package auth import ( "context" "database/sql" "errors" "net/url" "strings" "testing" "time" "github.com/tim/cairnquire/apps/server/internal/database" ) type testEmailSender struct { to []string subject string body string err error } func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error { s.to = append([]string(nil), to...) s.subject = subject s.body = body return s.err } func setupAuthTestService(t *testing.T) *Service { t.Helper() db, err := sql.Open("libsql", "file:"+t.TempDir()+"/auth.db") if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := database.ApplyMigrations(context.Background(), db); err != nil { t.Fatalf("apply migrations: %v", err) } service, err := NewService(NewRepository(db), "http://localhost:8080") if err != nil { t.Fatalf("new auth service: %v", err) } return service } func setupInitialAdmin(t *testing.T, service *Service, signupsEnabled bool) User { t.Helper() user, err := service.CompleteInitialSetup(context.Background(), "admin@example.com", "Admin", "correct horse battery staple", signupsEnabled) if err != nil { t.Fatalf("CompleteInitialSetup() error = %v", err) } return user } func TestPasswordLoginCreatesValidSession(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() user, err := service.CompleteInitialSetup(ctx, "Dev@Example.com", "Dev User", "correct horse battery staple", true) if err != nil { t.Fatalf("CompleteInitialSetup() error = %v", err) } if user.Email != "dev@example.com" { t.Fatalf("email = %q, want normalized", user.Email) } principal, token, err := service.LoginPassword(ctx, "dev@example.com", "correct horse battery staple", "127.0.0.1", "test") if err != nil { t.Fatalf("LoginPassword() error = %v", err) } if token == "" { t.Fatal("expected session token") } if principal.Role != RoleAdmin { t.Fatalf("role = %s, want initial setup user to be admin", principal.Role) } validated, err := service.ValidateSessionToken(ctx, token) if err != nil { t.Fatalf("ValidateSessionToken() error = %v", err) } if validated.UserID != principal.UserID { t.Fatalf("validated user = %q, want %q", validated.UserID, principal.UserID) } } func TestAPIKeyUsesShownOnceBearerToken(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() user := setupInitialAdmin(t, service, true) expires := time.Now().UTC().Add(time.Hour) created, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead, ScopeSyncWrite}, &expires) if err != nil { t.Fatalf("CreateAPIKey() error = %v", err) } if !strings.HasPrefix(created.Token, "cq_pat_") { t.Fatalf("token prefix = %q, want cq_pat_", created.Token) } if created.Record.KeyHash != "" && strings.Contains(created.Record.KeyHash, created.Token) { t.Fatal("record contains raw token") } principal, err := service.ValidateBearerToken(ctx, created.Token) if err != nil { t.Fatalf("ValidateBearerToken() error = %v", err) } if principal.APIKeyID != created.Record.ID { t.Fatalf("api key id = %q, want %q", principal.APIKeyID, created.Record.ID) } if !Allows(principal, ScopeDocsRead) { t.Fatal("expected docs:read to be allowed") } if Allows(principal, ScopeAdmin) { t.Fatal("did not expect admin scope to be allowed") } } func TestDeviceFlowMintsAPIKeyAfterApproval(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() user := setupInitialAdmin(t, service, true) start, err := service.StartDeviceFlow(ctx, "Laptop", []Scope{ScopeSyncRead}) if err != nil { t.Fatalf("StartDeviceFlow() error = %v", err) } if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "authorization_pending" || err == nil { t.Fatalf("PollDeviceFlow() before approval code=%q err=%v, want authorization_pending", code, err) } _, err = service.ApproveDeviceFlow(ctx, principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)), start.UserCode) if err != nil { t.Fatalf("ApproveDeviceFlow() error = %v", err) } created, code, err := service.PollDeviceFlow(ctx, start.DeviceCode) if err != nil { t.Fatalf("PollDeviceFlow() after approval code=%q err=%v", code, err) } if created.Token == "" { t.Fatal("expected device flow to mint api token") } if _, code, err := service.PollDeviceFlow(ctx, start.DeviceCode); code != "expired" || err == nil { t.Fatalf("PollDeviceFlow() after token issuance code=%q err=%v, want expired", code, err) } } func TestPasswordChangeInvalidatesOldPassword(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() user := setupInitialAdmin(t, service, true) principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) if err := service.ChangePassword(ctx, principal, "correct horse battery staple", "new correct horse battery staple"); err != nil { t.Fatalf("ChangePassword() error = %v", err) } if _, _, err := service.LoginPassword(ctx, "admin@example.com", "correct horse battery staple", "127.0.0.1", "test"); err == nil { t.Fatal("old password still works") } if _, _, err := service.LoginPassword(ctx, "admin@example.com", "new correct horse battery staple", "127.0.0.1", "test"); err != nil { t.Fatalf("new password login error = %v", err) } } func TestCannotDemoteOrDeleteLastAdmin(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() user := setupInitialAdmin(t, service, true) principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) if _, err := service.UpdateUserRole(ctx, principal, user.ID, string(RoleEditor)); err == nil { t.Fatal("expected demoting last admin to fail") } if _, err := service.UpdateUserAccess(ctx, principal, []UserAccessUpdate{{ID: user.ID, Role: RoleAdmin, Disabled: true}}); err == nil { t.Fatal("expected disabling last admin to fail") } if err := service.DeleteAccount(ctx, principal, "correct horse battery staple"); err == nil { t.Fatal("expected deleting last admin to fail") } } func TestDisabledUserCannotAuthenticateWithPasswordSessionOrAPIKey(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() admin := setupInitialAdmin(t, service, true) user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer") if err != nil { t.Fatalf("RegisterPasswordUser() error = %v", err) } _, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test") if err != nil { t.Fatalf("LoginPassword() before disable error = %v", err) } apiKey, err := service.CreateAPIKey(ctx, user.ID, "CLI", []Scope{ScopeDocsRead}, nil) if err != nil { t.Fatalf("CreateAPIKey() error = %v", err) } adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour)) if _, err := service.UpdateUserAccess(ctx, adminPrincipal, []UserAccessUpdate{{ID: user.ID, Role: RoleViewer, Disabled: true}}); err != nil { t.Fatalf("UpdateUserAccess() error = %v", err) } if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil { t.Fatal("disabled user password login succeeded") } if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil { t.Fatal("disabled user session remained valid") } if _, err := service.ValidateBearerToken(ctx, apiKey.Token); err == nil { t.Fatal("disabled user api token remained valid") } } func TestPasswordResetEmailReplacesPriorLinkAndRevokesSessions(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() admin := setupInitialAdmin(t, service, true) user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer") if err != nil { t.Fatalf("RegisterPasswordUser() error = %v", err) } _, sessionToken, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test") if err != nil { t.Fatalf("LoginPassword() before reset error = %v", err) } sender := &testEmailSender{} service.SetEmailSender(sender) adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour)) if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil { t.Fatalf("SendPasswordReset() first error = %v", err) } firstToken := passwordResetTokenFromBody(t, sender.body) if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err != nil { t.Fatalf("SendPasswordReset() second error = %v", err) } secondToken := passwordResetTokenFromBody(t, sender.body) if firstToken == secondToken { t.Fatal("expected each password reset email to contain a fresh token") } if err := service.ResetPassword(ctx, firstToken, "new correct horse battery staple"); err == nil { t.Fatal("first password reset link remained valid after requesting a new one") } if err := service.ResetPassword(ctx, secondToken, "new correct horse battery staple"); err != nil { t.Fatalf("ResetPassword() error = %v", err) } if err := service.ResetPassword(ctx, secondToken, "another correct horse battery staple"); err == nil { t.Fatal("password reset link was reusable") } if _, err := service.ValidateSessionToken(ctx, sessionToken); err == nil { t.Fatal("password reset did not revoke existing sessions") } if _, _, err := service.LoginPassword(ctx, user.Email, "correct horse battery staple", "127.0.0.1", "test"); err == nil { t.Fatal("old password still works after reset") } if _, _, err := service.LoginPassword(ctx, user.Email, "new correct horse battery staple", "127.0.0.1", "test"); err != nil { t.Fatalf("new password login error = %v", err) } } func TestPasswordResetEmailFailureInvalidatesLink(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() admin := setupInitialAdmin(t, service, true) user, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer") if err != nil { t.Fatalf("RegisterPasswordUser() error = %v", err) } sender := &testEmailSender{err: errors.New("smtp unavailable")} service.SetEmailSender(sender) adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour)) if err := service.SendPasswordReset(ctx, adminPrincipal, user.ID); err == nil { t.Fatal("expected password reset email delivery failure") } token := passwordResetTokenFromBody(t, sender.body) if err := service.ResetPassword(ctx, token, "new correct horse battery staple"); err == nil { t.Fatal("password reset link remained valid after email delivery failed") } } func passwordResetTokenFromBody(t *testing.T, body string) string { t.Helper() for _, line := range strings.Split(body, "\n") { if !strings.HasPrefix(line, "http") { continue } parsed, err := url.Parse(line) if err != nil { t.Fatalf("parse password reset URL: %v", err) } if token := parsed.Query().Get("token"); token != "" { return token } } t.Fatalf("password reset body does not contain a link: %q", body) return "" } func TestPublicRegistrationCannotAttachCredentialsToExistingUser(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() setupInitialAdmin(t, service, true) if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "correct horse battery staple", "admin"); err != nil { t.Fatalf("RegisterPasswordUser() error = %v", err) } if _, err := service.RegisterPasswordUser(ctx, "dev@example.com", "Dev", "another correct horse", "viewer"); err == nil { t.Fatal("expected duplicate password registration to fail") } if _, _, err := service.BeginPasskeyRegistration(ctx, "dev@example.com", "Dev", "viewer"); err == nil { t.Fatal("expected public passkey registration for existing account to fail") } } func TestCurrentUserCanBeginPasskeyRegistrationForExistingAccount(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() user := setupInitialAdmin(t, service, false) principal := principalFromUser(user, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) options, challengeID, err := service.BeginCurrentUserPasskeyRegistration(ctx, principal) if err != nil { t.Fatalf("BeginCurrentUserPasskeyRegistration() error = %v", err) } if options == nil || challengeID == "" { t.Fatalf("options = %#v, challengeID = %q; want registration options and challenge", options, challengeID) } } func TestInitialSetupCanDisablePublicRegistration(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() admin := setupInitialAdmin(t, service, false) if admin.Role != RoleAdmin { t.Fatalf("initial role = %s, want admin", admin.Role) } if _, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer"); err == nil || err.Error() != "signups are disabled" { t.Fatalf("RegisterPasswordUser() error = %v, want signups are disabled", err) } principal := principalFromUser(admin, "session", "sess:test", "", nil, time.Now().Add(time.Hour)) settings, err := service.UpdateSignupsEnabled(ctx, principal, true) if err != nil { t.Fatalf("UpdateSignupsEnabled() error = %v", err) } if !settings.SignupsEnabled { t.Fatal("expected signups to be enabled") } viewer, err := service.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "admin") if err != nil { t.Fatalf("RegisterPasswordUser() after enabling signups error = %v", err) } if viewer.Role != RoleViewer { t.Fatalf("public signup role = %s, want viewer", viewer.Role) } } func TestResourcePermissionsArePrivateByDefault(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() admin := setupInitialAdmin(t, service, true) viewer, err := service.repo.UpsertUser(ctx, User{ Email: "viewer@example.com", DisplayName: "Viewer", Role: RoleViewer, }) if err != nil { t.Fatalf("create viewer: %v", err) } viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour)) permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "private/page.md", nil) if err != nil { t.Fatalf("EffectiveResourcePermission() error = %v", err) } if permission != PermissionNone { t.Fatalf("viewer permission = %q, want no access", permission) } adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour)) permission, err = service.EffectiveResourcePermission(ctx, adminPrincipal, ResourceDocument, "private/page.md", nil) if err != nil { t.Fatalf("EffectiveResourcePermission() admin error = %v", err) } if permission != PermissionAdmin { t.Fatalf("admin permission = %q, want admin", permission) } } func TestResourcePermissionsUseMostPermissiveGrant(t *testing.T) { service := setupAuthTestService(t) ctx := context.Background() admin := setupInitialAdmin(t, service, true) viewer, err := service.repo.UpsertUser(ctx, User{ Email: "viewer@example.com", DisplayName: "Viewer", Role: RoleViewer, }) if err != nil { t.Fatalf("create viewer: %v", err) } adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour)) viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour)) if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceDocument, "shared/page.md", []ResourcePermissionUpdate{ {UserID: viewer.ID, Permission: PermissionRead}, }); err != nil { t.Fatalf("grant document read: %v", err) } if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceFolder, "shared", []ResourcePermissionUpdate{ {UserID: viewer.ID, Permission: PermissionWrite}, }); err != nil { t.Fatalf("grant folder write: %v", err) } permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "shared/page.md", nil) if err != nil { t.Fatalf("EffectiveResourcePermission() folder error = %v", err) } if permission != PermissionWrite { t.Fatalf("folder/document combined permission = %q, want write", permission) } if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceCollection, "team", []ResourcePermissionUpdate{ {UserID: viewer.ID, Permission: PermissionAdmin}, }); err != nil { t.Fatalf("grant collection admin: %v", err) } permission, err = service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "other/page.md", []string{"team"}) if err != nil { t.Fatalf("EffectiveResourcePermission() collection error = %v", err) } if permission != PermissionAdmin { t.Fatalf("collection permission = %q, want admin", permission) } }