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.
379 lines
14 KiB
Go
379 lines
14 KiB
Go
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package mailsqlite
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"gamertan.com/web/mail"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type testClock struct{ seconds atomic.Int64 }
|
|
|
|
func (clock *testClock) now() time.Time { return time.Unix(clock.seconds.Load(), 0).UTC() }
|
|
func (clock *testClock) advance(duration time.Duration) {
|
|
clock.seconds.Add(int64(duration / time.Second))
|
|
}
|
|
|
|
type transportFunc func(context.Context, mail.Message) error
|
|
|
|
func (send transportFunc) Send(ctx context.Context, message mail.Message) error {
|
|
return send(ctx, message)
|
|
}
|
|
|
|
func fixture(t *testing.T, capacity int) (*Queue, *sql.DB, *testClock) {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "mail.sqlite"))+"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(8)
|
|
t.Cleanup(func() { db.Close() })
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = CreateSchema(context.Background(), tx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = CreateSchema(context.Background(), tx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clock := &testClock{}
|
|
clock.seconds.Store(time.Date(2026, 9, 11, 6, 0, 0, 0, time.UTC).Unix())
|
|
queue, err := New(db, Options{EncryptionKey: bytes.Repeat([]byte{17}, 32), MaxPending: capacity, Now: clock.now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return queue, db, clock
|
|
}
|
|
|
|
func messageAt(clock *testClock, number int) mail.Message {
|
|
return mail.Message{ID: fmt.Sprintf("mail_message_%016d", number), To: "recipient@example.test", Subject: "Confirm your email", Text: "Private recovery token: only-in-ciphertext-123", CreatedAt: clock.now()}
|
|
}
|
|
|
|
func enqueue(t *testing.T, queue *Queue, clock *testClock, number int) mail.Message {
|
|
t.Helper()
|
|
message := messageAt(clock, number)
|
|
if err := queue.Enqueue(context.Background(), message, clock.now().Add(time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return message
|
|
}
|
|
|
|
func storedPayload(t *testing.T, db *sql.DB, id string) []byte {
|
|
t.Helper()
|
|
var sealed []byte
|
|
if err := db.QueryRow(`SELECT payload FROM gwf_mail_outbox WHERE id=?`, id).Scan(&sealed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return sealed
|
|
}
|
|
|
|
func TestTransactionalEncryptionAndDeduplication(t *testing.T) {
|
|
queue, db, clock := fixture(t, 1)
|
|
ctx := context.Background()
|
|
message := messageAt(clock, 1)
|
|
expires := clock.now().Add(time.Hour)
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = tx.Exec(`CREATE TABLE domain_change(id TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = tx.Exec(`INSERT INTO domain_change VALUES ('changed')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = queue.EnqueueTx(ctx, tx, message, expires); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err = tx.Rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var count int
|
|
if err = db.QueryRow(`SELECT COUNT(*) FROM gwf_mail_outbox`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("rollback count=%d err=%v", count, err)
|
|
}
|
|
if err = db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE name='domain_change'`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("domain rollback count=%d err=%v", count, err)
|
|
}
|
|
if err = queue.Enqueue(ctx, message, expires); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed := storedPayload(t, db, message.ID)
|
|
if len(sealed) == 0 || bytes.Contains(sealed, []byte(message.To)) || bytes.Contains(sealed, []byte(message.Text)) {
|
|
t.Fatal("payload is not encrypted")
|
|
}
|
|
if err = queue.Enqueue(ctx, message, expires); err != nil {
|
|
t.Fatalf("idempotent at capacity: %v", err)
|
|
}
|
|
changed := message
|
|
changed.Text = "Different content"
|
|
if err = queue.Enqueue(ctx, changed, expires); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("conflicting content: %v", err)
|
|
}
|
|
if err = queue.Enqueue(ctx, message, expires.Add(time.Minute)); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("conflicting expiry: %v", err)
|
|
}
|
|
if err = queue.Enqueue(ctx, messageAt(clock, 2), expires); !errors.Is(err, ErrFull) {
|
|
t.Fatalf("capacity: %v", err)
|
|
}
|
|
var calls int
|
|
send := transportFunc(func(_ context.Context, got mail.Message) error {
|
|
calls++
|
|
if got.ID != message.ID || got.To != message.To || got.Text != message.Text {
|
|
t.Fatal("message changed")
|
|
}
|
|
return nil
|
|
})
|
|
record, work, err := queue.ProcessOne(ctx, send)
|
|
if err != nil || !work || record.State != "accepted" || record.Attempts != 1 || !record.NextAttemptAt.IsZero() {
|
|
t.Fatalf("accept: %+v work=%v err=%v", record, work, err)
|
|
}
|
|
if storedPayload(t, db, message.ID) != nil {
|
|
t.Fatal("accepted payload retained")
|
|
}
|
|
if err = queue.Enqueue(ctx, message, expires); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, work, err = queue.ProcessOne(ctx, send); err != nil || work || calls != 1 {
|
|
t.Fatalf("completed ID sent again: calls=%d work=%v err=%v", calls, work, err)
|
|
}
|
|
records, err := queue.Recent(ctx, 10)
|
|
if err != nil || len(records) != 1 || records[0].State != "accepted" || !records[0].NextAttemptAt.IsZero() {
|
|
t.Fatalf("metadata: %+v %v", records, err)
|
|
}
|
|
if err = queue.Enqueue(ctx, messageAt(clock, 2), expires); err != nil {
|
|
t.Fatalf("released capacity: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConcurrentCapacity(t *testing.T) {
|
|
queue, _, clock := fixture(t, 1)
|
|
var group sync.WaitGroup
|
|
var wins, full atomic.Int32
|
|
for number := range 8 {
|
|
group.Go(func() {
|
|
err := queue.Enqueue(context.Background(), messageAt(clock, number), clock.now().Add(time.Hour))
|
|
if err == nil {
|
|
wins.Add(1)
|
|
} else if errors.Is(err, ErrFull) {
|
|
full.Add(1)
|
|
} else {
|
|
t.Errorf("enqueue: %v", err)
|
|
}
|
|
})
|
|
}
|
|
group.Wait()
|
|
if wins.Load() != 1 || full.Load() != 7 {
|
|
t.Fatalf("wins=%d full=%d", wins.Load(), full.Load())
|
|
}
|
|
}
|
|
|
|
func TestRetryBoundsAndSafeDiagnostics(t *testing.T) {
|
|
queue, db, clock := fixture(t, 0)
|
|
message := enqueue(t, queue, clock, 1)
|
|
send := transportFunc(func(context.Context, mail.Message) error {
|
|
return &mail.Error{Stage: "recipient", Code: 450, Retryable: true}
|
|
})
|
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
|
record, work, err := queue.ProcessOne(context.Background(), send)
|
|
if err != nil || !work || record.Attempts != attempt || record.FailureStage != "recipient" || record.SMTPCode != 450 {
|
|
t.Fatalf("attempt %d: %+v %v %v", attempt, record, work, err)
|
|
}
|
|
if attempt == maxAttempts {
|
|
if record.State != "failed" || storedPayload(t, db, message.ID) != nil {
|
|
t.Fatal("exhausted payload not cleared")
|
|
}
|
|
break
|
|
}
|
|
delay := time.Minute * time.Duration(1<<(attempt-1))
|
|
if record.State != "queued" || !record.NextAttemptAt.Equal(clock.now().Add(delay)) {
|
|
t.Fatalf("retry schedule: %+v", record)
|
|
}
|
|
if _, work, err = queue.ProcessOne(context.Background(), send); err != nil || work {
|
|
t.Fatalf("early retry: %v %v", work, err)
|
|
}
|
|
clock.advance(delay)
|
|
}
|
|
enqueue(t, queue, clock, 2)
|
|
record, _, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error {
|
|
return &mail.Error{Stage: "secret-recipient@example.test", Code: 9999, Retryable: false}
|
|
}))
|
|
if err != nil || record.State != "failed" || record.FailureStage != "transport" || record.SMTPCode != 0 {
|
|
t.Fatalf("unsafe diagnostic: %+v %v", record, err)
|
|
}
|
|
enqueue(t, queue, clock, 3)
|
|
record, _, err = queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return errors.New("contains private token and address") }))
|
|
if err != nil || record.State != "queued" || record.FailureStage != "transport" {
|
|
t.Fatalf("raw transport error: %+v %v", record, err)
|
|
}
|
|
}
|
|
|
|
func TestExpiryAndCiphertextRecovery(t *testing.T) {
|
|
queue, db, clock := fixture(t, 0)
|
|
message := enqueue(t, queue, clock, 1)
|
|
sealed := storedPayload(t, db, message.ID)
|
|
wrong, err := New(db, Options{EncryptionKey: bytes.Repeat([]byte{18}, 32), Now: clock.now})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var calls int
|
|
send := transportFunc(func(context.Context, mail.Message) error { calls++; return nil })
|
|
record, work, err := wrong.ProcessOne(context.Background(), send)
|
|
if err != nil || !work || record.State != "queued" || record.FailureStage != "payload" || record.Attempts != 0 || calls != 0 {
|
|
t.Fatalf("wrong key: %+v %v %v calls=%d", record, work, err, calls)
|
|
}
|
|
if !bytes.Equal(sealed, storedPayload(t, db, message.ID)) {
|
|
t.Fatal("key error destroyed pending ciphertext")
|
|
}
|
|
clock.advance(5 * time.Minute)
|
|
record, work, err = queue.ProcessOne(context.Background(), send)
|
|
if err != nil || !work || record.State != "accepted" || record.Attempts != 1 || calls != 1 {
|
|
t.Fatalf("key recovery: %+v %v %v calls=%d", record, work, err, calls)
|
|
}
|
|
message = enqueue(t, queue, clock, 2)
|
|
sealed = storedPayload(t, db, message.ID)
|
|
sealed[len(sealed)-1] ^= 1
|
|
if _, err = db.Exec(`UPDATE gwf_mail_outbox SET payload=? WHERE id=?`, sealed, message.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
record, _, err = queue.ProcessOne(context.Background(), send)
|
|
if err != nil || record.FailureStage != "payload" || calls != 1 {
|
|
t.Fatalf("tamper sent: %+v %v calls=%d", record, err, calls)
|
|
}
|
|
clock.advance(time.Hour)
|
|
if _, work, err = queue.ProcessOne(context.Background(), send); err != nil || work || calls != 1 {
|
|
t.Fatalf("expired sent: work=%v err=%v calls=%d", work, err, calls)
|
|
}
|
|
if storedPayload(t, db, message.ID) != nil {
|
|
t.Fatal("expired corrupt payload retained")
|
|
}
|
|
if _, err = queue.open(message.ID, sealed[:3], clock.now().Unix()); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("short ciphertext accepted")
|
|
}
|
|
valid := enqueue(t, queue, clock, 3)
|
|
if _, err = queue.open("another_message_identity", storedPayload(t, db, valid.ID), clock.now().Add(time.Hour).Unix()); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("AAD identity not bound")
|
|
}
|
|
if _, err = queue.open(valid.ID, storedPayload(t, db, valid.ID), clock.now().Add(2*time.Hour).Unix()); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("expiry not bound")
|
|
}
|
|
}
|
|
|
|
func TestLeaseIsolationAndLateAcknowledgement(t *testing.T) {
|
|
queue, db, clock := fixture(t, 0)
|
|
message := enqueue(t, queue, clock, 1)
|
|
entered, release := make(chan struct{}), make(chan struct{})
|
|
type result struct {
|
|
record Record
|
|
work bool
|
|
err error
|
|
}
|
|
done := make(chan result, 1)
|
|
go func() {
|
|
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(ctx context.Context, _ mail.Message) error {
|
|
close(entered)
|
|
select {
|
|
case <-release:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}))
|
|
done <- result{record, work, err}
|
|
}()
|
|
<-entered
|
|
if _, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { t.Error("duplicate active lease"); return nil })); err != nil || work {
|
|
t.Fatalf("leased work claimed: %v %v", work, err)
|
|
}
|
|
// SMTP cannot hold the SQLite writer lock while waiting on the network.
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
defer cancel()
|
|
if _, err := db.ExecContext(ctx, `CREATE TABLE independent_domain_write(id TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clock.advance(leaseDuration + time.Second)
|
|
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return nil }))
|
|
if err != nil || !work || record.State != "accepted" || record.Attempts != 2 {
|
|
t.Fatalf("takeover: %+v %v %v", record, work, err)
|
|
}
|
|
close(release)
|
|
previous := <-done
|
|
if !previous.work || !errors.Is(previous.err, ErrConflict) {
|
|
t.Fatalf("late acknowledgement: %+v", previous)
|
|
}
|
|
if storedPayload(t, db, message.ID) != nil {
|
|
t.Fatal("late worker restored payload")
|
|
}
|
|
}
|
|
|
|
func TestCancellationLeavesRecoverableLease(t *testing.T) {
|
|
queue, db, clock := fixture(t, 0)
|
|
message := enqueue(t, queue, clock, 1)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
_, work, err := queue.ProcessOne(ctx, transportFunc(func(context.Context, mail.Message) error { cancel(); return nil }))
|
|
if !work || !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancelled acknowledgement: %v %v", work, err)
|
|
}
|
|
if storedPayload(t, db, message.ID) == nil {
|
|
t.Fatal("uncertain delivery destroyed recovery payload")
|
|
}
|
|
clock.advance(leaseDuration + time.Second)
|
|
record, work, err := queue.ProcessOne(context.Background(), transportFunc(func(context.Context, mail.Message) error { return nil }))
|
|
if err != nil || !work || record.State != "accepted" || record.Attempts != 2 {
|
|
t.Fatalf("recovery: %+v %v %v", record, work, err)
|
|
}
|
|
}
|
|
|
|
func TestValidation(t *testing.T) {
|
|
queue, db, clock := fixture(t, 0)
|
|
ctx := context.Background()
|
|
for _, options := range []Options{{}, {EncryptionKey: make([]byte, 31)}, {EncryptionKey: make([]byte, 32), MaxPending: -1}, {EncryptionKey: make([]byte, 32), MaxPending: 10001}} {
|
|
if _, err := New(db, options); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("invalid options: %v", err)
|
|
}
|
|
}
|
|
if _, err := New(nil, Options{EncryptionKey: make([]byte, 32)}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("nil DB accepted")
|
|
}
|
|
if err := CreateSchema(ctx, nil); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("nil migration accepted")
|
|
}
|
|
if err := queue.EnqueueTx(ctx, nil, messageAt(clock, 1), clock.now().Add(time.Hour)); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("nil tx accepted")
|
|
}
|
|
for _, expiry := range []time.Time{clock.now(), clock.now().Add(-time.Minute), clock.now().Add(25 * time.Hour)} {
|
|
if err := queue.Enqueue(ctx, messageAt(clock, 1), expiry); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("invalid expiry: %v", err)
|
|
}
|
|
}
|
|
message := messageAt(clock, 1)
|
|
message.CreatedAt = clock.now().Add(2 * time.Minute)
|
|
if err := queue.Enqueue(ctx, message, clock.now().Add(time.Hour)); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("future message accepted")
|
|
}
|
|
for _, limit := range []int{0, 101} {
|
|
if _, err := queue.Recent(ctx, limit); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("invalid metadata limit")
|
|
}
|
|
}
|
|
if _, _, err := queue.ProcessOne(ctx, nil); !errors.Is(err, ErrInvalid) {
|
|
t.Fatal("nil transport accepted")
|
|
}
|
|
}
|