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:
Lucas Rayan committed 2026-08-21 09:55:37 -03:00
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)
}
}