Dev #2

Merged
sandrodach merged 37 commits from dev into main 2026-10-25 12:14:23 +01:00
12 changed files with 297 additions and 23 deletions
Showing only changes of commit 7f312fef14 - Show all commits

No files matched your search

+9
View File
@@ -34,6 +34,15 @@ func main() {
// Seed SMTP defaults from environment variables into DB if not yet configured in DB // Seed SMTP defaults from environment variables into DB if not yet configured in DB
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
if cfg.DemoMode {
log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
if err := store.EnsureDemoAccounts(); err != nil {
log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err)
} else {
log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
}
}
wsHub := ws.NewHub() wsHub := ws.NewHub()
staticFS, err := fs.Sub(embeddedDist, "dist") staticFS, err := fs.Sub(embeddedDist, "dist")
+2
View File
@@ -14,6 +14,8 @@ services:
- BASE_URL=${BASE_URL:-http://localhost:8080} - BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI) # Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- SMTP_HOST=${SMTP_HOST:-} - SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
+2
View File
@@ -14,6 +14,8 @@ services:
- BASE_URL=${BASE_URL:-http://localhost:8080} - BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false}
# Optional SMTP / Mail Configuration (can also be configured in Admin UI) # Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- SMTP_HOST=${SMTP_HOST:-} - SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
@@ -399,13 +399,44 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
{authTab === 'login' && ( {authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5"> <form onSubmit={handleLogin} className="space-y-3.5">
{authStatus.demoMode && (
<div className="p-2.5 rounded border border-accent/30 bg-accent/10 space-y-1.5">
<div className="flex items-center justify-between text-[11px] font-semibold text-accent">
<span>🧪 Demo-Modus aktiv</span>
<span className="text-[10px] text-muted font-normal">Klick zum Ausfüllen:</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setEmail('admin');
setPassword('admin');
}}
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
>
admin / admin
</button>
<button
type="button"
onClick={() => {
setEmail('user');
setPassword('user');
}}
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
>
user / user
</button>
</div>
</div>
)}
<div> <div>
<label className="block text-muted mb-1">E-Mail-Adresse</label> <label className="block text-muted mb-1">Benutzername oder E-Mail-Adresse</label>
<input <input
type="email" type="text"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de" placeholder="admin oder name@beispiel.de"
required required
autoFocus autoFocus
className="w-full h-8 px-2.5 input-field" className="w-full h-8 px-2.5 input-field"
+2 -2
View File
@@ -125,10 +125,10 @@ export const api = {
return res; return res;
}, },
async login(email: string, password: string): Promise<{ token: string; user: User }> { async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', { const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }),
}); });
setToken(res.token); setToken(res.token);
return res; return res;
+2
View File
@@ -6,11 +6,13 @@ export interface AuthStatus {
needsSetup: boolean; needsSetup: boolean;
allowRegistration: boolean; allowRegistration: boolean;
instanceName: string; instanceName: string;
demoMode?: boolean;
} }
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
username?: string;
displayName: string; displayName: string;
role: UserRole; role: UserRole;
isActive: boolean; isActive: boolean;
+18 -2
View File
@@ -301,6 +301,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
"needsSetup": count == 0, "needsSetup": count == 0,
"allowRegistration": allowReg, "allowRegistration": allowReg,
"instanceName": instanceName, "instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
}) })
} }
@@ -451,6 +452,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"`
Login string `json:"login"`
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -458,9 +461,22 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
user, err := s.store.GetUserByEmail(req.Email) identifier := strings.TrimSpace(req.Login)
if identifier == "" {
identifier = strings.TrimSpace(req.Username)
}
if identifier == "" {
identifier = strings.TrimSpace(req.Email)
}
if identifier == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich")
return
}
user, err := s.store.GetUserByLogin(identifier)
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) { if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
writeError(w, http.StatusUnauthorized, "E-Mail oder Passwort ungültig") writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return return
} }
if !user.IsActive { if !user.IsActive {
+87
View File
@@ -431,3 +431,90 @@ func TestNewFullFeatures(t *testing.T) {
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought) t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought)
} }
} }
func TestLoginWithUsernameAndDemoMode(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo_api_test.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
defer store.Close()
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("failed to seed demo accounts: %v", err)
}
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "demo-jwt-secret",
DemoMode: true,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Check auth status reports demoMode: true
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
}
var statusResp struct {
NeedsSetup bool `json:"needsSetup"`
DemoMode bool `json:"demoMode"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
if !statusResp.DemoMode {
t.Fatalf("expected demoMode=true, got %v", statusResp.DemoMode)
}
if statusResp.NeedsSetup {
t.Fatalf("expected needsSetup=false when demo accounts exist, got %v", statusResp.NeedsSetup)
}
// 2. Login with username "admin" / "admin"
loginBody := []byte(`{"email":"admin","password":"admin"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for admin login, got %d: %s", rec.Code, rec.Body.String())
}
var loginRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &loginRes)
if loginRes.Token == "" || loginRes.User.Role != "admin" {
t.Fatalf("invalid admin login response: %+v", loginRes)
}
// 3. Login with username "user" / "user"
userLoginBody := []byte(`{"username":"user","password":"user"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(userLoginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for user login, got %d: %s", rec.Code, rec.Body.String())
}
var userLoginRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &userLoginRes)
if userLoginRes.Token == "" || userLoginRes.User.Role != "member" {
t.Fatalf("invalid user login response: %+v", userLoginRes)
}
// 4. Invalid credentials must return 401
wrongLoginBody := []byte(`{"login":"admin","password":"wrongpassword"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(wrongLoginBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code)
}
}
+5
View File
@@ -14,6 +14,7 @@ type Config struct {
BaseURL string BaseURL string
AllowRegistration bool AllowRegistration bool
LogLevel string LogLevel string
DemoMode bool
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings) // Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
SMTPHost string SMTPHost string
@@ -41,6 +42,9 @@ func Load() *Config {
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true" allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
logLevel := getEnv("LOG_LEVEL", "info") logLevel := getEnv("LOG_LEVEL", "info")
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
smtpHost := getEnv("SMTP_HOST", "") smtpHost := getEnv("SMTP_HOST", "")
smtpPort := getEnv("SMTP_PORT", "587") smtpPort := getEnv("SMTP_PORT", "587")
smtpUser := getEnv("SMTP_USER", "") smtpUser := getEnv("SMTP_USER", "")
@@ -56,6 +60,7 @@ func Load() *Config {
BaseURL: baseURL, BaseURL: baseURL,
AllowRegistration: allowReg, AllowRegistration: allowReg,
LogLevel: logLevel, LogLevel: logLevel,
DemoMode: demoMode,
SMTPHost: smtpHost, SMTPHost: smtpHost,
SMTPPort: smtpPort, SMTPPort: smtpPort,
SMTPUser: smtpUser, SMTPUser: smtpUser,
+1
View File
@@ -95,6 +95,7 @@ func FormatGermanDateTime(isoStr string) string {
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username,omitempty"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` // admin | team_admin | member Role string `json:"role"` // admin | team_admin | member
+78 -16
View File
@@ -68,6 +68,7 @@ func (s *Store) migrate() error {
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
username TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', role TEXT NOT NULL DEFAULT 'member',
@@ -267,6 +268,8 @@ func (s *Store) migrate() error {
// Idempotent column migrations for existing databases // Idempotent column migrations for existing databases
columnMigrations := []string{ columnMigrations := []string{
"ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';",
"CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);",
"ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';",
"ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;", "ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;",
"ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';", "ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';",
@@ -291,15 +294,8 @@ func (s *Store) migrate() error {
_, _ = s.db.Exec(q) _, _ = s.db.Exec(q)
} }
// Remove legacy auto-seeded default user ('admin@mindsift.local') so First-Boot Setup triggers cleanly // Backfill username for existing accounts where username is empty
var legacyUserID string _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`)
if err := s.db.QueryRow(`SELECT id FROM users WHERE email = 'admin@mindsift.local'`).Scan(&legacyUserID); err == nil && legacyUserID != "" {
_, _ = s.db.Exec(`DELETE FROM thoughts WHERE user_id = ?`, legacyUserID)
_, _ = s.db.Exec(`DELETE FROM documents WHERE user_id = ?`, legacyUserID)
_, _ = s.db.Exec(`DELETE FROM spaces WHERE owner_id = ?`, legacyUserID)
_, _ = s.db.Exec(`DELETE FROM task_lists WHERE user_id = ?`, legacyUserID)
_, _ = s.db.Exec(`DELETE FROM users WHERE id = ?`, legacyUserID)
}
// Default system settings (public registration disabled by default; invite or admin creation required) // Default system settings (public registration disabled by default; invite or admin creation required)
_, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES _, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES
@@ -397,7 +393,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) {
// --- USERS --- // --- USERS ---
const userSelectCols = `id, email, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at` const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at`
func parseAlarmChannels(raw string) []string { func parseAlarmChannels(raw string) []string {
parts := strings.Split(raw, ",") parts := strings.Split(raw, ",")
@@ -418,7 +414,7 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) {
var isActiveInt int var isActiveInt int
var channelsRaw string var channelsRaw string
err := row.Scan( err := row.Scan(
&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName,
&u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail, &u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail,
&u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt, &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.CreatedAt,
) )
@@ -443,13 +439,24 @@ func (s *Store) CountUsers() (int, error) {
} }
func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) { func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*domain.User, error) {
cleanEmail := strings.ToLower(strings.TrimSpace(email))
username := strings.Split(cleanEmail, "@")[0]
return s.CreateUserWithUsername(cleanEmail, username, passwordHash, displayName, role)
}
func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) {
if role != "admin" && role != "team_admin" && role != "member" { if role != "admin" && role != "team_admin" && role != "member" {
role = "member" role = "member"
} }
cleanEmail := strings.ToLower(strings.TrimSpace(email)) cleanEmail := strings.ToLower(strings.TrimSpace(email))
cleanUsername := strings.ToLower(strings.TrimSpace(username))
if cleanUsername == "" {
cleanUsername = strings.Split(cleanEmail, "@")[0]
}
u := &domain.User{ u := &domain.User{
ID: domain.NewID("usr"), ID: domain.NewID("usr"),
Email: cleanEmail, Email: cleanEmail,
Username: cleanUsername,
PasswordHash: passwordHash, PasswordHash: passwordHash,
DisplayName: strings.TrimSpace(displayName), DisplayName: strings.TrimSpace(displayName),
Role: role, Role: role,
@@ -462,9 +469,9 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
CreatedAt: domain.NowISO(), CreatedAt: domain.NowISO(),
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO users (id, email, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at) `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, created_at)
VALUES (?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`, VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, ?)`,
u.ID, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -473,13 +480,68 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma
} }
func (s *Store) GetUserByEmail(email string) (*domain.User, error) { func (s *Store) GetUserByEmail(email string) (*domain.User, error) {
return s.GetUserByLogin(email)
}
func (s *Store) GetUserByLogin(login string) (*domain.User, error) {
cleanLogin := strings.ToLower(strings.TrimSpace(login))
if cleanLogin == "" {
return nil, errors.New("benutzername oder e-mail darf nicht leer sein")
}
row := s.db.QueryRow( row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users WHERE email = ?`, `SELECT `+userSelectCols+` FROM users
strings.ToLower(strings.TrimSpace(email)), WHERE LOWER(email) = ?
OR (username != '' AND LOWER(username) = ?)
OR LOWER(display_name) = ?
OR (INSTR(email, '@') > 0 AND LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) = ?)
LIMIT 1`,
cleanLogin, cleanLogin, cleanLogin, cleanLogin,
) )
return scanUser(row) return scanUser(row)
} }
// EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation
func (s *Store) EnsureDemoAccounts() error {
s.initMu.Lock()
defer s.initMu.Unlock()
adminHash, err := auth.HashPassword("admin")
if err != nil {
return err
}
userHash, err := auth.HashPassword("user")
if err != nil {
return err
}
// 1. Admin user (admin / admin)
adminUser, err := s.GetUserByLogin("admin")
if err != nil || adminUser == nil {
adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin")
if err != nil {
return fmt.Errorf("failed to create demo admin: %w", err)
}
_ = s.SeedStarterContent(adminUser)
} else {
_ = s.UpdateUserPassword(adminUser.ID, adminHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1, role = 'admin' WHERE id = ?`, adminUser.ID)
}
// 2. Regular user (user / user)
normalUser, err := s.GetUserByLogin("user")
if err != nil || normalUser == nil {
_, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member")
if err != nil {
return fmt.Errorf("failed to create demo user: %w", err)
}
} else {
_ = s.UpdateUserPassword(normalUser.ID, userHash)
_, _ = s.db.Exec(`UPDATE users SET is_active = 1 WHERE id = ?`, normalUser.ID)
}
return nil
}
func (s *Store) GetUserByID(id string) (*domain.User, error) { func (s *Store) GetUserByID(id string) (*domain.User, error) {
row := s.db.QueryRow( row := s.db.QueryRow(
`SELECT `+userSelectCols+` FROM users WHERE id = ?`, `SELECT `+userSelectCols+` FROM users WHERE id = ?`,
+57
View File
@@ -4,6 +4,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/domain"
) )
@@ -162,3 +163,59 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded") t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded")
} }
} }
func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "demo_test.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("EnsureDemoAccounts failed: %v", err)
}
// 1. Lookup by username "admin"
adminUser, err := store.GetUserByLogin("admin")
if err != nil || adminUser == nil {
t.Fatalf("expected to find admin user by username 'admin', got %v", err)
}
if adminUser.Role != "admin" {
t.Fatalf("expected role 'admin', got %s", adminUser.Role)
}
if !auth.CheckPassword("admin", adminUser.PasswordHash) {
t.Fatalf("admin password check failed")
}
// 2. Lookup by email "admin@mindsift.local"
adminByEmail, err := store.GetUserByLogin("admin@mindsift.local")
if err != nil || adminByEmail.ID != adminUser.ID {
t.Fatalf("lookup by email failed: got %v", err)
}
// 3. Lookup with case insensitivity "ADMIN"
adminCaps, err := store.GetUserByLogin("ADMIN")
if err != nil || adminCaps.ID != adminUser.ID {
t.Fatalf("lookup by uppercase failed: got %v", err)
}
// 4. Lookup by username "user"
normalUser, err := store.GetUserByLogin("user")
if err != nil || normalUser == nil {
t.Fatalf("expected to find user by username 'user', got %v", err)
}
if normalUser.Role != "member" {
t.Fatalf("expected role 'member', got %s", normalUser.Role)
}
if !auth.CheckPassword("user", normalUser.PasswordHash) {
t.Fatalf("user password check failed")
}
// 5. Ensure re-running is idempotent and preserves/refreshes credentials
if err := store.EnsureDemoAccounts(); err != nil {
t.Fatalf("second EnsureDemoAccounts call failed: %v", err)
}
}