package main import ( "context" "database/sql" "errors" "testing" "time" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/outbox" "atlas9.dev/c/core/tokens" _ "github.com/mattn/go-sqlite3" ) // mockMailer records emails sent during tests. type mockMailer struct { calls []mailCall } type mailCall struct { Email string Subject string Body string } func (m *mockMailer) Send(ctx context.Context, email, subject, body string) error { m.calls = append(m.calls, mailCall{Email: email, Subject: subject, Body: body}) return nil } // memorySession is a simple in-memory SessionStore for tests. type memorySession struct { id core.ID } func (s *memorySession) Get(ctx context.Context) (core.ID, error) { return s.id, nil } func (s *memorySession) Put(ctx context.Context, id core.ID) error { s.id = id return nil } func (s *memorySession) Destroy(ctx context.Context) error { s.id = core.ID{} return nil } func setupTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if err := runMigrations(db); err != nil { t.Fatal(err) } return db } func setupTestService(t *testing.T) (*IdentityApi, *memorySession, *mockMailer) { t.Helper() db := setupTestDB(t) session := &memorySession{} mailer := &mockMailer{} consumers := []string{"welcome_email"} verificationTokens := tokens.NewSecureStore( tokens.NewSqliteStore[tokens.VerifiedData[VerificationData]](db, tokens.Options{ Table: "email_verification_tokens", Expiration: 24 * time.Hour, }), tokens.RandomString(32), ) resetTokens := tokens.NewSecureStore( tokens.NewSqliteStore[tokens.VerifiedData[PasswordResetData]](db, tokens.Options{ Table: "password_reset_tokens", Expiration: 1 * time.Hour, }), tokens.RandomString(32), ) svc := &IdentityApi{ DB: db, Users: func(tx dbi.DBI) iam.UserStore { return NewSqliteUserStore(tx) }, Passwords: func(tx dbi.DBI) iam.PasswordStore { return NewSqlitePasswordHashStore(tx) }, Profiles: func(tx dbi.DBI) *ProfileStore { return NewProfileStore(tx) }, Tenants: func(tx dbi.DBI) iam.TenantStore { return NewSqliteTenantStore(tx) }, TenantMembers: func(tx dbi.DBI) iam.TenantMemberStore { return NewSqliteTenantMemberStore(tx) }, Outbox: func(tx dbi.DBI) outbox.Emitter { return outbox.NewSqliteStore(tx, consumers, false) }, Sessions: session, VerificationTokens: verificationTokens, ResetTokens: resetTokens, Mailer: mailer, BaseURL: "http://localhost:8010", } return svc, session, mailer } // createResetToken creates a password reset token directly for testing. func createResetToken(t *testing.T, svc *IdentityApi, userID core.ID) string { t.Helper() tok, err := svc.ResetTokens.Put(context.Background(), userID.String(), PasswordResetData{ UserID: userID, }) if err != nil { t.Fatalf("creating reset token: %v", err) } return tok.Key } // createVerificationToken creates a verification token directly for testing. // In production, the outbox worker creates these asynchronously via SendWelcomeEmail. func createVerificationToken(t *testing.T, svc *IdentityApi, userID core.ID) string { t.Helper() tok, err := svc.VerificationTokens.Put(context.Background(), userID.String(), VerificationData{ UserID: userID, }) if err != nil { t.Fatalf("creating verification token: %v", err) } return tok.Key } // registerAndVerify is a helper that registers a user and verifies their email. func registerAndVerify(t *testing.T, svc *IdentityApi, email, password string) core.ID { t.Helper() ctx := context.Background() userID, err := svc.Register(ctx, email, password) if err != nil { t.Fatalf("Register: %v", err) } combined := createVerificationToken(t, svc, userID) if err := svc.Verify(ctx, combined); err != nil { t.Fatalf("Verify: %v", err) } return userID } // --- Registration Tests --- func TestRegister(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() userID, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } if userID.IsZero() { t.Error("expected non-zero user ID") } } func TestRegisterCreatesPersonalTenant(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() userID, err := svc.Register(ctx, "tenant@example.com", "password123") if err != nil { t.Fatal(err) } // Personal tenant should exist with ID == user ID tenant, err := dbi.ReadOnly(ctx, svc.DB, func(tx dbi.DBI) (*iam.Tenant, error) { return svc.Tenants(tx).Get(ctx, userID) }) if err != nil { t.Fatalf("expected personal tenant, got error: %v", err) } if !tenant.ID.Equal(userID) { t.Errorf("tenant ID = %s, want user ID %s", tenant.ID, userID) } if tenant.Type != iam.TenantPersonal { t.Errorf("tenant type = %q, want %q", tenant.Type, iam.TenantPersonal) } if tenant.Name != "tenant@example.com" { t.Errorf("tenant name = %q, want %q", tenant.Name, "tenant@example.com") } } func TestRegisterCreatesTenantMembership(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() userID, err := svc.Register(ctx, "member@example.com", "password123") if err != nil { t.Fatal(err) } // User should be an active member of their personal tenant member, err := dbi.ReadOnly(ctx, svc.DB, func(tx dbi.DBI) (*iam.TenantMember, error) { return svc.TenantMembers(tx).Get(ctx, userID, userID) }) if err != nil { t.Fatalf("expected tenant membership, got error: %v", err) } if member.Status != iam.MemberActive { t.Errorf("membership status = %q, want %q", member.Status, iam.MemberActive) } } func TestRegisterCreatesUnverifiedUser(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() userID, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } user, err := dbi.ReadOnly(ctx, svc.DB, func(tx dbi.DBI) (*iam.User, error) { return svc.Users(tx).Get(ctx, userID) }) if err != nil { t.Fatal(err) } if user.Verified { t.Error("newly registered user should not be verified") } } func TestRegisterDuplicateEmail(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() _, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } _, err = svc.Register(ctx, "test@example.com", "password456") if !errors.Is(err, ErrUserExists) { t.Errorf("expected ErrUserExists, got %v", err) } } func TestRegisterEmitsEvent(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() _, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } var count int err = svc.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM outbox WHERE event_type = 'user.registered'", ).Scan(&count) if err != nil { t.Fatal(err) } if count != 1 { t.Errorf("expected 1 outbox event, got %d", count) } } // --- Login Tests --- func TestLoginSuccess(t *testing.T) { svc, session, _ := setupTestService(t) ctx := context.Background() registerAndVerify(t, svc, "test@example.com", "password123") // Clear session from registration/verification session.Destroy(ctx) userID, err := svc.Login(ctx, "test@example.com", "password123") if err != nil { t.Fatalf("Login: %v", err) } if userID.IsZero() { t.Error("expected non-zero user ID") } // Session should be set sessionID, _ := session.Get(ctx) if !sessionID.Equal(userID) { t.Errorf("session ID = %s, want %s", sessionID, userID) } } func TestLoginUnverifiedEmail(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() _, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } _, err = svc.Login(ctx, "test@example.com", "password123") if !errors.Is(err, ErrEmailNotVerified) { t.Errorf("expected ErrEmailNotVerified, got %v", err) } } func TestLoginWrongPassword(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() registerAndVerify(t, svc, "test@example.com", "password123") _, err := svc.Login(ctx, "test@example.com", "wrongpassword") if !errors.Is(err, ErrInvalidCredentials) { t.Errorf("expected ErrInvalidCredentials, got %v", err) } } func TestLoginNonexistentEmail(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() _, err := svc.Login(ctx, "nobody@example.com", "password123") if !errors.Is(err, ErrInvalidCredentials) { t.Errorf("expected ErrInvalidCredentials, got %v", err) } } // --- Email Verification Tests --- func TestVerifyValidToken(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() userID, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } // User should be unverified user, err := dbi.ReadOnly(ctx, svc.DB, func(tx dbi.DBI) (*iam.User, error) { return svc.Users(tx).Get(ctx, userID) }) if err != nil { t.Fatal(err) } if user.Verified { t.Fatal("user should be unverified before verification") } combined := createVerificationToken(t, svc, userID) if err := svc.Verify(ctx, combined); err != nil { t.Fatal(err) } // User should now be verified user, err = dbi.ReadOnly(ctx, svc.DB, func(tx dbi.DBI) (*iam.User, error) { return svc.Users(tx).Get(ctx, userID) }) if err != nil { t.Fatal(err) } if !user.Verified { t.Error("user should be verified after verification") } // Token should be deleted var count int err = svc.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM email_verification_tokens", ).Scan(&count) if err != nil { t.Fatal(err) } if count != 0 { t.Errorf("expected 0 tokens after verification, got %d", count) } } func TestVerifyInvalidToken(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() err := svc.Verify(ctx, "invalid.token") if err == nil { t.Error("expected error for invalid token") } } func TestVerifyEmptyToken(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() err := svc.Verify(ctx, "") if err == nil { t.Error("expected error for empty token") } } func TestVerifyThenLogin(t *testing.T) { svc, session, _ := setupTestService(t) ctx := context.Background() userID, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } // Cannot login before verification _, err = svc.Login(ctx, "test@example.com", "password123") if !errors.Is(err, ErrEmailNotVerified) { t.Fatalf("expected ErrEmailNotVerified, got %v", err) } // Verify combined := createVerificationToken(t, svc, userID) if err := svc.Verify(ctx, combined); err != nil { t.Fatal(err) } // Now login succeeds loginID, err := svc.Login(ctx, "test@example.com", "password123") if err != nil { t.Fatalf("Login after verification: %v", err) } if !loginID.Equal(userID) { t.Errorf("login ID = %s, want %s", loginID, userID) } sessionID, _ := session.Get(ctx) if !sessionID.Equal(userID) { t.Errorf("session ID = %s, want %s", sessionID, userID) } } // --- Logout Tests --- func TestLogout(t *testing.T) { svc, session, _ := setupTestService(t) ctx := context.Background() registerAndVerify(t, svc, "test@example.com", "password123") session.Destroy(ctx) _, err := svc.Login(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } // Session should be active id, _ := session.Get(ctx) if id.IsZero() { t.Fatal("session should be active after login") } if err := svc.Logout(ctx); err != nil { t.Fatal(err) } // Session should be cleared id, _ = session.Get(ctx) if !id.IsZero() { t.Error("session should be cleared after logout") } } // --- Profile Tests --- func TestGetProfileNotLoggedIn(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() _, _, err := svc.GetProfile(ctx) if !errors.Is(err, ErrNotLoggedIn) { t.Errorf("expected ErrNotLoggedIn, got %v", err) } } func TestUpdateProfileNotLoggedIn(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() err := svc.UpdateProfile(ctx, &Profile{Name: "test"}) if !errors.Is(err, ErrNotLoggedIn) { t.Errorf("expected ErrNotLoggedIn, got %v", err) } } func TestProfileRoundTrip(t *testing.T) { svc, session, _ := setupTestService(t) ctx := context.Background() registerAndVerify(t, svc, "test@example.com", "password123") session.Destroy(ctx) _, err := svc.Login(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } err = svc.UpdateProfile(ctx, &Profile{ Name: "Test User", Bio: "A test bio", Location: "Testville", Website: "https://test.com", }) if err != nil { t.Fatal(err) } user, profile, err := svc.GetProfile(ctx) if err != nil { t.Fatal(err) } if user.Email != "test@example.com" { t.Errorf("email = %q, want %q", user.Email, "test@example.com") } if profile.Name != "Test User" { t.Errorf("name = %q, want %q", profile.Name, "Test User") } if profile.Bio != "A test bio" { t.Errorf("bio = %q, want %q", profile.Bio, "A test bio") } if profile.Location != "Testville" { t.Errorf("location = %q, want %q", profile.Location, "Testville") } if profile.Website != "https://test.com" { t.Errorf("website = %q, want %q", profile.Website, "https://test.com") } } // --- Integration Tests --- func TestFullRegistrationFlow(t *testing.T) { svc, session, _ := setupTestService(t) ctx := context.Background() // 1. Register userID, err := svc.Register(ctx, "flow@example.com", "securepass") if err != nil { t.Fatalf("Register: %v", err) } // 2. Cannot login before verification _, err = svc.Login(ctx, "flow@example.com", "securepass") if !errors.Is(err, ErrEmailNotVerified) { t.Fatalf("expected ErrEmailNotVerified, got %v", err) } // 3. Verify email combinedToken := createVerificationToken(t, svc, userID) if err := svc.Verify(ctx, combinedToken); err != nil { t.Fatalf("Verify: %v", err) } // 4. Login succeeds loginID, err := svc.Login(ctx, "flow@example.com", "securepass") if err != nil { t.Fatalf("Login after verification: %v", err) } // 5. Session is set sessionID, _ := session.Get(ctx) if !sessionID.Equal(loginID) { t.Errorf("session ID = %s, want %s", sessionID, loginID) } // 6. Logout if err := svc.Logout(ctx); err != nil { t.Fatalf("Logout: %v", err) } // 7. Session is cleared sessionID, _ = session.Get(ctx) if !sessionID.IsZero() { t.Error("session should be cleared after logout") } } // --- Password Reset Tests --- func TestRequestPasswordReset(t *testing.T) { svc, _, mailer := setupTestService(t) ctx := context.Background() registerAndVerify(t, svc, "test@example.com", "password123") err := svc.RequestPasswordReset(ctx, "test@example.com") if err != nil { t.Fatal(err) } if len(mailer.calls) != 1 { t.Fatalf("expected 1 email sent, got %d", len(mailer.calls)) } if mailer.calls[0].Email != "test@example.com" { t.Errorf("email = %q, want %q", mailer.calls[0].Email, "test@example.com") } if mailer.calls[0].Subject != "Reset your password" { t.Errorf("subject = %q, want %q", mailer.calls[0].Subject, "Reset your password") } } func TestRequestPasswordResetNonexistentEmail(t *testing.T) { svc, _, mailer := setupTestService(t) ctx := context.Background() err := svc.RequestPasswordReset(ctx, "nobody@example.com") if err != nil { t.Errorf("expected nil error for nonexistent email, got %v", err) } if len(mailer.calls) != 0 { t.Errorf("expected no emails sent, got %d", len(mailer.calls)) } } func TestResetPassword(t *testing.T) { svc, session, _ := setupTestService(t) ctx := context.Background() userID := registerAndVerify(t, svc, "test@example.com", "oldpass") session.Destroy(ctx) // Create reset token and reset password combined := createResetToken(t, svc, userID) err := svc.ResetPassword(ctx, combined, "newpass") if err != nil { t.Fatal(err) } // Old password should fail _, err = svc.Login(ctx, "test@example.com", "oldpass") if !errors.Is(err, ErrInvalidCredentials) { t.Errorf("expected ErrInvalidCredentials with old password, got %v", err) } // New password should work _, err = svc.Login(ctx, "test@example.com", "newpass") if err != nil { t.Errorf("expected login with new password to succeed, got %v", err) } } func TestResetPasswordInvalidToken(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() err := svc.ResetPassword(ctx, "invalid.token", "newpass") if err == nil { t.Error("expected error for invalid token") } } func TestResetPasswordDeletesToken(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() userID := registerAndVerify(t, svc, "test@example.com", "password123") combined := createResetToken(t, svc, userID) err := svc.ResetPassword(ctx, combined, "newpass") if err != nil { t.Fatal(err) } // Token should be consumed — second use fails err = svc.ResetPassword(ctx, combined, "anotherpass") if err == nil { t.Error("expected error reusing reset token") } } // --- Resend Verification Tests --- func TestResendVerification(t *testing.T) { svc, _, mailer := setupTestService(t) ctx := context.Background() // Register but don't verify _, err := svc.Register(ctx, "test@example.com", "password123") if err != nil { t.Fatal(err) } err = svc.ResendVerification(ctx, "test@example.com") if err != nil { t.Fatal(err) } if len(mailer.calls) != 1 { t.Fatalf("expected 1 email sent, got %d", len(mailer.calls)) } if mailer.calls[0].Email != "test@example.com" { t.Errorf("email = %q, want %q", mailer.calls[0].Email, "test@example.com") } } func TestResendVerificationAlreadyVerified(t *testing.T) { svc, _, mailer := setupTestService(t) ctx := context.Background() registerAndVerify(t, svc, "test@example.com", "password123") err := svc.ResendVerification(ctx, "test@example.com") if err != nil { t.Errorf("expected nil for already-verified user, got %v", err) } if len(mailer.calls) != 0 { t.Errorf("expected no emails for already-verified user, got %d", len(mailer.calls)) } } func TestResendVerificationNonexistentEmail(t *testing.T) { svc, _, _ := setupTestService(t) ctx := context.Background() err := svc.ResendVerification(ctx, "nobody@example.com") if !errors.Is(err, ErrInvalidCredentials) { t.Errorf("expected ErrInvalidCredentials, got %v", err) } }