Every fix here is a case where the code and the plan disagreed, and the code was wrong. Certificates (section 8.2, 8.4). The parser documented that it skipped expired entries but never checked, so an expired certificate would be served until every handshake failed. LastReload() only advanced when acme.json changed - and it does not change for sixty days - which made the timestamp risk R2 monitors indistinguishable from a watcher gone blind. Expiry is now checked on every pass, LastReload records the check and LastChange the swap, and the <7d/<24h alerts of section 8.4 exist. ARC (section 5). buildArcSealer resolved the DKIM key at startup, before runDnsReconciler had provisioned one. On a first boot ARC was disabled until someone restarted the process, which the log even admitted while offering no way for it to start. A LazySealer binds on first use instead. Health (section 8.4). There was no degraded state at all. /health now reports one - still 200, because restarting cannot fix a certificate Traefik never issued. Bounces. DSNs were sent From postmaster@lambdamail.local, a domain that does not resolve, so the notification was itself undeliverable. They now carry the configured mail host, a Message-ID, and Auto-Submitted so a vacation responder cannot loop against them. Fan-out atomicity (section 6.1). Persist ran one transaction per recipient, so a failure halfway through an alias fan-out left earlier recipients with a copy while the sender was told to retry - and the retry delivered to them twice. PersistAll commits the whole delivery or none of it. Verified against Postgres by reverting the repository to the old behaviour and confirming the new test fails on it. SMTP replies. The use case encoded replies in error text ("550 5.7.1 ...") which the adapter re-parsed by prefix, so rewording a message could turn a permanent refusal into a retry. SmtpRejection carries the decision and the adapter renders it. Repository tests. These only run with TEST_DATABASE_URL, which CI does set - so the job was red: copy and expunge seeded a 68-character digest into a CHAR(64) column, and three others used fixed digests that collide with the UNIQUE index on any re-run. A shared testBlobDigest() helper fixes both; the suite now passes twice in a row against one database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
218 lines
7.5 KiB
Go
218 lines
7.5 KiB
Go
package usecase
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"lambdamail/protocols/internal/application/port"
|
|
)
|
|
|
|
type fakeMailboxRepository struct {
|
|
byAddress map[string]port.MailboxRecord
|
|
}
|
|
|
|
func (f *fakeMailboxRepository) FindActiveByAddress(_ context.Context, address string) (*port.MailboxRecord, error) {
|
|
rec, ok := f.byAddress[address]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &rec, nil
|
|
}
|
|
|
|
func (f *fakeMailboxRepository) ResolveDeliveryTargets(_ context.Context, address string) ([]port.MailboxRecord, error) {
|
|
rec, ok := f.byAddress[address]
|
|
if !ok {
|
|
return []port.MailboxRecord{}, nil
|
|
}
|
|
return []port.MailboxRecord{rec}, nil
|
|
}
|
|
|
|
func TestUseCase_ResolveRecipient_ReturnsRecordForActiveMailbox(t *testing.T) {
|
|
mailboxID := uuid.New()
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
|
"user@example.test": {ID: mailboxID, MaxMessageBytes: 1000, QuotaBytes: 1000},
|
|
}}
|
|
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
|
|
|
|
targets, err := uc.ResolveRecipient(context.Background(), "user@example.test")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(targets) != 1 || targets[0].ID != mailboxID {
|
|
t.Errorf("targets = %+v, want single record with ID %v", targets, mailboxID)
|
|
}
|
|
}
|
|
|
|
func TestUseCase_ResolveRecipient_ReturnsErrRecipientNotFoundForUnknownAddress(t *testing.T) {
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{}}
|
|
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
|
|
|
|
_, err := uc.ResolveRecipient(context.Background(), "nobody@example.test")
|
|
if !errors.Is(err, ErrRecipientNotFound) {
|
|
t.Fatalf("error = %v, want ErrRecipientNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestUseCase_ResolveRecipient_ReturnsErrMailboxQuotaExceededWhenFull(t *testing.T) {
|
|
mailboxes := &fakeMailboxRepository{byAddress: map[string]port.MailboxRecord{
|
|
"full@example.test": {ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 1000},
|
|
}}
|
|
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
|
|
|
|
_, err := uc.ResolveRecipient(context.Background(), "full@example.test")
|
|
if !errors.Is(err, ErrMailboxQuotaExceeded) {
|
|
t.Fatalf("error = %v, want ErrMailboxQuotaExceeded", err)
|
|
}
|
|
}
|
|
|
|
func TestUseCase_ResolveRecipient_ReturnsErrMailboxQuotaExceededWhenAnyFanOutTargetIsFull(t *testing.T) {
|
|
mailboxes := &fakeAliasFanOutRepository{targets: []port.MailboxRecord{
|
|
{ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 0},
|
|
{ID: uuid.New(), QuotaBytes: 1000, UsedBytes: 1000},
|
|
}}
|
|
uc := NewProcessInboundEmailUseCase(mailboxes, nil, nil)
|
|
|
|
_, err := uc.ResolveRecipient(context.Background(), "alias@example.test")
|
|
if !errors.Is(err, ErrMailboxQuotaExceeded) {
|
|
t.Fatalf("error = %v, want ErrMailboxQuotaExceeded when any fan-out target is full", err)
|
|
}
|
|
}
|
|
|
|
// fakeAliasFanOutRepository simulates an alias resolving to multiple mailboxes.
|
|
type fakeAliasFanOutRepository struct {
|
|
targets []port.MailboxRecord
|
|
}
|
|
|
|
func (f *fakeAliasFanOutRepository) FindActiveByAddress(_ context.Context, _ string) (*port.MailboxRecord, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeAliasFanOutRepository) ResolveDeliveryTargets(_ context.Context, _ string) ([]port.MailboxRecord, error) {
|
|
return f.targets, nil
|
|
}
|
|
|
|
type stubBlobStorage struct {
|
|
ref port.BlobRef
|
|
err error
|
|
}
|
|
|
|
func (s *stubBlobStorage) Store(_ context.Context, r io.Reader) (port.BlobRef, error) {
|
|
_, _ = io.Copy(io.Discard, r)
|
|
return s.ref, s.err
|
|
}
|
|
|
|
// errRepositoryFailure stands in for any persistence error.
|
|
var errRepositoryFailure = errors.New("repository is unavailable")
|
|
|
|
type recordingMessageRepository struct {
|
|
persisted []port.PersistInboundMessageInput
|
|
nextUID int64
|
|
// failAfter makes PersistAll reject a batch larger than this many
|
|
// recipients, standing in for a partial write in the real repository.
|
|
failAfter int
|
|
}
|
|
|
|
func (r *recordingMessageRepository) PersistAll(_ context.Context, inputs []port.PersistInboundMessageInput) ([]int64, error) {
|
|
// The real repository commits once for the whole batch, so a failure
|
|
// records nothing. Mirroring that here keeps the mock honest.
|
|
if r.failAfter > 0 && len(inputs) > r.failAfter {
|
|
return nil, errRepositoryFailure
|
|
}
|
|
|
|
uids := make([]int64, 0, len(inputs))
|
|
for _, input := range inputs {
|
|
r.persisted = append(r.persisted, input)
|
|
r.nextUID++
|
|
uids = append(uids, r.nextUID)
|
|
}
|
|
return uids, nil
|
|
}
|
|
|
|
func TestUseCase_Handle_StoresBlobOnceAndPersistsOncePerRecipient(t *testing.T) {
|
|
blobRef := port.BlobRef{ID: uuid.New(), SHA256: "abc123", SizeBytes: 42}
|
|
blobs := &stubBlobStorage{ref: blobRef}
|
|
messages := &recordingMessageRepository{}
|
|
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
|
|
|
|
recipients := []port.MailboxRecord{
|
|
{ID: uuid.New(), MaxMessageBytes: 1000},
|
|
{ID: uuid.New(), MaxMessageBytes: 1000},
|
|
}
|
|
addresses := []string{"a@example.test", "b@example.test"}
|
|
|
|
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
|
Sender: "sender@example.test",
|
|
Recipients: recipients,
|
|
RecipientAddresses: addresses,
|
|
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(messages.persisted) != 2 {
|
|
t.Fatalf("persisted %d messages, want 2 (one per recipient, blob stored once)", len(messages.persisted))
|
|
}
|
|
for i, p := range messages.persisted {
|
|
if p.Blob.ID != blobRef.ID {
|
|
t.Errorf("persisted[%d].Blob.ID = %v, want %v (same blob for every recipient)", i, p.Blob.ID, blobRef.ID)
|
|
}
|
|
if p.MailboxID != recipients[i].ID {
|
|
t.Errorf("persisted[%d].MailboxID = %v, want %v", i, p.MailboxID, recipients[i].ID)
|
|
}
|
|
if p.RecipientAddress != addresses[i] {
|
|
t.Errorf("persisted[%d].RecipientAddress = %q, want %q", i, p.RecipientAddress, addresses[i])
|
|
}
|
|
if p.SPFResult != "none" || p.DKIMResult != "none" || p.DMARCResult != "none" {
|
|
t.Errorf("persisted[%d] auth results = %q/%q/%q, want \"none\"/\"none\"/\"none\" (verification lands in a later sub-project)", i, p.SPFResult, p.DKIMResult, p.DMARCResult)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUseCase_Handle_PropagatesBlobStorageError(t *testing.T) {
|
|
blobs := &stubBlobStorage{err: errors.New("disk full")}
|
|
uc := NewProcessInboundEmailUseCase(nil, blobs, &recordingMessageRepository{})
|
|
|
|
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
|
Sender: "sender@example.test",
|
|
Recipients: []port.MailboxRecord{{ID: uuid.New()}},
|
|
RecipientAddresses: []string{"a@example.test"},
|
|
Body: bytes.NewBufferString("x"),
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error when blob storage fails, got nil")
|
|
}
|
|
}
|
|
|
|
// One SMTP transaction gets one reply, so a fan-out delivery has to be
|
|
// all-or-nothing: if the sender is told to retry, no recipient may already
|
|
// hold a copy, or the retry duplicates it for them.
|
|
func TestUseCase_Handle_DoesNotPartiallyDeliverOnFanOut(t *testing.T) {
|
|
blobs := &stubBlobStorage{ref: port.BlobRef{ID: uuid.New(), SHA256: "abc123", SizeBytes: 42}}
|
|
messages := &recordingMessageRepository{failAfter: 1}
|
|
uc := NewProcessInboundEmailUseCase(nil, blobs, messages)
|
|
|
|
recipients := []port.MailboxRecord{
|
|
{ID: uuid.New()},
|
|
{ID: uuid.New()},
|
|
}
|
|
|
|
err := uc.Handle(context.Background(), ProcessInboundEmailInput{
|
|
Sender: "sender@remote.test",
|
|
Recipients: recipients,
|
|
RecipientAddresses: []string{"alias@example.test", "alias@example.test"},
|
|
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody\r\n"),
|
|
})
|
|
|
|
if err == nil {
|
|
t.Fatal("Handle reported success even though persistence failed")
|
|
}
|
|
if len(messages.persisted) != 0 {
|
|
t.Errorf("%d recipients were left with a copy after a failed delivery; the sender's retry would duplicate it", len(messages.persisted))
|
|
}
|
|
}
|