172 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|