package userstore_test import ( "context" "os" "strings" "testing" "time" "github.com/jackc/pgx/v5" "archivmail/internal/userstore" ) func newTestStore(t *testing.T) *userstore.Store { t.Helper() dsn := os.Getenv("TEST_DATABASE_URL") if dsn == "" { t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)") } // Use a unique schema per test to isolate schema := "test_" + strings.ReplaceAll(t.Name(), "/", "_") schema = strings.ToLower(schema) // Append schema to DSN sep := "?" if strings.Contains(dsn, "?") { sep = "&" } schemaDSN := dsn + sep + "search_path=" + schema // Create schema ctx := context.Background() conn, err := pgx.Connect(ctx, dsn) if err != nil { t.Fatalf("connect: %v", err) } conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema) conn.Close(ctx) s, err := userstore.New(schemaDSN) if err != nil { t.Fatalf("userstore.New: %v", err) } t.Cleanup(func() { s.Close() conn2, _ := pgx.Connect(context.Background(), dsn) if conn2 != nil { conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE") conn2.Close(context.Background()) } }) return s } func TestCreateAndGetUser(t *testing.T) { s := newTestStore(t) u, err := s.Create(userstore.CreateUserRequest{ Username: "alice", Email: "alice@example.com", Password: "secret123", Role: userstore.RoleAdmin, }) if err != nil { t.Fatalf("Create: %v", err) } if u.ID == 0 { t.Error("expected non-zero ID") } if u.Username != "alice" { t.Errorf("Username = %q", u.Username) } if u.Role != userstore.RoleAdmin { t.Errorf("Role = %q", u.Role) } if u.Source != "local" { t.Errorf("Source = %q, want local", u.Source) } got, err := s.GetByID(u.ID) if err != nil { t.Fatalf("GetByID: %v", err) } if got.Email != "alice@example.com" { t.Errorf("Email = %q", got.Email) } } func TestVerifyPassword(t *testing.T) { s := newTestStore(t) _, err := s.Create(userstore.CreateUserRequest{ Username: "bob", Email: "bob@example.com", Password: "correcthorse", Role: userstore.RoleUser, }) if err != nil { t.Fatal(err) } // Correct password u, err := s.VerifyPassword("bob", "correcthorse") if err != nil { t.Errorf("VerifyPassword correct: %v", err) } if u.Username != "bob" { t.Errorf("Username = %q", u.Username) } // Wrong password if _, err := s.VerifyPassword("bob", "wrongpassword"); err == nil { t.Error("expected error for wrong password") } // Non-existent user if _, err := s.VerifyPassword("nobody", "x"); err == nil { t.Error("expected error for unknown user") } } // TestVerifyLogin covers the full PROJ-46 login-identifier matrix. func TestVerifyLogin(t *testing.T) { s := newTestStore(t) ctx := context.Background() tenantID := int64(1) // Tenant user: username "patrick" != email if _, err := s.Create(userstore.CreateUserRequest{ Username: "patrick", Email: "patrick@perlbach24.de", Password: "pw-tenant", Role: userstore.RoleUser, TenantID: &tenantID, }); err != nil { t.Fatal(err) } // Non-tenant user (superadmin/system), tenant_id IS NULL if _, err := s.Create(userstore.CreateUserRequest{ Username: "superadmin", Email: "superadmin@localhost", Password: "pw-super", Role: userstore.RoleSuperAdmin, TenantID: nil, }); err != nil { t.Fatal(err) } // 1. Tenant user via email → success u, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "pw-tenant") if err != nil { t.Fatalf("tenant user via email should succeed: %v", err) } if u.Username != "patrick" { t.Errorf("Username = %q, want patrick", u.Username) } // 2. Tenant user via username (≠ email) → invalid_credentials if _, err := s.VerifyLogin(ctx, "patrick", "pw-tenant"); err == nil { t.Error("tenant user via username should be rejected") } // 3. Non-tenant user via username → success u, err = s.VerifyLogin(ctx, "superadmin", "pw-super") if err != nil { t.Fatalf("non-tenant user via username should succeed: %v", err) } if u.Username != "superadmin" { t.Errorf("Username = %q, want superadmin", u.Username) } // 4. Non-tenant user via email → success u, err = s.VerifyLogin(ctx, "superadmin@localhost", "pw-super") if err != nil { t.Fatalf("non-tenant user via email should succeed: %v", err) } if u.Username != "superadmin" { t.Errorf("Username = %q, want superadmin", u.Username) } // 5. Unknown identifier → invalid_credentials if _, err := s.VerifyLogin(ctx, "ghost@nowhere.tld", "x"); err == nil { t.Error("unknown identifier should be rejected") } if _, err := s.VerifyLogin(ctx, "ghost", "x"); err == nil { t.Error("unknown username should be rejected") } // Wrong password for valid identifier → rejected if _, err := s.VerifyLogin(ctx, "patrick@perlbach24.de", "wrong"); err == nil { t.Error("wrong password should be rejected") } } func TestUpdateUser(t *testing.T) { s := newTestStore(t) u, _ := s.Create(userstore.CreateUserRequest{ Username: "carol", Email: "carol@old.com", Password: "pw", Role: userstore.RoleUser, }) newEmail := "carol@new.com" newRole := userstore.RoleAuditor updated, err := s.Update(u.ID, userstore.UpdateUserRequest{ Email: &newEmail, Role: &newRole, }) if err != nil { t.Fatalf("Update: %v", err) } if updated.Email != "carol@new.com" { t.Errorf("Email after update = %q", updated.Email) } if updated.Role != userstore.RoleAuditor { t.Errorf("Role after update = %q", updated.Role) } } func TestDisableUser(t *testing.T) { s := newTestStore(t) u, _ := s.Create(userstore.CreateUserRequest{ Username: "dave", Email: "dave@x.com", Password: "pw", Role: userstore.RoleUser, }) active := false s.Update(u.ID, userstore.UpdateUserRequest{Active: &active}) if _, err := s.VerifyPassword("dave", "pw"); err == nil { t.Error("disabled user should not be able to login") } } func TestDeleteUser(t *testing.T) { s := newTestStore(t) u, _ := s.Create(userstore.CreateUserRequest{ Username: "eve", Email: "eve@x.com", Password: "pw", Role: userstore.RoleUser, }) if err := s.Delete(u.ID); err != nil { t.Fatalf("Delete: %v", err) } if _, err := s.GetByID(u.ID); err == nil { t.Error("GetByID should error after delete") } // Delete non-existent should error if err := s.Delete(u.ID); err == nil { t.Error("second delete should return error") } } func TestListUsers(t *testing.T) { s := newTestStore(t) users := []userstore.CreateUserRequest{ {Username: "u1", Email: "u1@x.com", Password: "pw", Role: userstore.RoleUser}, {Username: "u2", Email: "u2@x.com", Password: "pw", Role: userstore.RoleAdmin}, {Username: "u3", Email: "u3@x.com", Password: "pw", Role: userstore.RoleAuditor}, {Username: "u4", Email: "u4@x.com", Password: "pw", Role: userstore.RoleUser}, } for _, req := range users { s.Create(req) } all, err := s.List("") if err != nil { t.Fatalf("List all: %v", err) } if len(all) != 4 { t.Errorf("List all: got %d, want 4", len(all)) } admins, _ := s.List(userstore.RoleAdmin) if len(admins) != 1 { t.Errorf("List admin: got %d, want 1", len(admins)) } regular, _ := s.List(userstore.RoleUser) if len(regular) != 2 { t.Errorf("List user: got %d, want 2", len(regular)) } } func TestTokenBlacklist(t *testing.T) { s := newTestStore(t) jti := "test-jti-12345" expires := time.Now().Add(1 * time.Hour) if err := s.BlacklistToken(jti, expires); err != nil { t.Fatalf("BlacklistToken: %v", err) } blacklisted, err := s.IsBlacklisted(jti) if err != nil { t.Fatalf("IsBlacklisted: %v", err) } if !blacklisted { t.Error("token should be blacklisted") } // Non-blacklisted token bl2, _ := s.IsBlacklisted("other-jti") if bl2 { t.Error("unknown token should not be blacklisted") } } func TestCleanExpiredTokens(t *testing.T) { s := newTestStore(t) // Add an already-expired token s.BlacklistToken("expired-jti", time.Now().Add(-1*time.Hour)) // Add a valid token s.BlacklistToken("valid-jti", time.Now().Add(1*time.Hour)) if err := s.CleanExpiredTokens(); err != nil { t.Fatalf("CleanExpiredTokens: %v", err) } bl, _ := s.IsBlacklisted("expired-jti") if bl { t.Error("expired token should be cleaned up") } bl2, _ := s.IsBlacklisted("valid-jti") if !bl2 { t.Error("valid token should still be blacklisted") } } func TestUpsertLDAPUser(t *testing.T) { s := newTestStore(t) u, err := s.UpsertLDAPUser("ldapuser", "ldap@corp.com", userstore.RoleAuditor, nil) if err != nil { t.Fatalf("UpsertLDAPUser: %v", err) } if u.Source != "ldap" { t.Errorf("Source = %q, want ldap", u.Source) } // Second upsert should update, not duplicate u2, err := s.UpsertLDAPUser("ldapuser", "ldap@corp.com", userstore.RoleAuditor, nil) if err != nil { t.Fatalf("UpsertLDAPUser second: %v", err) } if u2.ID != u.ID { t.Error("second upsert should not create a new record") } all, _ := s.List("") if len(all) != 1 { t.Errorf("expected 1 user after double upsert, got %d", len(all)) } }