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.
180 lines
6.0 KiB
Go
180 lines
6.0 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
|
|
"lambdamail/protocols/internal/application/port"
|
|
)
|
|
|
|
func TestAuthRepository_FindByAddress_ReturnsHashForActiveMailbox(t *testing.T) {
|
|
pool := testPool(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
|
|
domainID := uuid.New()
|
|
domainName := "authrepo-test-" + domainID.String() + ".invalid"
|
|
_, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
|
domainID, domainName, domainName)
|
|
if err != nil {
|
|
t.Fatalf("seed domain: %v", err)
|
|
}
|
|
defer pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID)
|
|
|
|
mailboxID := uuid.New()
|
|
address := "user@" + domainName
|
|
hash := "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG"
|
|
_, err = pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'user', $3, $4)`,
|
|
mailboxID, domainID, address, hash)
|
|
if err != nil {
|
|
t.Fatalf("seed mailbox: %v", err)
|
|
}
|
|
|
|
repo := NewAuthRepository(pool)
|
|
rec, err := repo.FindByAddress(ctx, address)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if rec == nil {
|
|
t.Fatal("rec = nil, want a record")
|
|
}
|
|
if rec.ID != mailboxID || rec.PasswordHash != hash {
|
|
t.Errorf("rec = %+v, want ID=%v PasswordHash=%q", rec, mailboxID, hash)
|
|
}
|
|
}
|
|
|
|
func TestAuthRepository_FindByAddress_ReturnsNilForUnknownAddress(t *testing.T) {
|
|
pool := testPool(t)
|
|
defer pool.Close()
|
|
|
|
repo := NewAuthRepository(pool)
|
|
rec, err := repo.FindByAddress(context.Background(), "definitely-not-seeded@example.invalid")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if rec != nil {
|
|
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)
|
|
}
|
|
}
|