Add end-to-end tests for TLS RPT report ingestion and WebSocket event handling
- Implemented tests for ingesting TLS RPT reports from emails and ensuring they are stored correctly in the database. - Added tests to verify that the same report can be ingested multiple times without duplication. - Created tests for backfilling reports that were previously delivered to the inbox. - Developed WebSocket event handling tests to ensure events are correctly routed to subscribers based on mailbox ownership. - Included tests for the mail API to validate session handling, mailbox isolation, and error mapping. - Ensured that the API rejects unauthorized requests and handles malformed JSON gracefully.
This commit is contained in:
1 parent
880dced9e4
commit
f330c4324e
28 files changed
+2438
-6
No files matched your search
@@ -0,0 +1,191 @@
|
||||
package httppresentation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
)
|
||||
|
||||
// The event stream tells a browser its mailbox changed. It is the one place
|
||||
// where one account's activity is pushed out over another's connection if the
|
||||
// filtering is wrong, and none of it was covered.
|
||||
|
||||
func newTestHub(t *testing.T, store *fakeMailStore) *EventHub {
|
||||
t.Helper()
|
||||
uc := usecase.NewWebmailUseCase(store, &fakeBlobReader{}, nil, nil, "mail.example.test")
|
||||
return NewEventHub(testVerifier(interopSecret), uc)
|
||||
}
|
||||
|
||||
// The hub matches on the aggregate's UUID string, so subscribers are keyed by
|
||||
// real UUIDs rather than the readable names the mail API fakes use.
|
||||
var (
|
||||
mailboxOne = uuid.New()
|
||||
mailboxTwo = uuid.New()
|
||||
)
|
||||
|
||||
// subscribeTo registers a subscriber directly, which is what the WebSocket
|
||||
// handler does after authenticating. Driving a real upgrade would test the
|
||||
// library; this tests the routing decision, which is ours.
|
||||
func subscribeTo(hub *EventHub, mailboxID string, buffer int) *subscriber {
|
||||
sub := &subscriber{mailboxID: mailboxID, events: make(chan EventMessage, buffer)}
|
||||
hub.add(sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
// The check that keeps one mailbox's activity off another's connection.
|
||||
func TestEventsReachOnlyTheirOwnMailbox(t *testing.T) {
|
||||
hub := newTestHub(t, storeWithMail())
|
||||
mine := subscribeTo(hub, mailboxOne.String(), 4)
|
||||
theirs := subscribeTo(hub, mailboxTwo.String(), 4)
|
||||
|
||||
hub.Publish(port.OutboxEvent{
|
||||
EventType: "EmailReceived",
|
||||
AggregateID: mailboxOne,
|
||||
Payload: []byte(`{"uid":7}`),
|
||||
})
|
||||
|
||||
select {
|
||||
case msg := <-mine.events:
|
||||
if msg.Type != "EmailReceived" {
|
||||
t.Errorf("got event type %q", msg.Type)
|
||||
}
|
||||
default:
|
||||
t.Error("the mailbox's own event never arrived")
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-theirs.events:
|
||||
t.Errorf("another mailbox's event was delivered: %+v", msg)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// A subscriber that has stopped reading must be dropped, never allowed to
|
||||
// stall the relay that every other connection shares.
|
||||
func TestASlowSubscriberIsDroppedRatherThanBlocking(t *testing.T) {
|
||||
hub := newTestHub(t, storeWithMail())
|
||||
// Buffer of one, then filled, so the next publish has nowhere to go.
|
||||
stalled := subscribeTo(hub, mailboxOne.String(), 1)
|
||||
stalled.events <- EventMessage{Type: "filler"}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
hub.Publish(port.OutboxEvent{
|
||||
EventType: "EmailReceived",
|
||||
AggregateID: mailboxOne,
|
||||
Payload: []byte(`{}`),
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-context.Background().Done():
|
||||
t.Fatal("Publish blocked on a subscriber that was not reading")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriberCountFollowsAddAndRemove(t *testing.T) {
|
||||
hub := newTestHub(t, storeWithMail())
|
||||
if hub.SubscriberCount() != 0 {
|
||||
t.Fatalf("a new hub has %d subscribers", hub.SubscriberCount())
|
||||
}
|
||||
|
||||
sub := subscribeTo(hub, mailboxOne.String(), 1)
|
||||
if hub.SubscriberCount() != 1 {
|
||||
t.Errorf("after one subscriber: %d", hub.SubscriberCount())
|
||||
}
|
||||
|
||||
hub.remove(sub)
|
||||
if hub.SubscriberCount() != 0 {
|
||||
t.Errorf("after removing it: %d", hub.SubscriberCount())
|
||||
}
|
||||
}
|
||||
|
||||
// Publishing with nobody listening must be a no-op, not a panic: the relay
|
||||
// runs whether or not a browser happens to be open.
|
||||
func TestPublishWithNoSubscribersIsHarmless(t *testing.T) {
|
||||
hub := newTestHub(t, storeWithMail())
|
||||
hub.Publish(port.OutboxEvent{
|
||||
EventType: "EmailReceived",
|
||||
AggregateID: uuid.New(),
|
||||
Payload: []byte(`{}`),
|
||||
})
|
||||
}
|
||||
|
||||
// --- the upgrade's own authentication ------------------------------------
|
||||
|
||||
// The session is verified before the upgrade, so an unauthenticated caller
|
||||
// gets a plain 401 and never reaches the WebSocket machinery.
|
||||
func TestEventStreamRefusesAnUnauthenticatedUpgrade(t *testing.T) {
|
||||
hub := newTestHub(t, storeWithMail())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
hub.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/events", nil))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("an unauthenticated upgrade answered %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStreamRefusesAnAdminSession(t *testing.T) {
|
||||
hub := newTestHub(t, storeWithMail())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "lm_user_session", Value: adminSurfaceToken(t)})
|
||||
rec := httptest.NewRecorder()
|
||||
hub.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("an admin session reached the event stream: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A valid session for an address this server no longer serves must not open a
|
||||
// stream either - that is a deleted account whose token has not expired yet.
|
||||
func TestEventStreamRefusesASessionWithNoMailbox(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
delete(store.addresses, sessionOwner)
|
||||
hub := newTestHub(t, store)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "lm_user_session", Value: interopSession})
|
||||
rec := httptest.NewRecorder()
|
||||
hub.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("a session with no mailbox answered %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The payload has to reach the browser unchanged: the page reads the UID out
|
||||
// of it to decide what to refresh.
|
||||
func TestEventCarriesItsPayloadThrough(t *testing.T) {
|
||||
hub := newTestHub(t, storeWithMail())
|
||||
sub := subscribeTo(hub, mailboxOne.String(), 2)
|
||||
|
||||
hub.Publish(port.OutboxEvent{
|
||||
EventType: "EmailReceived",
|
||||
AggregateID: mailboxOne,
|
||||
Payload: []byte(`{"recipient":"user@example.test","uid":42}`),
|
||||
})
|
||||
|
||||
msg := <-sub.events
|
||||
var payload struct {
|
||||
Recipient string `json:"recipient"`
|
||||
UID int `json:"uid"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
t.Fatalf("payload did not survive: %v", err)
|
||||
}
|
||||
if payload.UID != 42 || payload.Recipient != "user@example.test" {
|
||||
t.Errorf("payload arrived as %+v", payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package httppresentation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/application/usecase"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
)
|
||||
|
||||
// The webmail's HTTP surface: the only part of this server a browser talks to
|
||||
// directly, and the one that decides whose mail a session may touch.
|
||||
//
|
||||
// None of it was covered. Every one of these asserts something a mistake here
|
||||
// would cost: another account's mail, an attachment rendered as a page on this
|
||||
// origin, or a sender address chosen by the request rather than the session.
|
||||
|
||||
// --- test doubles --------------------------------------------------------
|
||||
|
||||
// fakeMailStore records what the API asked for, so a handler can be checked
|
||||
// on the arguments it passed rather than only on its status code.
|
||||
type fakeMailStore struct {
|
||||
// byMailbox is the mail each mailbox owns, keyed by mailbox ID.
|
||||
byMailbox map[string][]port.WebmailMessage
|
||||
addresses map[string]string // address -> mailbox ID
|
||||
|
||||
seenCalls []string
|
||||
deletedCalls []string
|
||||
movedCalls []string
|
||||
expungeErr error
|
||||
moveErr error
|
||||
blob uuid.UUID
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) FindMailboxIDByAddress(_ context.Context, address string) (string, error) {
|
||||
id, ok := f.addresses[address]
|
||||
if !ok {
|
||||
return "", postgres.ErrMessageNotFound
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) ListFolders(_ context.Context, mailboxID string) ([]port.WebmailFolder, error) {
|
||||
return []port.WebmailFolder{{
|
||||
ID: "f1", Name: "INBOX", SpecialUse: "inbox",
|
||||
TotalCount: len(f.byMailbox[mailboxID]),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) ListMessages(
|
||||
_ context.Context, mailboxID, _, _ string, _, _ int,
|
||||
) ([]port.WebmailMessage, error) {
|
||||
return f.byMailbox[mailboxID], nil
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) GetMessageBlob(
|
||||
_ context.Context, mailboxID, _ string, uid uint32,
|
||||
) (uuid.UUID, error) {
|
||||
for _, m := range f.byMailbox[mailboxID] {
|
||||
if m.UID == uid {
|
||||
return f.blob, nil
|
||||
}
|
||||
}
|
||||
// Exactly what the real repository does for a UID this mailbox does not
|
||||
// own: not found, indistinguishable from one that does not exist.
|
||||
return uuid.Nil, postgres.ErrMessageNotFound
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) MarkSeen(_ context.Context, mailboxID, folder string, uid uint32, seen bool) error {
|
||||
f.seenCalls = append(f.seenCalls, mailboxID+"/"+folder)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid uint32) error {
|
||||
if f.expungeErr != nil {
|
||||
return f.expungeErr
|
||||
}
|
||||
f.expungeErr = nil
|
||||
f.deletedCalls = append(f.deletedCalls, mailboxID+"/"+folder)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) MoveToTrash(
|
||||
_ context.Context, mailboxID, folder string, uid uint32,
|
||||
) (uint32, error) {
|
||||
if f.moveErr != nil {
|
||||
return 0, f.moveErr
|
||||
}
|
||||
f.movedCalls = append(f.movedCalls, mailboxID+"/"+folder)
|
||||
return uid, nil
|
||||
}
|
||||
|
||||
type fakeBlobReader struct{ raw []byte }
|
||||
|
||||
func (f *fakeBlobReader) ReadByID(context.Context, uuid.UUID) ([]byte, error) {
|
||||
return f.raw, nil
|
||||
}
|
||||
|
||||
// --- harness -------------------------------------------------------------
|
||||
|
||||
// newMailAPI builds the API over fakes, pinned to an instant inside the
|
||||
// captured tokens' validity window so these tests never age out.
|
||||
func newMailAPI(t *testing.T, store *fakeMailStore, raw []byte) *mailAPI {
|
||||
t.Helper()
|
||||
uc := usecase.NewWebmailUseCase(store, &fakeBlobReader{raw: raw}, nil, nil, "mail.example.test")
|
||||
return &mailAPI{useCase: uc, sessions: testVerifier(interopSecret)}
|
||||
}
|
||||
|
||||
// The session in interopSession belongs to this address.
|
||||
const sessionOwner = "user@example.test"
|
||||
|
||||
func request(method, target string, body string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+interopSession)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func storeWithMail() *fakeMailStore {
|
||||
return &fakeMailStore{
|
||||
addresses: map[string]string{sessionOwner: "mailbox-1", "other@example.test": "mailbox-2"},
|
||||
byMailbox: map[string][]port.WebmailMessage{
|
||||
"mailbox-1": {{UID: 1, Subject: "mine"}},
|
||||
"mailbox-2": {{UID: 99, Subject: "somebody else's"}},
|
||||
},
|
||||
blob: uuid.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- authentication ------------------------------------------------------
|
||||
|
||||
func TestMailApiRefusesARequestWithNoSession(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
for name, req := range map[string]*http.Request{
|
||||
"folders": httptest.NewRequest(http.MethodGet, "/api/v1/mail/folders", nil),
|
||||
"messages": httptest.NewRequest(http.MethodGet, "/api/v1/mail/messages", nil),
|
||||
"seen": httptest.NewRequest(http.MethodPost, "/api/v1/mail/seen", strings.NewReader(`{"uid":1}`)),
|
||||
"delete": httptest.NewRequest(http.MethodPost, "/api/v1/mail/delete", strings.NewReader(`{"uid":1}`)),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
switch name {
|
||||
case "folders":
|
||||
api.handleFolders(rec, req)
|
||||
case "messages":
|
||||
api.handleMessages(rec, req)
|
||||
case "seen":
|
||||
api.handleSeen(rec, req)
|
||||
case "delete":
|
||||
api.handleDelete(rec, req)
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s answered %d without a session, want 401", name, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An admin session must not reach the mail API. The two surfaces are separated
|
||||
// by audience precisely so a console token cannot read a mailbox.
|
||||
func TestMailApiRefusesAnAdminSession(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/folders", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminSurfaceToken(t))
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleFolders(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("an admin token reached the mail API: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The half-issued token from between the password and the second factor is not
|
||||
// a session and must not be accepted as one.
|
||||
func TestMailApiRefusesAnMfaChallengeToken(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/folders", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+interopChallenge)
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleFolders(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("an MFA challenge token was accepted as a session: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A tampered signature must fail, or the secret is doing nothing.
|
||||
func TestMailApiRefusesATamperedToken(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/folders", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+interopSession[:len(interopSession)-4]+"AAAA")
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleFolders(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("a token with a broken signature was accepted: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The cookie is the browser's normal path and must work like the header.
|
||||
func TestMailApiAcceptsTheSessionCookie(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/mail/folders", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "lm_user_session", Value: interopSession})
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleFolders(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("the session cookie was not accepted: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- isolation between accounts -----------------------------------------
|
||||
|
||||
// The mailbox is resolved from the session's address, never from the request.
|
||||
// This is the check that keeps one account out of another's mail.
|
||||
func TestMailApiServesOnlyTheSessionsOwnMailbox(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
api := newMailAPI(t, store, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleMessages(rec, request(http.MethodGet, "/api/v1/mail/messages", ""))
|
||||
|
||||
var got []port.WebmailMessage
|
||||
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
for _, m := range got {
|
||||
if m.Subject == "somebody else's" {
|
||||
t.Fatal("the API served another mailbox's mail")
|
||||
}
|
||||
}
|
||||
if len(got) != 1 || got[0].Subject != "mine" {
|
||||
t.Errorf("got %+v, want only this session's own message", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming another mailbox's UID must read as not-found, not as that message.
|
||||
func TestMailApiCannotReadAnotherMailboxesMessageByUid(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
api := newMailAPI(t, store, []byte("Subject: secret\r\n\r\nbody"))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
// UID 99 exists, but in mailbox-2.
|
||||
api.handleMessage(rec, request(http.MethodGet, "/api/v1/mail/message/99", ""))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("reading another mailbox's UID answered %d, want 404", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "secret") {
|
||||
t.Error("another mailbox's message body was served")
|
||||
}
|
||||
}
|
||||
|
||||
// --- reading -------------------------------------------------------------
|
||||
|
||||
// Opening a message marks it read; that is what moves the unread counter.
|
||||
func TestOpeningAMessageMarksItRead(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
api := newMailAPI(t, store, []byte("Subject: hi\r\n\r\nbody"))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleMessage(rec, request(http.MethodGet, "/api/v1/mail/message/1?folder=inbox", ""))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("reading own message answered %d", rec.Code)
|
||||
}
|
||||
if len(store.seenCalls) != 1 || store.seenCalls[0] != "mailbox-1/inbox" {
|
||||
t.Errorf("marked seen on %v, want exactly [mailbox-1/inbox]", store.seenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The folder defaults rather than 400-ing, because the list view omits it.
|
||||
func TestMessageListDefaultsToTheInbox(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
api := newMailAPI(t, store, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleSeen(rec, request(http.MethodPost, "/api/v1/mail/seen", `{"uid":1,"seen":true}`))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("answered %d", rec.Code)
|
||||
}
|
||||
if len(store.seenCalls) != 1 || store.seenCalls[0] != "mailbox-1/inbox" {
|
||||
t.Errorf("the folder did not default to inbox: %v", store.seenCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// --- deleting ------------------------------------------------------------
|
||||
|
||||
func TestDeleteMovesToTrashFromAnOrdinaryFolder(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
api := newMailAPI(t, store, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleDelete(rec, request(http.MethodPost, "/api/v1/mail/delete", `{"folder":"inbox","uid":1}`))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete answered %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
// The mailbox is asserted, not just the call: counting calls alone would
|
||||
// pass just as happily if the delete acted on somebody else's mailbox.
|
||||
if len(store.movedCalls) != 1 || store.movedCalls[0] != "mailbox-1/inbox" {
|
||||
t.Errorf("moved %v, want exactly [mailbox-1/inbox]", store.movedCalls)
|
||||
}
|
||||
if len(store.deletedCalls) != 0 {
|
||||
t.Errorf("deleting from the inbox destroyed the message: %v", store.deletedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFromTrashExpungesForGood(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
store.moveErr = port.ErrAlreadyInTrash
|
||||
api := newMailAPI(t, store, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleDelete(rec, request(http.MethodPost, "/api/v1/mail/delete", `{"folder":"trash","uid":1}`))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete from trash answered %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if len(store.deletedCalls) != 1 || store.deletedCalls[0] != "mailbox-1/trash" {
|
||||
t.Errorf("expunged %v, want exactly [mailbox-1/trash]", store.deletedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// A delete with no UID is a bug in the caller, not a request to delete UID 0.
|
||||
func TestDeleteRejectsAMissingUid(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleDelete(rec, request(http.MethodPost, "/api/v1/mail/delete", `{"folder":"inbox"}`))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("a delete with no UID answered %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRejectsMalformedJson(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleDelete(rec, request(http.MethodPost, "/api/v1/mail/delete", `{not json`))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("malformed JSON answered %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- error mapping -------------------------------------------------------
|
||||
|
||||
// Each domain error has to reach the browser as something the composer can act
|
||||
// on. A send limit that arrives as a 500 tells the user to try again, which is
|
||||
// the one thing that will not work.
|
||||
func TestDomainErrorsMapToUsefulStatusCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want int
|
||||
}{
|
||||
{postgres.ErrMessageNotFound, http.StatusNotFound},
|
||||
{usecase.ErrNoSuchMailbox, http.StatusNotFound},
|
||||
{usecase.ErrNoRecipients, http.StatusBadRequest},
|
||||
{usecase.ErrSendLimitExceeded, http.StatusTooManyRequests},
|
||||
{usecase.ErrSenderNotOwned, http.StatusForbidden},
|
||||
{usecase.ErrDraftsUnavailable, http.StatusServiceUnavailable},
|
||||
{errors.New("something unexpected"), http.StatusInternalServerError},
|
||||
}
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
for _, tc := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
api.fail(rec, tc.err)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("%v mapped to %d, want %d", tc.err, rec.Code, tc.want)
|
||||
}
|
||||
// The internals must not travel to the browser with it.
|
||||
if strings.Contains(rec.Body.String(), "something unexpected") {
|
||||
t.Errorf("an internal error message leaked to the client: %s", rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// adminSurfaceToken mints a token for the admin surface under the same secret,
|
||||
// to prove the audience check is what refuses it rather than the signature.
|
||||
func adminSurfaceToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
return mintSession(t, WebSession{
|
||||
Subject: "mb-1", Email: sessionOwner, Role: "SUPER_ADMIN",
|
||||
Surface: "admin", Audience: "lambdamail:admin",
|
||||
Purpose: "session", MfaSatisfied: true,
|
||||
IssuedAt: interopIssuedAt,
|
||||
ExpiresAt: interopIssuedAt + 3600,
|
||||
})
|
||||
}
|
||||
|
||||
var _ = time.Now
|
||||
|
||||
// --- composing -----------------------------------------------------------
|
||||
|
||||
// The sender is taken from the session and never from the request body.
|
||||
// Letting the body choose From is how a webmail becomes an open relay for
|
||||
// spoofed mail, signed with this domain's own DKIM key.
|
||||
func TestComposeAlwaysSendsAsTheSessionsOwnAddress(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := request(http.MethodPost, "/api/v1/mail/send", `{
|
||||
"from":"ceo@bank.example",
|
||||
"to":["victim@example.test"],
|
||||
"subject":"Wire transfer",
|
||||
"html":"<p>urgent</p>"
|
||||
}`)
|
||||
|
||||
input, ok := api.decodeCompose(rec, req, sessionOwner)
|
||||
if !ok {
|
||||
t.Fatalf("decode refused a valid body: %s", rec.Body)
|
||||
}
|
||||
if input.From != sessionOwner {
|
||||
t.Errorf("From is %q; the request body chose the sender", input.From)
|
||||
}
|
||||
}
|
||||
|
||||
// Bcc has to survive decoding, or a blind copy is silently dropped and nobody
|
||||
// is told the message did not reach them.
|
||||
func TestComposeKeepsBlindCopies(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
input, ok := api.decodeCompose(rec, request(http.MethodPost, "/api/v1/mail/send", `{
|
||||
"to":["a@example.test"],"cc":["b@example.test"],"bcc":["c@example.test"],"text":"hi"
|
||||
}`), sessionOwner)
|
||||
if !ok {
|
||||
t.Fatalf("decode refused: %s", rec.Body)
|
||||
}
|
||||
if len(input.Bcc) != 1 || input.Bcc[0] != "c@example.test" {
|
||||
t.Errorf("Bcc arrived as %v", input.Bcc)
|
||||
}
|
||||
if len(input.Cc) != 1 {
|
||||
t.Errorf("Cc arrived as %v", input.Cc)
|
||||
}
|
||||
}
|
||||
|
||||
// The composer sends "html"; the field was once read as "body", and every
|
||||
// message went out blank. The legacy name still has to work for anything
|
||||
// written against the old shape.
|
||||
func TestComposeAcceptsBothTheCurrentAndLegacyBodyFields(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
current, ok := api.decodeCompose(rec, request(http.MethodPost, "/x",
|
||||
`{"to":["a@example.test"],"html":"<p>real</p>"}`), sessionOwner)
|
||||
if !ok || current.HTML != "<p>real</p>" {
|
||||
t.Errorf("the html field did not survive: %+v", current)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
legacy, ok := api.decodeCompose(rec, request(http.MethodPost, "/x",
|
||||
`{"to":["a@example.test"],"body":"plain words"}`), sessionOwner)
|
||||
if !ok || legacy.Text != "plain words" {
|
||||
t.Errorf("the legacy body field was dropped: %+v", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
// The draft a sent message came from has to reach the use case, or the draft
|
||||
// outlives the message it became.
|
||||
func TestComposeCarriesTheDraftItCameFrom(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
input, ok := api.decodeCompose(rec, request(http.MethodPost, "/api/v1/mail/send",
|
||||
`{"to":["a@example.test"],"text":"hi","draft_uid":77}`), sessionOwner)
|
||||
if !ok {
|
||||
t.Fatalf("decode refused: %s", rec.Body)
|
||||
}
|
||||
if input.DraftUID != 77 {
|
||||
t.Errorf("DraftUID is %d, want 77", input.DraftUID)
|
||||
}
|
||||
}
|
||||
|
||||
// The draft decode reads the body once and has to get both halves out of it.
|
||||
func TestDraftDecodeReadsBothTheMessageAndTheUidItReplaces(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
input, replaceUID, ok := api.decodeDraft(rec, request(http.MethodPost, "/api/v1/mail/draft",
|
||||
`{"to":["a@example.test"],"subject":"half","html":"<p>writ</p>","replace_uid":12}`), sessionOwner)
|
||||
if !ok {
|
||||
t.Fatalf("decode refused: %s", rec.Body)
|
||||
}
|
||||
if replaceUID != 12 {
|
||||
t.Errorf("replace_uid is %d, want 12", replaceUID)
|
||||
}
|
||||
if input.Subject != "half" || input.HTML != "<p>writ</p>" {
|
||||
t.Errorf("the draft body was lost: %+v", input)
|
||||
}
|
||||
if input.From != sessionOwner {
|
||||
t.Errorf("a draft's From is %q, want the session's address", input.From)
|
||||
}
|
||||
}
|
||||
|
||||
// A draft with no replace_uid is the first save of a message and must still
|
||||
// decode - it simply supersedes nothing.
|
||||
func TestDraftDecodeToleratesAMissingReplaceUid(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
_, replaceUID, ok := api.decodeDraft(rec, request(http.MethodPost, "/api/v1/mail/draft",
|
||||
`{"subject":"first save"}`), sessionOwner)
|
||||
if !ok {
|
||||
t.Fatalf("the first save of a draft was refused: %s", rec.Body)
|
||||
}
|
||||
if replaceUID != 0 {
|
||||
t.Errorf("replace_uid is %d, want 0", replaceUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeRejectsMalformedJson(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if _, ok := api.decodeCompose(rec, request(http.MethodPost, "/x", `{"to":`), sessionOwner); ok {
|
||||
t.Error("malformed JSON was accepted")
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("answered %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package httppresentation
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -91,3 +95,44 @@ func TestWebSessionVerifier_RejectsExpiredToken(t *testing.T) {
|
||||
t.Fatalf("error = %v, want ErrTokenExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mintSession signs a session token the way the auth service does, so a test
|
||||
// can build a claim set the captured tokens do not cover - another surface, an
|
||||
// expired window, a different address.
|
||||
//
|
||||
// The captured tokens above stay the interop check: this only produces the
|
||||
// variations, and produces them through the same algorithm.
|
||||
func mintSession(t *testing.T, claims WebSession) string {
|
||||
t.Helper()
|
||||
header := base64.RawURLEncoding.EncodeToString(
|
||||
[]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||
body, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal claims: %v", err)
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(body)
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(interopSecret))
|
||||
mac.Write([]byte(header + "." + payload))
|
||||
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return header + "." + payload + "." + signature
|
||||
}
|
||||
|
||||
// The minted tokens have to verify, or every test built on them is asserting
|
||||
// against a signature this server would reject for the wrong reason.
|
||||
func TestMintedSessionVerifiesLikeARealOne(t *testing.T) {
|
||||
token := mintSession(t, WebSession{
|
||||
Subject: "mb-1", Email: "user@example.test", Surface: "user",
|
||||
Audience: "lambdamail:user", Purpose: "session", MfaSatisfied: true,
|
||||
IssuedAt: interopIssuedAt, ExpiresAt: interopIssuedAt + 3600,
|
||||
})
|
||||
|
||||
session, err := testVerifier(interopSecret).RequireSurface(token, "user")
|
||||
if err != nil {
|
||||
t.Fatalf("a locally minted session was rejected: %v", err)
|
||||
}
|
||||
if session.Email != "user@example.test" {
|
||||
t.Errorf("Email = %q", session.Email)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user