fix(auth): app passwords now sign in SMTP, IMAP and POP3

The webmail creates app passwords (shown once, stored hashed, with scopes) but
the protocol servers only compared a login against mailboxes.password_hash, so
an app password was always refused with 535 / authentication failed. They exist
because mail clients cannot do a second factor.

- port.MailboxAuth carries the mailbox's app passwords (newest 20) with their
  scopes; the Postgres repository loads them and stamps last_used_at on use
- verifyCredential accepts the mailbox password or an app password that holds
  the scope of the protocol; SMTP, IMAP and POP3 use it. ManageSieve keeps the
  mailbox password only, as no default scope covers it
- a malformed app password hash is skipped instead of failing the login; a
  malformed mailbox hash still surfaces as the server-side error it was

Tests: unit cases for scopes and malformed hashes, the three entry points,
repository tests against Postgres, and an end-to-end test on the real
submission server that fails with 535 without the fix and passes with it.
Full `go test ./...` with TEST_DATABASE_URL, go vet and lang-lint are clean.
This commit is contained in:
Lucas Rayan committed 2026-10-16 09:21:08 -03:00
1 parent 8e7c0abd0a
commit 110a240cd9
10 files changed
+599 -5

No files matched your search

+17
View File
@@ -93,6 +93,23 @@ make reset-password EMAIL=… PASSWORD='…' # recovers a lockout, ends its se
make reset-mfa EMAIL=… # clears every second factor make reset-mfa EMAIL=… # clears every second factor
``` ```
### App passwords for mail clients
A mail client cannot answer a second factor, so an account signs a client in
with an **app password**: create one in the webmail (Settings, app passwords),
copy it once, and give it to the client as the account password. It is shown a
single time and stored only as an Argon2id hash.
An app password works for SMTP submission, IMAP and POP3, as its `scopes`
column allows (the default is all three). A scope limits it: an app password
with only `imap` can read mail and cannot send. ManageSieve takes the mailbox
password only. The mailbox password keeps working everywhere, and a login with
an app password stamps its `last_used_at`, which the settings screen shows.
Until this was wired in, the protocols compared every password only against the
mailbox password, so an app password was created, shown and then refused by
SMTP, IMAP and POP3 with `535 Authentication credentials invalid`.
An account locks itself for 15 minutes after 5 failed passwords. That is the An account locks itself for 15 minutes after 5 failed passwords. That is the
lockout working, not a fault; `reset-password` clears it. lockout working, not a fault; `reset-password` clears it.
@@ -15,8 +15,29 @@ type MailboxAuth struct {
DomainName string DomainName string
// MaxRecipientsPerHour caps a compromised account's blast radius. // MaxRecipientsPerHour caps a compromised account's blast radius.
MaxRecipientsPerHour int MaxRecipientsPerHour int
// AppPasswords are the per-client credentials the user generated in the
// webmail. Protocol clients cannot answer a second factor, so these are how
// a mail client signs in on an account that has one.
AppPasswords []AppPasswordAuth
} }
// AppPasswordAuth is one app password as stored: a hash, never the secret.
type AppPasswordAuth struct {
ID uuid.UUID
Hash string
Scopes []string
}
// AppPasswordUseRecorder is implemented by repositories that can note when an
// app password was last used. It is optional: a fake in a test need not have it.
type AppPasswordUseRecorder interface {
RecordAppPasswordUse(ctx context.Context, id uuid.UUID) error
}
// MaxAppPasswordsChecked bounds how many app passwords one login attempt is
// compared against.
const MaxAppPasswordsChecked = 20
// AuthRepository resolves a login address to its stored credential. // AuthRepository resolves a login address to its stored credential.
type AuthRepository interface { type AuthRepository interface {
// FindByAddress returns nil, nil (not an error) when no active mailbox // FindByAddress returns nil, nil (not an error) when no active mailbox
@@ -0,0 +1,68 @@
package usecase
import (
"context"
"log/slog"
"github.com/alexedwards/argon2id"
"lambdamail/protocols/internal/application/port"
)
// Scopes an app password can be limited to. They match the values of
// app_passwords.scopes, whose default is {imap,pop3,smtp}.
const (
ScopeSMTP = "smtp"
ScopeIMAP = "imap"
ScopePOP3 = "pop3"
)
// verifyCredential reports whether password unlocks the mailbox for a client
// of the given protocol: either the mailbox's own password, or one of its app
// passwords that holds the scope.
//
// Before this, every protocol compared only against the mailbox password, so
// an app password - created in the webmail precisely because a mail client
// cannot answer a second factor - never worked anywhere.
//
// An error is returned only when the mailbox's own stored hash is unreadable
// (a server-side fault, reported as before) and no app password matched. A
// broken app password hash is skipped: it must not take the good ones, or a
// plain wrong-password answer, down with it.
func verifyCredential(ctx context.Context, repo port.AuthRepository, rec *port.MailboxAuth, password, scope string) (bool, error) {
match, mainErr := argon2id.ComparePasswordAndHash(password, rec.PasswordHash)
if mainErr == nil && match {
return true, nil
}
for _, app := range rec.AppPasswords {
if !hasScope(app.Scopes, scope) {
continue
}
ok, err := argon2id.ComparePasswordAndHash(password, app.Hash)
if err != nil || !ok {
continue
}
if recorder, can := repo.(port.AppPasswordUseRecorder); can {
// Best effort: a failed timestamp must not fail a login that succeeded
if err := recorder.RecordAppPasswordUse(ctx, app.ID); err != nil {
slog.Warn("record app password use", "error", err)
}
}
return true, nil
}
if mainErr != nil {
return false, mainErr
}
return false, nil
}
func hasScope(scopes []string, scope string) bool {
for _, s := range scopes {
if s == scope {
return true
}
}
return false
}
@@ -0,0 +1,218 @@
package usecase
import (
"context"
"errors"
"testing"
"github.com/alexedwards/argon2id"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
)
// Light parameters: the tests check logic, not the cost of a real hash.
var testArgon = &argon2id.Params{Memory: 8 * 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32}
func mustHash(t *testing.T, password string) string {
t.Helper()
hash, err := argon2id.CreateHash(password, testArgon)
if err != nil {
t.Fatalf("create hash: %v", err)
}
return hash
}
// recordingAuthRepo is an AuthRepository that also records which app password
// was used, like the Postgres one does.
type recordingAuthRepo struct {
used []uuid.UUID
}
func (r *recordingAuthRepo) FindByAddress(context.Context, string) (*port.MailboxAuth, error) {
return nil, nil
}
func (r *recordingAuthRepo) RecordAppPasswordUse(_ context.Context, id uuid.UUID) error {
r.used = append(r.used, id)
return nil
}
func TestVerifyCredential(t *testing.T) {
appID := uuid.New()
rec := &port.MailboxAuth{
ID: uuid.New(),
PasswordHash: mustHash(t, "main-password-123"),
AppPasswords: []port.AppPasswordAuth{
{ID: appID, Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP, ScopeIMAP}},
{ID: uuid.New(), Hash: mustHash(t, "imap-only-xyz"), Scopes: []string{ScopeIMAP}},
},
}
repo := &recordingAuthRepo{}
cases := []struct {
name string
password string
scope string
want bool
}{
{"main password works for smtp", "main-password-123", ScopeSMTP, true},
{"main password works for imap", "main-password-123", ScopeIMAP, true},
{"app password works in a scope it holds", "app-password-abc", ScopeSMTP, true},
{"app password works for imap too", "app-password-abc", ScopeIMAP, true},
{"app password is refused outside its scopes", "app-password-abc", ScopePOP3, false},
{"an imap-only app password cannot send", "imap-only-xyz", ScopeSMTP, false},
{"an imap-only app password can read", "imap-only-xyz", ScopeIMAP, true},
{"wrong password fails", "nope", ScopeSMTP, false},
{"empty password fails", "", ScopeSMTP, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := verifyCredential(context.Background(), repo, rec, tc.password, tc.scope)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("verifyCredential = %v, want %v", got, tc.want)
}
})
}
// Only the successful app password logins were recorded, and only that one
for _, id := range repo.used {
if id == rec.ID {
t.Fatalf("recorded the mailbox id as an app password use")
}
}
if len(repo.used) == 0 {
t.Fatalf("expected app password use to be recorded")
}
}
func TestVerifyCredentialMainPasswordDoesNotRecordAppUse(t *testing.T) {
rec := &port.MailboxAuth{
ID: uuid.New(),
PasswordHash: mustHash(t, "main-password-123"),
AppPasswords: []port.AppPasswordAuth{{ID: uuid.New(), Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP}}},
}
repo := &recordingAuthRepo{}
ok, err := verifyCredential(context.Background(), repo, rec, "main-password-123", ScopeSMTP)
if err != nil || !ok {
t.Fatalf("main password: ok=%v err=%v", ok, err)
}
if len(repo.used) != 0 {
t.Fatalf("a main password login must not touch app passwords, recorded %v", repo.used)
}
}
func TestVerifyCredentialMalformedAppHashIsSkipped(t *testing.T) {
rec := &port.MailboxAuth{
ID: uuid.New(),
PasswordHash: mustHash(t, "main-password-123"),
AppPasswords: []port.AppPasswordAuth{
{ID: uuid.New(), Hash: "not-a-phc-string", Scopes: []string{ScopeSMTP}},
{ID: uuid.New(), Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP}},
},
}
ok, err := verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "app-password-abc", ScopeSMTP)
if err != nil || !ok {
t.Fatalf("a good app password must still work next to a broken one: ok=%v err=%v", ok, err)
}
ok, err = verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "wrong", ScopeSMTP)
if err != nil || ok {
t.Fatalf("a broken app hash must not turn a wrong password into an error: ok=%v err=%v", ok, err)
}
}
func TestVerifyCredentialMalformedMainHashKeepsReportingTheError(t *testing.T) {
rec := &port.MailboxAuth{ID: uuid.New(), PasswordHash: "garbage"}
ok, err := verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "anything", ScopeSMTP)
if ok {
t.Fatalf("must not authenticate against a malformed hash")
}
if err == nil {
t.Fatalf("a malformed main hash is a server-side fault and must surface as an error, as before")
}
}
func TestVerifyCredentialMalformedMainHashStillAllowsAnAppPassword(t *testing.T) {
rec := &port.MailboxAuth{
ID: uuid.New(),
PasswordHash: "garbage",
AppPasswords: []port.AppPasswordAuth{{ID: uuid.New(), Hash: mustHash(t, "app-password-abc"), Scopes: []string{ScopeSMTP}}},
}
ok, err := verifyCredential(context.Background(), &recordingAuthRepo{}, rec, "app-password-abc", ScopeSMTP)
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
}
// The three protocol entry points, wired the way the servers wire them: an app
// password signs in where its scopes allow, and only there.
func appPasswordMailbox(t *testing.T, scopes ...string) (port.MailboxAuth, string) {
t.Helper()
const secret = "app-password-from-the-webmail"
return port.MailboxAuth{
ID: uuid.New(),
PasswordHash: mustHash(t, "the-real-mailbox-password"),
AppPasswords: []port.AppPasswordAuth{{ID: uuid.New(), Hash: mustHash(t, secret), Scopes: scopes}},
}, secret
}
func TestSubmissionAuthenticate_AcceptsAnAppPasswordWithTheSmtpScope(t *testing.T) {
rec, secret := appPasswordMailbox(t, ScopeSMTP)
uc := NewProcessOutboundEmailUseCase(&fakeAuthRepo{record: &rec}, nil, nil, nil)
got, err := uc.Authenticate(context.Background(), "user@example.test", secret)
if err != nil || got == nil || got.ID != rec.ID {
t.Fatalf("Authenticate = %+v, %v; want the mailbox", got, err)
}
}
func TestSubmissionAuthenticate_RefusesAnAppPasswordWithoutTheSmtpScope(t *testing.T) {
rec, secret := appPasswordMailbox(t, ScopeIMAP)
uc := NewProcessOutboundEmailUseCase(&fakeAuthRepo{record: &rec}, nil, nil, nil)
if _, err := uc.Authenticate(context.Background(), "user@example.test", secret); !errors.Is(err, ErrSubmissionAuthFailed) {
t.Fatalf("error = %v, want ErrSubmissionAuthFailed", err)
}
}
func TestImapLogin_AcceptsAnAppPasswordWithTheImapScope(t *testing.T) {
rec, secret := appPasswordMailbox(t, ScopeIMAP)
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{"user@example.test": rec}}
uc := NewImapSessionUseCase(auth, nil, nil, nil, nil, nil, nil)
id, err := uc.Login(context.Background(), "user@example.test", secret)
if err != nil || id != rec.ID.String() {
t.Fatalf("Login = %q, %v; want %q", id, err, rec.ID)
}
}
func TestImapLogin_RefusesAnAppPasswordWithoutTheImapScope(t *testing.T) {
rec, secret := appPasswordMailbox(t, ScopeSMTP)
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{"user@example.test": rec}}
uc := NewImapSessionUseCase(auth, nil, nil, nil, nil, nil, nil)
if _, err := uc.Login(context.Background(), "user@example.test", secret); !errors.Is(err, ErrAuthFailed) {
t.Fatalf("error = %v, want ErrAuthFailed", err)
}
}
func TestPop3Login_AcceptsAnAppPasswordWithThePop3Scope(t *testing.T) {
rec, secret := appPasswordMailbox(t, ScopePOP3)
auth := &fakeAuthRepository{byAddress: map[string]port.MailboxAuth{"user@example.test": rec}}
uc := NewPop3SessionUseCase(auth, nil, nil, nil, nil)
id, err := uc.Login(context.Background(), "user@example.test", secret)
if err != nil || id != rec.ID.String() {
t.Fatalf("Login = %q, %v; want %q", id, err, rec.ID)
}
}
@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"github.com/alexedwards/argon2id"
"github.com/google/uuid" "github.com/google/uuid"
"lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/application/port"
@@ -64,7 +63,7 @@ func (uc *ImapSessionUseCase) Login(ctx context.Context, address, password strin
if rec == nil { if rec == nil {
return "", ErrAuthFailed return "", ErrAuthFailed
} }
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) match, err := verifyCredential(ctx, uc.auth, rec, password, ScopeIMAP)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/alexedwards/argon2id"
"github.com/google/uuid" "github.com/google/uuid"
"lambdamail/protocols/internal/application/port" "lambdamail/protocols/internal/application/port"
@@ -42,7 +41,7 @@ func (uc *Pop3SessionUseCase) Login(ctx context.Context, username, password stri
if rec == nil { if rec == nil {
return "", ErrAuthFailed return "", ErrAuthFailed
} }
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) match, err := verifyCredential(ctx, uc.authRepo, rec, password, ScopePOP3)
if err != nil { if err != nil {
return "", fmt.Errorf("verify password hash: %w", err) return "", fmt.Errorf("verify password hash: %w", err)
} }
@@ -82,7 +82,7 @@ func (uc *ProcessOutboundEmailUseCase) Authenticate(ctx context.Context, address
return nil, ErrSubmissionAuthFailed return nil, ErrSubmissionAuthFailed
} }
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash) match, err := verifyCredential(ctx, uc.auth, rec, password, ScopeSMTP)
if err != nil || !match { if err != nil || !match {
return nil, ErrSubmissionAuthFailed return nil, ErrSubmissionAuthFailed
} }
@@ -0,0 +1,119 @@
package e2e
import (
"context"
"crypto/tls"
"net"
"testing"
"time"
"github.com/alexedwards/argon2id"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/dkim"
"lambdamail/protocols/internal/infrastructure/postgres"
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
gosmtp "github.com/emersion/go-smtp"
)
// TestSubmissionAcceptsAppPasswords drives the real submission server over
// STARTTLS. An app password is what a mail client uses on an account that has a
// second factor, and it used to be refused everywhere because the protocols
// only compared against the mailbox password.
func TestSubmissionAcceptsAppPasswords(t *testing.T) {
if testing.Short() {
t.Skip("e2e test skipped in -short mode")
}
ctx := context.Background()
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(15445).
RuntimePath(t.TempDir()).
StartTimeout(120 * time.Second))
if err := pg.Start(); err != nil {
t.Fatalf("start embedded postgres: %v", err)
}
defer pg.Stop()
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:15445/postgres?sslmode=disable")
if err != nil {
t.Fatalf("connect pool: %v", err)
}
defer pool.Close()
applyMigrations(t, ctx, pool)
const (
address = "plataforma@apppass.test"
mainPassword = "the main mailbox password"
sendSecret = "app password that may send"
readSecret = "app password for reading only"
)
hashOf := func(secret string) string {
hash, err := argon2id.CreateHash(secret, argon2id.DefaultParams)
if err != nil {
t.Fatalf("hash: %v", err)
}
return hash
}
domainID, mailboxID := uuid.New(), uuid.New()
pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'apppass.test', 'apppass.test')`, domainID)
pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'plataforma', $3, $4)`,
mailboxID, domainID, address, hashOf(mainPassword))
// Default scopes: imap, pop3, smtp
pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash) VALUES ($1, 'ciberlab', $2)`, mailboxID, hashOf(sendSecret))
pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash, scopes) VALUES ($1, 'reader', $2, '{imap}')`, mailboxID, hashOf(readSecret))
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
submissionUC := appusecase.NewProcessOutboundEmailUseCase(postgres.NewAuthRepository(pool), postgres.NewOutboundRepository(pool), blobs, dkim.NewSigner(nil))
server := gosmtp.NewServer(smtppresentation.NewSubmissionBackend(submissionUC))
server.Domain = "mail.apppass.test"
server.AllowInsecureAuth = false
server.TLSConfig = &tls.Config{Certificates: []tls.Certificate{selfSignedTLSCert(t)}}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go server.Serve(ln)
login := func(password string) error {
client, err := gosmtp.DialStartTLS(ln.Addr().String(), &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close()
return client.Auth(plainAuth(address, password))
}
if err := login(mainPassword); err != nil {
t.Errorf("the mailbox password must keep working: %v", err)
}
if err := login(sendSecret); err != nil {
t.Errorf("an app password with the smtp scope must sign in: %v", err)
}
if err := login(readSecret); err == nil {
t.Error("an imap-only app password must not be able to submit mail")
}
if err := login("not a password"); err == nil {
t.Error("a wrong password must still be refused")
}
var stamped bool
pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM app_passwords WHERE label = 'ciberlab'`).Scan(&stamped)
if !stamped {
t.Error("using an app password must stamp last_used_at")
}
var untouched bool
pool.QueryRow(ctx, `SELECT last_used_at IS NULL FROM app_passwords WHERE label = 'reader'`).Scan(&untouched)
if !untouched {
t.Error("a refused app password must not be stamped as used")
}
}
@@ -38,5 +38,37 @@ func (r *AuthRepository) FindByAddress(ctx context.Context, address string) (*po
return nil, err return nil, err
} }
rec.ID = id rec.ID = id
// The newest few only: each one costs an Argon2 comparison on a failed login,
// so the number considered is bounded.
rows, err := r.pool.Query(ctx, `
SELECT id, password_hash, scopes
FROM app_passwords
WHERE mailbox_id = $1
ORDER BY created_at DESC
LIMIT $2
`, id, port.MaxAppPasswordsChecked)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var app port.AppPasswordAuth
if err := rows.Scan(&app.ID, &app.Hash, &app.Scopes); err != nil {
return nil, err
}
rec.AppPasswords = append(rec.AppPasswords, app)
}
if err := rows.Err(); err != nil {
return nil, err
}
return &rec, nil return &rec, nil
} }
// RecordAppPasswordUse stamps the moment an app password last signed a client in.
func (r *AuthRepository) RecordAppPasswordUse(ctx context.Context, id uuid.UUID) error {
_, err := r.pool.Exec(ctx, `UPDATE app_passwords SET last_used_at = NOW() WHERE id = $1`, id)
return err
}
@@ -5,6 +5,9 @@ import (
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"lambdamail/protocols/internal/application/port"
) )
func TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox(t *testing.T) { func TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox(t *testing.T) {
@@ -56,3 +59,121 @@ func TestAuthRepository_FindByAddress_ReturnsNilForUnknownAddress(t *testing.T)
t.Errorf("rec = %+v, want nil", rec) t.Errorf("rec = %+v, want nil", rec)
} }
} }
// seedAuthMailbox creates a throwaway domain and mailbox and returns the mailbox id
// and address. Cleanup removes the domain, which cascades to the rest.
func seedAuthMailbox(t *testing.T, pool interface {
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
}) (uuid.UUID, string) {
t.Helper()
ctx := context.Background()
domainID := uuid.New()
domainName := "authrepo-app-" + domainID.String() + ".invalid"
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, domainID, domainName, domainName); err != nil {
t.Fatalf("seed domain: %v", err)
}
t.Cleanup(func() { pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) })
mailboxID := uuid.New()
address := "user@" + domainName
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, 'main-hash')`,
mailboxID, domainID, address); err != nil {
t.Fatalf("seed mailbox: %v", err)
}
return mailboxID, address
}
func TestAuthRepository_FindByAddress_LoadsAppPasswordsWithTheirScopes(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
mailboxID, address := seedAuthMailbox(t, pool)
if _, err := pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash) VALUES ($1, 'phone', 'hash-default-scopes')`, mailboxID); err != nil {
t.Fatalf("seed app password: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash, scopes) VALUES ($1, 'reader', 'hash-imap-only', '{imap}')`, mailboxID); err != nil {
t.Fatalf("seed app password: %v", err)
}
rec, err := NewAuthRepository(pool).FindByAddress(ctx, address)
if err != nil || rec == nil {
t.Fatalf("FindByAddress: rec=%v err=%v", rec, err)
}
if len(rec.AppPasswords) != 2 {
t.Fatalf("AppPasswords = %d, want 2", len(rec.AppPasswords))
}
byHash := map[string][]string{}
for _, app := range rec.AppPasswords {
byHash[app.Hash] = app.Scopes
}
if got := byHash["hash-default-scopes"]; len(got) != 3 {
t.Errorf("default scopes = %v, want imap, pop3 and smtp", got)
}
if got := byHash["hash-imap-only"]; len(got) != 1 || got[0] != "imap" {
t.Errorf("imap-only scopes = %v, want [imap]", got)
}
}
func TestAuthRepository_FindByAddress_AMailboxWithoutAppPasswordsStillWorks(t *testing.T) {
pool := testPool(t)
defer pool.Close()
_, address := seedAuthMailbox(t, pool)
rec, err := NewAuthRepository(pool).FindByAddress(context.Background(), address)
if err != nil || rec == nil {
t.Fatalf("FindByAddress: rec=%v err=%v", rec, err)
}
if len(rec.AppPasswords) != 0 {
t.Errorf("AppPasswords = %v, want none", rec.AppPasswords)
}
}
func TestAuthRepository_FindByAddress_ReadsOnlyTheNewestAppPasswords(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
mailboxID, address := seedAuthMailbox(t, pool)
for i := 0; i < port.MaxAppPasswordsChecked+5; i++ {
if _, err := pool.Exec(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash, created_at) VALUES ($1, $2, 'h', NOW() - make_interval(mins => $3))`,
mailboxID, "label", i); err != nil {
t.Fatalf("seed app password: %v", err)
}
}
rec, err := NewAuthRepository(pool).FindByAddress(ctx, address)
if err != nil || rec == nil {
t.Fatalf("FindByAddress: rec=%v err=%v", rec, err)
}
if len(rec.AppPasswords) != port.MaxAppPasswordsChecked {
t.Errorf("AppPasswords = %d, want the cap of %d", len(rec.AppPasswords), port.MaxAppPasswordsChecked)
}
}
func TestAuthRepository_RecordAppPasswordUse_StampsLastUsedAt(t *testing.T) {
pool := testPool(t)
defer pool.Close()
ctx := context.Background()
mailboxID, _ := seedAuthMailbox(t, pool)
var appID uuid.UUID
if err := pool.QueryRow(ctx, `INSERT INTO app_passwords (mailbox_id, label, password_hash) VALUES ($1, 'phone', 'h') RETURNING id`, mailboxID).Scan(&appID); err != nil {
t.Fatalf("seed app password: %v", err)
}
if err := NewAuthRepository(pool).RecordAppPasswordUse(ctx, appID); err != nil {
t.Fatalf("RecordAppPasswordUse: %v", err)
}
var stamped bool
if err := pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM app_passwords WHERE id = $1`, appID).Scan(&stamped); err != nil || !stamped {
t.Fatalf("last_used_at not set: stamped=%v err=%v", stamped, err)
}
}