Cc/Cco. Os dois rótulos do compositor eram o último texto em inglês que restava, e "Bcc" tem nome próprio em português - quem não conhece a abreviatura inglesa não adivinha o que o campo faz. Larguras. A barra lateral tinha 244px fixos e nela não cabia "Console de administração", que transbordava no telemóvel e no computador: o botão passa a mostrar um rótulo curto e guarda o nome completo no tooltip. O endereço da conta usava break-words e partia-se a meio do domínio, deixando um "br" sozinho na linha de baixo; passa a truncar com o valor inteiro no tooltip. A lista tinha 404px fixos e o leitor 760px centrados, portanto num ecrã largo os assuntos eram espremidos numa coluna estreita enquanto o leitor ficava entre duas margens enormes. Ambos crescem agora com a janela. O assunto ganha overflow-wrap: os Message-ID dos relatórios do Google são uma única "palavra" sem espaços, mais longa que a coluna, e sem sítio onde quebrar saíam da linha. Imagens remotas. A escolha era reposta sempre que uma mensagem era aberta, por isso a mesma newsletter tinha de ser desbloqueada em cada leitura. A decisão passa a ser lembrada por remetente, com um segundo botão para a retirar. Bloqueado continua a ser o que acontece por omissão: uma imagem remota é um aviso de leitura que o remetente não pediu licença para receber. Assinatura. Era escrita num textarea - texto simples com quebras de linha reais - e injetada no compositor com innerHTML, onde uma quebra de linha é espaço em branco: todas desapareciam. Passa a ser um campo rico, com negrito, itálico, links e imagens, guardado como HTML e sanitizado nos dois sentidos, já que este valor é injetado como marcação na origem do webmail. Assinaturas guardadas pelo campo antigo continuam a funcionar: as suas quebras de linha são convertidas. Mover entre pastas. Faltava a única ação corrente que o leitor não tinha: arquivar uma mensagem para fora da caixa de entrada era impossível. Enviados e Rascunhos nunca são destino - descrevem como a mensagem veio a existir, não onde o leitor a arrumou - mas sair deles é permitido. Pastas criadas pelo utilizador, que não têm papel special-use, são destino como qualquer outra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
550 lines
18 KiB
Go
550 lines
18 KiB
Go
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) MoveToFolder(
|
|
_ context.Context, mailboxID, folder string, uid uint32, target string,
|
|
) (uint32, error) {
|
|
if f.moveErr != nil {
|
|
return 0, f.moveErr
|
|
}
|
|
f.movedCalls = append(f.movedCalls, mailboxID+"/"+folder+"->"+target)
|
|
return uid, 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)
|
|
}
|
|
}
|