verify / verify (push) Successful in 4m29s
Reviewed source export adds verified TLS mail, encrypted outbox, mailbox verification and password reset protocols. Preserve public ancestry; omit local development history and operational queue. Consumer deployment and inbox delivery proof remain separate.
593 lines
22 KiB
Go
593 lines
22 KiB
Go
// 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")
|
|
}
|
|
}
|