// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "bytes" "context" "crypto/sha256" "errors" "net/url" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "gamertan.com/web/auth" "gamertan.com/web/authmail" "gamertan.com/web/mail" "gamertan.com/web/mailsqlite" ) const mailPassword = "correct horse battery staple" var accountMailHash = sync.OnceValues(func() (string, error) { return auth.HashPassword(mailPassword) }) type mailClock struct{ unix atomic.Int64 } func (clock *mailClock) now() time.Time { return time.Unix(clock.unix.Load(), 0).UTC() } func (clock *mailClock) advance(duration time.Duration) { clock.unix.Add(int64(duration / time.Second)) } type mailFixture struct { store *Store repo *MailRepository queue *mailsqlite.Queue service *authmail.Service clock *mailClock user auth.User session auth.Session path string } func mailConfig(clock *mailClock) authmail.Config { return authmail.Config{Origin: "https://accounts.example.test", SiteName: "Example Site", ConfirmPath: "/account/email/confirm/", ResetPath: "/reset-password/", SecurityPath: "/account/security/", Now: clock.now} } func accountMailFixture(t *testing.T, capacity int) mailFixture { t.Helper() clock := &mailClock{} clock.unix.Store(time.Date(2026, 9, 11, 8, 0, 0, 0, time.UTC).Unix()) path := filepath.Join(t.TempDir(), "identity.sqlite") store, err := Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { store.Close() }) if err = store.RequireMailSchema(t.Context()); err == nil { t.Fatal("mail schema silently installed") } if err = store.MigrateMail(t.Context()); err != nil { t.Fatal(err) } if err = store.MigrateMail(t.Context()); err != nil { t.Fatal(err) } if err = store.RequireMailSchema(t.Context()); err != nil { t.Fatal(err) } user := auth.User{ID: "mail_user_fixture", Username: "mail.reader", Email: "reader@example.test", DisplayName: "Reader", Status: "active", CreatedAt: clock.now(), UpdatedAt: clock.now()} hash, err := accountMailHash() if err != nil { t.Fatal(err) } if err = store.CreateUser(t.Context(), user, hash); err != nil { t.Fatal(err) } session := auth.Session{UserID: user.ID, Digest: sha256.Sum256([]byte("acting-mail-session")), CreatedAt: clock.now(), ExpiresAt: clock.now().Add(24 * time.Hour), LastSeenAt: clock.now()} if err = store.CreateSession(t.Context(), session); err != nil { t.Fatal(err) } repo, queue, err := store.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), MaxPending: capacity, Now: clock.now}) if err != nil { t.Fatal(err) } service, err := authmail.New(repo, mailConfig(clock)) if err != nil { t.Fatal(err) } return mailFixture{store, repo, queue, service, clock, user, session, path} } type collectMail struct{ messages []mail.Message } func (collector *collectMail) Send(_ context.Context, message mail.Message) error { collector.messages = append(collector.messages, message) return nil } func (fixture mailFixture) drain(t *testing.T) []mail.Message { t.Helper() collector := &collectMail{} for range 10 { record, work, err := fixture.queue.ProcessOne(t.Context(), collector) if err != nil { t.Fatal(err) } if !work { return collector.messages } if record.State != "accepted" { t.Fatalf("outbox state=%s stage=%s", record.State, record.FailureStage) } } t.Fatal("unbounded fixture queue") return nil } func mailToken(t *testing.T, message mail.Message) string { t.Helper() for _, part := range strings.Fields(message.Text) { parsed, err := url.Parse(part) if err == nil && parsed.Scheme == "https" && parsed.Query().Get("token") != "" { if parsed.Host != "accounts.example.test" { t.Fatal("untrusted action origin") } return parsed.Query().Get("token") } } t.Fatal("no action token in fixture mail") return "" } func (fixture mailFixture) verify(t *testing.T) { t.Helper() if err := fixture.service.RequestVerification(t.Context(), fixture.session.Digest); err != nil { t.Fatal(err) } messages := fixture.drain(t) if len(messages) != 1 { t.Fatalf("verification messages=%d", len(messages)) } if complete, err := fixture.service.Confirm(t.Context(), mailToken(t, messages[0])); err != nil || !complete { t.Fatalf("verify: %v %v", complete, err) } fixture.clock.advance(time.Minute) } func TestMailVerificationResetAndPreservedFactors(t *testing.T) { f := accountMailFixture(t, 0) if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || verified { t.Fatalf("legacy auto-verified: %v %v", verified, err) } for _, email := range []string{f.user.Email, "unknown@example.test", "not an address"} { if err := f.service.RequestReset(t.Context(), email); err != nil { t.Fatal(err) } } if got := f.drain(t); len(got) != 0 { t.Fatal("unverified/unknown address received reset") } if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil { t.Fatal(err) } messages := f.drain(t) if len(messages) != 1 || messages[0].To != f.user.Email { t.Fatal("verification recipient") } token := mailToken(t, messages[0]) for range 2 { if purpose, err := f.service.Inspect(t.Context(), token); err != nil || purpose != authmail.Verify { t.Fatalf("inspect: %s %v", purpose, err) } } if verified, _ := f.service.Status(t.Context(), f.session.Digest); verified { t.Fatal("read-only inspection mutated identity") } if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) { t.Fatalf("verify token used for reset: %v", err) } if completed, err := f.service.Confirm(t.Context(), token); err != nil || !completed { t.Fatalf("confirm: %v %v", completed, err) } if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) { t.Fatalf("token replay: %v", err) } if verified, err := f.service.Status(t.Context(), f.session.Digest); err != nil || !verified { t.Fatalf("not verified: %v %v", verified, err) } for _, statement := range []string{ `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at) VALUES(X'010203','mail_user_fixture','Fixture',X'7B7D',1)`, `INSERT INTO gwf_recovery_codes(user_id,code_hash,created_at) VALUES('mail_user_fixture',X'1234',1)`, `INSERT INTO gwf_recovery_grants(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(X'1234','mail_user_fixture',1,9999999999)`, } { if _, err := f.store.db.Exec(statement); err != nil { t.Fatal(err) } } f.clock.advance(time.Minute) if err := f.service.RequestReset(t.Context(), strings.ToUpper(f.user.Email)); err != nil { t.Fatal(err) } if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil { t.Fatal("rate limit disclosed account", err) } messages = f.drain(t) if len(messages) != 1 { t.Fatalf("reset requests=%d", len(messages)) } token = mailToken(t, messages[0]) if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) { t.Fatalf("reset token confirmed: %v", err) } if err := f.service.ResetPassword(t.Context(), token, mailPassword); !errors.Is(err, auth.ErrPasswordUnchanged) { t.Fatalf("password reuse: %v", err) } if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil { t.Fatal(err) } user, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID) if err != nil || user.ID != f.user.ID || user.Email != f.user.Email || !auth.VerifyPassword(hash, "a different safe password") || auth.VerifyPassword(hash, mailPassword) { t.Fatal("incorrect reset result", err) } if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) { t.Fatalf("session survived: %v", err) } for table, want := range map[string]int{"gwf_passkey_credentials": 1, "gwf_recovery_codes": 1, "gwf_recovery_grants": 0, "gwf_passkey_enrollment_tokens": 0, "gwf_account_mail_requests": 0} { var count int if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != want { t.Fatalf("%s count=%d want=%d err=%v", table, count, want, err) } } if err = f.service.ResetPassword(t.Context(), token, "third different safe password"); !errors.Is(err, authmail.ErrUnavailable) { t.Fatalf("reset replay: %v", err) } messages = f.drain(t) if len(messages) != 1 || !strings.Contains(messages[0].Subject, "password was changed") || strings.Contains(messages[0].Text, "token=") { t.Fatal("password-change notice missing or carries token") } } func TestMailAddressChangeBothMailboxesAndStableOwnership(t *testing.T) { for _, oldFirst := range []bool{false, true} { t.Run(map[bool]string{false: "new-first", true: "old-first"}[oldFirst], func(t *testing.T) { f := accountMailFixture(t, 0) if err := f.service.RequestChange(t.Context(), f.session.Digest, "wrong password", "new@example.test"); !errors.Is(err, auth.ErrInvalidCredentials) { t.Fatalf("unauthenticated change: %v", err) } if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "NEW@example.test"); err != nil { t.Fatal(err) } for _, statement := range []string{ `CREATE TABLE preserved_purchase(id TEXT PRIMARY KEY,user_id TEXT,old_email TEXT)`, `INSERT INTO preserved_purchase VALUES('order_fixture','mail_user_fixture','reader@example.test')`, `INSERT INTO gwf_organizations(id,slug,name,personal,created_at,updated_at) VALUES('family_fixture','family-fixture','Family',0,1,1)`, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES('family_fixture','mail_user_fixture','active',1)`, `INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0101','old_invitation','family_fixture','reader@example.test','mail_user_fixture',1,9999999999)`, `INSERT INTO gwf_organization_invitations(token_hash,id,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) VALUES(X'0202','new_invitation','family_fixture','new@example.test','mail_user_fixture',1,9999999999)`, } { if _, err := f.store.db.Exec(statement); err != nil { t.Fatal(err) } } messages := f.drain(t) if len(messages) != 2 { t.Fatalf("change messages=%d", len(messages)) } var oldToken, newToken string for _, message := range messages { if message.To == f.user.Email { oldToken = mailToken(t, message) } else if message.To == "new@example.test" { newToken = mailToken(t, message) } else { t.Fatal("wrong mailbox") } } first, second := newToken, oldToken if oldFirst { first, second = oldToken, newToken } if done, err := f.service.Confirm(t.Context(), first); err != nil || done { t.Fatalf("first confirmation: %v %v", done, err) } user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID) if err != nil || user.Email != f.user.Email { t.Fatal("address changed with one mailbox") } if got := f.drain(t); len(got) != 0 { t.Fatal("premature change notice") } if _, err = f.service.Confirm(t.Context(), first); !errors.Is(err, authmail.ErrUnavailable) { t.Fatalf("first-leg replay: %v", err) } if done, err := f.service.Confirm(t.Context(), second); err != nil || !done { t.Fatalf("second confirmation: %v %v", done, err) } user, _, err = f.store.CredentialByUserID(t.Context(), f.user.ID) if err != nil || user.Email != "new@example.test" || user.ID != f.user.ID { t.Fatal("canonical identity changed incorrectly") } if _, _, err = f.store.CredentialByIdentifier(t.Context(), f.user.Email); !errors.Is(err, auth.ErrUserNotFound) { t.Fatalf("old login address survived: %v", err) } if subject, err := f.repo.ResetSubject(t.Context(), "new@example.test", f.clock.now()); err != nil || !subject.Verified { t.Fatal("new mailbox not verified", err) } var value string if err = f.store.db.QueryRow(`SELECT user_id||':'||old_email FROM preserved_purchase`).Scan(&value); err != nil || value != f.user.ID+":"+f.user.Email { t.Fatal("historical purchase changed") } var count int if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, f.user.ID).Scan(&count); err != nil || count != 1 { t.Fatal("membership changed") } if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='old_invitation' AND revoked_at IS NOT NULL`).Scan(&count); err != nil || count != 1 { t.Fatal("old invitation retained") } if err = f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_organization_invitations WHERE id='new_invitation' AND revoked_at IS NULL AND used_at IS NULL`).Scan(&count); err != nil || count != 1 { t.Fatal("new invitation granted or rewritten") } if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); !errors.Is(err, auth.ErrSessionNotFound) { t.Fatal("change did not sign out sessions") } if got := f.drain(t); len(got) != 2 { t.Fatalf("change notices=%d", len(got)) } }) } } func TestMailTokenInvalidation(t *testing.T) { for _, test := range []struct { name, statement string advance time.Duration }{ {"expiry", "", authmail.Lifetime}, {"password-changed", `UPDATE gwf_password_credentials SET password_hash='new-hash'`, 0}, {"email-changed", `UPDATE gwf_users SET email='elsewhere@example.test',email_normalized='elsewhere@example.test'`, 0}, {"profile-revision", `UPDATE gwf_users SET profile_revision=profile_revision+1`, 0}, {"suspended", `UPDATE gwf_users SET status='suspended'`, 0}, {"pending", `UPDATE gwf_users SET registration_pending=1`, 0}, {"session-revoked", `DELETE FROM gwf_auth_sessions`, 0}, {"forced-password-change", `UPDATE gwf_users SET password_change_required=1`, 0}, } { t.Run(test.name, func(t *testing.T) { f := accountMailFixture(t, 0) if err := f.service.RequestVerification(t.Context(), f.session.Digest); err != nil { t.Fatal(err) } token := mailToken(t, f.drain(t)[0]) if test.statement != "" { if _, err := f.store.db.Exec(test.statement); err != nil { t.Fatal(err) } } f.clock.advance(test.advance) if _, err := f.service.Confirm(t.Context(), token); !errors.Is(err, authmail.ErrUnavailable) { t.Fatalf("invalidated token accepted: %v", err) } var count int if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM gwf_verified_emails`).Scan(&count); err != nil || count != 0 { t.Fatal("invalidated verification wrote state") } }) } } func TestMailAuditAndOutboxRollback(t *testing.T) { f := accountMailFixture(t, 1) if _, err := f.store.db.Exec(`CREATE TRIGGER reject_mail_audit BEFORE INSERT ON gwf_audit_events WHEN NEW.action LIKE 'auth.mail.%' BEGIN SELECT RAISE(ABORT,'fixture audit failure'); END`); err != nil { t.Fatal(err) } if err := f.service.RequestVerification(t.Context(), f.session.Digest); err == nil { t.Fatal("audit failure ignored") } for _, table := range []string{"gwf_account_mail_requests", "gwf_account_mail_limits", "gwf_mail_outbox"} { var count int if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil || count != 0 { t.Fatalf("%s not rolled back", table) } } if _, err := f.store.db.Exec(`DROP TRIGGER reject_mail_audit`); err != nil { t.Fatal(err) } f.verify(t) if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil { t.Fatal(err) } token := mailToken(t, f.drain(t)[0]) if err := f.queue.Enqueue(t.Context(), mail.Message{ID: "mail_capacity_fixture", To: "other@example.test", Subject: "Fixture", Text: "Unrelated message", CreatedAt: f.clock.now()}, f.clock.now().Add(time.Hour)); err != nil { t.Fatal(err) } if err := f.service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, mailsqlite.ErrFull) { t.Fatalf("outbox failure: %v", err) } _, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID) if err != nil || !auth.VerifyPassword(hash, mailPassword) { t.Fatal("password changed without committed notification") } if _, err = f.service.Inspect(t.Context(), token); err != nil { t.Fatal("token lost on rollback", err) } if _, _, err = f.store.PrincipalBySession(t.Context(), f.session.Digest, f.clock.now()); err != nil { t.Fatal("session lost on rollback", err) } f.drain(t) if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil { t.Fatal("retry after queue recovery", err) } } func TestMailConcurrentConsumeAndRateLimit(t *testing.T) { f := accountMailFixture(t, 0) f.verify(t) if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil { t.Fatal(err) } token := mailToken(t, f.drain(t)[0]) var group sync.WaitGroup var accepted, rejected atomic.Int32 for range 2 { group.Go(func() { err := f.service.ResetPassword(context.Background(), token, "a different safe password") if err == nil { accepted.Add(1) } else if errors.Is(err, authmail.ErrUnavailable) { rejected.Add(1) } else { t.Errorf("concurrent consume: %v", err) } }) } group.Wait() if accepted.Load() != 1 || rejected.Load() != 1 { t.Fatalf("accepted=%d rejected=%d", accepted.Load(), rejected.Load()) } g := accountMailFixture(t, 0) var firstToken string for request := range 5 { if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil { t.Fatal(err) } messages := g.drain(t) if request == 0 { firstToken = mailToken(t, messages[0]) } if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) { t.Fatalf("burst limit: %v", err) } g.clock.advance(time.Minute) } if err := g.service.RequestVerification(t.Context(), g.session.Digest); !errors.Is(err, authmail.ErrLimited) { t.Fatalf("hour limit: %v", err) } if _, err := g.service.Inspect(t.Context(), firstToken); !errors.Is(err, authmail.ErrUnavailable) { t.Fatal("replaced link survived") } g.clock.advance(time.Hour) if err := g.service.RequestVerification(t.Context(), g.session.Digest); err != nil { t.Fatal("rate window did not recover", err) } } func TestMailRestartAndAddressCollision(t *testing.T) { f := accountMailFixture(t, 0) if err := f.service.RequestChange(t.Context(), f.session.Digest, mailPassword, "new@example.test"); err != nil { t.Fatal(err) } messages := f.drain(t) first, second := mailToken(t, messages[0]), mailToken(t, messages[1]) if _, err := f.service.Confirm(t.Context(), first); err != nil { t.Fatal(err) } other := f.user other.ID = "another_mail_user" other.Username = "another.reader" other.Email = "new@example.test" hash, _ := accountMailHash() if err := f.store.CreateUser(t.Context(), other, hash); err != nil { t.Fatal(err) } reopened, err := OpenWithOptions(f.path, OpenOptions{Migrate: false}) if err != nil { t.Fatal(err) } defer reopened.Close() if err = reopened.RequireMailSchema(t.Context()); err != nil { t.Fatal(err) } repo, _, err := reopened.AccountMail(mailsqlite.Options{EncryptionKey: bytes.Repeat([]byte{31}, 32), Now: f.clock.now}) if err != nil { t.Fatal(err) } service, err := authmail.New(repo, mailConfig(f.clock)) if err != nil { t.Fatal(err) } if _, err = service.Confirm(t.Context(), second); !errors.Is(err, authmail.ErrAddressUnavailable) { t.Fatalf("collision not rechecked: %v", err) } if _, err = service.Inspect(t.Context(), second); err != nil { t.Fatal("collision consumed token", err) } user, _, err := f.store.CredentialByUserID(t.Context(), f.user.ID) if err != nil || user.Email != f.user.Email { t.Fatal("collision overwrote canonical email") } var version int if version, err = f.store.CurrentSchema(t.Context()); err != nil || version != 11 { t.Fatalf("base schema changed: %d %v", version, err) } } func TestMailCommitUsesFreshTimeAndCredentials(t *testing.T) { for _, change := range []string{"expiry", "credential"} { t.Run(change, func(t *testing.T) { f := accountMailFixture(t, 0) f.verify(t) if err := f.service.RequestReset(t.Context(), f.user.Email); err != nil { t.Fatal(err) } token := mailToken(t, f.drain(t)[0]) config := mailConfig(f.clock) config.Compose = func(intent authmail.MailIntent) (authmail.MessageContent, error) { if intent.Kind == "password-changed" { if change == "expiry" { f.clock.advance(authmail.Lifetime) } else { if _, err := f.store.db.Exec(`UPDATE gwf_password_credentials SET password_hash='concurrent-credential'`); err != nil { t.Fatal(err) } } } return authmail.DefaultComposer(intent) } service, err := authmail.New(f.repo, config) if err != nil { t.Fatal(err) } if err = service.ResetPassword(t.Context(), token, "a different safe password"); !errors.Is(err, authmail.ErrUnavailable) { t.Fatalf("stale completion: %v", err) } _, hash, err := f.store.CredentialByUserID(t.Context(), f.user.ID) if err != nil || auth.VerifyPassword(hash, "a different safe password") { t.Fatal("stale completion changed password") } if got := f.drain(t); len(got) != 0 { t.Fatal("uncommitted reset sent notice") } }) } } func TestMailMigrationRollbackAndMixedCaseLegacyAddress(t *testing.T) { store, err := Open(filepath.Join(t.TempDir(), "rollback.sqlite")) if err != nil { t.Fatal(err) } defer store.Close() if _, err = store.db.Exec(`CREATE TABLE gwf_verified_emails(conflicting_schema TEXT)`); err != nil { t.Fatal(err) } if err = store.MigrateMail(t.Context()); err == nil { t.Fatal("schema conflict ignored") } var count int if err = store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name IN ('gwf_mail_outbox','gwf_account_mail_migrations')`).Scan(&count); err != nil || count != 0 { t.Fatal("partial mail migration remained") } if err = store.RequireCurrentSchema(t.Context()); err != nil { t.Fatal("base schema damaged", err) } f := accountMailFixture(t, 0) if _, err = f.store.db.Exec(`UPDATE gwf_users SET email='READER@Example.Test'`); err != nil { t.Fatal(err) } f.verify(t) if err = f.service.RequestReset(t.Context(), "Reader@Example.Test"); err != nil { t.Fatal(err) } token := mailToken(t, f.drain(t)[0]) if err = f.service.ResetPassword(t.Context(), token, "a different safe password"); err != nil { t.Fatal("legacy address reset", err) } if notices := f.drain(t); len(notices) != 1 || notices[0].To != "reader@example.test" { t.Fatal("canonical notice recipient") } }