Files
LambdaMail/services/protocols/internal/application/usecase/process_outbound_email_test.go
T

172 lines
6.0 KiB
Go

package usecase
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"github.com/alexedwards/argon2id"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
type fakeAuthRepo struct {
record *port.MailboxAuth
}
func (f *fakeAuthRepo) FindByAddress(_ context.Context, _ string) (*port.MailboxAuth, error) {
return f.record, nil
}
type countingOutboundRepo struct {
fakeOutboundRepo
recentCount int
}
func (c *countingOutboundRepo) CountRecipientsSince(_ context.Context, _ uuid.UUID, _ time.Time) (int, error) {
return c.recentCount, nil
}
type recordingSigner struct {
calledForDomain string
}
func (r *recordingSigner) Sign(_ context.Context, domainName string, message []byte) ([]byte, error) {
r.calledForDomain = domainName
return append([]byte("DKIM-Signature: v=1; d="+domainName+"\r\n"), message...), nil
}
func submissionInput(mailboxID uuid.UUID, recipients ...string) ProcessOutboundEmailInput {
return ProcessOutboundEmailInput{
MailboxID: mailboxID,
SenderAddr: "user@example.test",
DomainName: "example.test",
Recipients: recipients,
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody"),
MaxRecipientsPerHour: 200,
}
}
// PLAN.md section 3: submission signs with DKIM and enqueues one job per
// recipient, all referencing a single stored copy of the message.
func TestSubmit_SignsAndEnqueuesOneJobPerRecipient(t *testing.T) {
repo := &countingOutboundRepo{}
blobs := &capturingBlobStorage{}
signer := &recordingSigner{}
mailboxID := uuid.New()
uc := NewProcessOutboundEmailUseCase(nil, repo, blobs, signer)
err := uc.Submit(context.Background(), submissionInput(mailboxID, "a@remote.test", "b@other.test"))
if err != nil {
t.Fatalf("Submit: %v", err)
}
if len(repo.jobs) != 2 {
t.Fatalf("enqueued %d jobs, want 2", len(repo.jobs))
}
if signer.calledForDomain != "example.test" {
t.Errorf("signed for domain %q, want example.test", signer.calledForDomain)
}
if !strings.HasPrefix(string(blobs.stored), "DKIM-Signature:") {
t.Errorf("the stored message is not the signed one:\n%s", blobs.stored)
}
first, second := repo.jobs[0], repo.jobs[1]
if first.BlobID != second.BlobID {
t.Error("recipients got separate copies of the message instead of sharing one blob")
}
if first.DestinationDomain != "remote.test" || second.DestinationDomain != "other.test" {
t.Errorf("destination domains = %q, %q", first.DestinationDomain, second.DestinationDomain)
}
if first.Status != entity.OutboundJobStatusQueued {
t.Errorf("status = %s, want QUEUED", first.Status)
}
if first.ExpiresAt.Sub(first.NextAttemptAt) < 4*24*time.Hour {
t.Errorf("delivery window = %v, want the 5 day window of PLAN section 6.3", first.ExpiresAt.Sub(first.NextAttemptAt))
}
if first.MailboxID == nil || *first.MailboxID != mailboxID {
t.Error("job is not attributed to the sending mailbox")
}
}
// A compromised account must hit the wall before the mail is queued, not
// after (PLAN.md section 5.2).
func TestSubmit_RefusesWhenHourlyRecipientLimitWouldBeExceeded(t *testing.T) {
repo := &countingOutboundRepo{recentCount: 199}
uc := NewProcessOutboundEmailUseCase(nil, repo, &capturingBlobStorage{}, nil)
err := uc.Submit(context.Background(), submissionInput(uuid.New(), "a@remote.test", "b@remote.test"))
if !errors.Is(err, ErrSendLimitExceeded) {
t.Fatalf("error = %v, want ErrSendLimitExceeded", err)
}
if len(repo.jobs) != 0 {
t.Errorf("enqueued %d jobs despite exceeding the limit", len(repo.jobs))
}
}
func TestSubmit_AllowsSubmissionExactlyAtTheLimit(t *testing.T) {
repo := &countingOutboundRepo{recentCount: 198}
uc := NewProcessOutboundEmailUseCase(nil, repo, &capturingBlobStorage{}, nil)
if err := uc.Submit(context.Background(), submissionInput(uuid.New(), "a@remote.test", "b@remote.test")); err != nil {
t.Fatalf("Submit at exactly the limit: %v", err)
}
if len(repo.jobs) != 2 {
t.Errorf("enqueued %d jobs, want 2", len(repo.jobs))
}
}
func TestAuthenticate_AcceptsCorrectPasswordAndRejectsWrongOne(t *testing.T) {
hash, err := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
if err != nil {
t.Fatalf("CreateHash: %v", err)
}
repo := &fakeAuthRepo{record: &port.MailboxAuth{
ID: uuid.New(), PasswordHash: hash, EmailAddress: "user@example.test",
}}
uc := NewProcessOutboundEmailUseCase(repo, nil, nil, nil)
ctx := context.Background()
if _, err := uc.Authenticate(ctx, "user@example.test", "correct horse battery staple"); err != nil {
t.Fatalf("Authenticate with the right password: %v", err)
}
if _, err := uc.Authenticate(ctx, "user@example.test", "wrong"); !errors.Is(err, ErrSubmissionAuthFailed) {
t.Errorf("error = %v, want ErrSubmissionAuthFailed", err)
}
}
// An unknown mailbox and a wrong password must be indistinguishable, so the
// endpoint cannot be used to enumerate accounts.
func TestAuthenticate_UnknownMailboxYieldsTheSameError(t *testing.T) {
uc := NewProcessOutboundEmailUseCase(&fakeAuthRepo{record: nil}, nil, nil, nil)
if _, err := uc.Authenticate(context.Background(), "nobody@example.test", "x"); !errors.Is(err, ErrSubmissionAuthFailed) {
t.Fatalf("error = %v, want ErrSubmissionAuthFailed", err)
}
}
// Without this check any authenticated account could forge mail from any
// other address the server hosts.
func TestAuthorizeSender_RejectsForeignEnvelopeSender(t *testing.T) {
session := &port.MailboxAuth{EmailAddress: "user@example.test"}
if err := AuthorizeSender(session, "user@example.test"); err != nil {
t.Errorf("sending as itself was refused: %v", err)
}
if err := AuthorizeSender(session, "USER@EXAMPLE.TEST"); err != nil {
t.Errorf("address comparison must be case insensitive: %v", err)
}
if err := AuthorizeSender(session, "ceo@example.test"); !errors.Is(err, ErrSenderNotOwned) {
t.Errorf("error = %v, want ErrSenderNotOwned", err)
}
if err := AuthorizeSender(nil, "user@example.test"); !errors.Is(err, ErrSubmissionAuthFailed) {
t.Errorf("error = %v, want ErrSubmissionAuthFailed for an unauthenticated session", err)
}
}