Files
LambdaMail/services/protocols/internal/e2e/webmail_api_e2e_test.go
T
Lucas RayanandClaude Opus 5 b57854107e #21: finish the three translations across every screen
F4's acceptance criterion asks for the three translations to be 100%
complete with the language lint green. The bundles had parity but the
admin screens and the account area were still hardcoded English: 63
visible strings that no locale could reach.

They now come from a shared ui namespace, so the three bundles cannot
drift apart, and all three carry 126 keys.

The language lint also caught this branch's own Go tests: the RFC 2047
cases need accented output to be worth anything, and the delivery test
had Portuguese fixture text. The expectations are written as Go escapes
instead, which keeps the assertions and leaves the source ASCII, and the
fixture text is English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:27:14 -03:00

360 lines
12 KiB
Go

package e2e
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/google/uuid"
appusecase "lambdamail/protocols/internal/application/usecase"
"lambdamail/protocols/internal/infrastructure/diskstorage"
"lambdamail/protocols/internal/infrastructure/postgres"
httppresentation "lambdamail/protocols/internal/presentation/http"
)
const webmailSecret = "webmail-e2e-shared-secret"
// mintSession builds the token the auth service would issue, using the same
// HS256 construction, so this test exercises the real verification path.
func mintSession(t *testing.T, email, surface string, ttl time.Duration) string {
t.Helper()
encode := func(v any) string {
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
header := encode(map[string]string{"alg": "HS256", "typ": "JWT"})
payload := encode(map[string]any{
"sub": uuid.NewString(), "email": email, "role": "USER", "domainId": uuid.NewString(),
"surface": surface, "aud": "lambdamail:" + surface, "mfaSatisfied": true,
"purpose": "session", "iat": time.Now().Unix(), "exp": time.Now().Add(ttl).Unix(),
})
mac := hmac.New(sha256.New, []byte(webmailSecret))
mac.Write([]byte(header + "." + payload))
return header + "." + payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func TestWebmailApiEndToEnd(t *testing.T) {
if testing.Short() {
t.Skip("e2e test skipped in -short mode")
}
ctx := context.Background()
runtimeDir := t.TempDir()
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(54346).
RuntimePath(runtimeDir).
StartTimeout(120 * time.Second))
if err := pg.Start(); err != nil {
t.Fatalf("start embedded postgres: %v", err)
}
defer pg.Stop()
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:54346/postgres?sslmode=disable")
if err != nil {
t.Fatalf("connect pool: %v", err)
}
defer pool.Close()
applyMigrations(t, ctx, pool)
// ------------------------------------------------------------ fixtures
domainID, mailboxID, folderID := uuid.New(), uuid.New(), uuid.New()
const address = "reader@webmail.example"
if _, err := pool.Exec(ctx,
`INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'webmail.example', 'webmail.example')`, domainID); err != nil {
t.Fatalf("seed domain: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash)
VALUES ($1, $2, 'reader', $3, 'x')`, mailboxID, domainID, address); err != nil {
t.Fatalf("seed mailbox: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, 'INBOX', 'inbox')`,
folderID, mailboxID); err != nil {
t.Fatalf("seed folder: %v", err)
}
// ------------------------------------------------- deliver a real message
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
inbound := appusecase.NewProcessInboundEmailUseCase(
postgres.NewMailboxRepository(pool), blobs, postgres.NewInboundMessageRepository(pool))
raw := "From: Alice Sender <alice@remote.test>\r\n" +
"To: " + address + "\r\n" +
"Subject: =?UTF-8?Q?Relat=C3=B3rio?=\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Body of the test message.\r\n"
targets, err := inbound.ResolveRecipient(ctx, address)
if err != nil {
t.Fatalf("resolve recipient: %v", err)
}
if err := inbound.Handle(ctx, appusecase.ProcessInboundEmailInput{
Sender: "alice@remote.test",
Recipients: targets,
RecipientAddresses: []string{address},
Body: strings.NewReader(raw),
}); err != nil {
t.Fatalf("deliver: %v", err)
}
// ------------------------------------------------------------ the API
webmailUC := appusecase.NewWebmailUseCase(
postgres.NewWebmailRepository(pool),
diskstorage.NewLocalDiskBlobReader(pool),
nil, nil, "mail.webmail.example",
)
router := httppresentation.NewRouter(nil, func() error { return nil })
router.SetMailAPI(webmailUC, webmailSecret)
server := httptest.NewServer(router)
defer server.Close()
token := mintSession(t, address, "user", time.Hour)
call := func(method, path string, body any) (*http.Response, []byte) {
var reader *bytes.Reader
if body != nil {
encoded, _ := json.Marshal(body)
reader = bytes.NewReader(encoded)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, server.URL+path, reader)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
return resp, buf.Bytes()
}
// 1. Folders come back with the seeded INBOX and its unread counter.
resp, body := call(http.MethodGet, "/api/v1/mail/folders", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("folders: status %d body %s", resp.StatusCode, body)
}
var folders []struct {
Name string `json:"name"`
SpecialUse string `json:"special_use"`
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(body, &folders); err != nil {
t.Fatalf("decode folders: %v (%s)", err, body)
}
if len(folders) != 1 || folders[0].SpecialUse != "inbox" || folders[0].UnreadCount != 1 {
t.Fatalf("folders = %+v, want one inbox with 1 unread", folders)
}
// 2. The listing carries the header metadata, decoded.
resp, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("messages: status %d body %s", resp.StatusCode, body)
}
var messages []struct {
UID uint32 `json:"uid"`
Subject string `json:"subject"`
FromName string `json:"from_display_name"`
Snippet string `json:"snippet"`
Seen bool `json:"seen"`
}
if err := json.Unmarshal(body, &messages); err != nil {
t.Fatalf("decode messages: %v (%s)", err, body)
}
if len(messages) != 1 {
t.Fatalf("got %d messages, want 1", len(messages))
}
if messages[0].Subject != "Relat\u00f3rio" {
t.Errorf("Subject = %q, want the decoded encoded-word", messages[0].Subject)
}
if messages[0].FromName != "Alice Sender" {
t.Errorf("FromName = %q", messages[0].FromName)
}
if messages[0].Snippet == "" {
t.Error("Snippet is empty; the list would show no preview")
}
if messages[0].Seen {
t.Error("a freshly delivered message is already marked seen")
}
// 3. Fetching the body returns the original bytes and marks it read.
resp, body = call(http.MethodGet, fmt.Sprintf("/api/v1/mail/message/%d?folder=inbox", messages[0].UID), nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("message: status %d body %s", resp.StatusCode, body)
}
var message struct {
Subject string `json:"subject"`
From string `json:"from"`
Text string `json:"text"`
}
if err := json.Unmarshal(body, &message); err != nil {
t.Fatalf("decode message: %v", err)
}
if !strings.Contains(message.Text, "Body of the test message.") {
t.Errorf("the rendered body is not the stored message: %q", message.Text)
}
if message.Subject != "Relat\u00f3rio" {
t.Errorf("rendered Subject = %q, want the decoded encoded-word", message.Subject)
}
if !strings.Contains(message.From, "alice@remote.test") {
t.Errorf("rendered From = %q", message.From)
}
_, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox", nil)
_ = json.Unmarshal(body, &messages)
if !messages[0].Seen {
t.Error("opening the message did not mark it seen")
}
var unread int
if err := pool.QueryRow(ctx, `SELECT unread_count FROM folders WHERE id = $1`, folderID).Scan(&unread); err != nil {
t.Fatal(err)
}
if unread != 0 {
t.Errorf("unread_count = %d after reading the only message, want 0", unread)
}
// 4. Search matches on the indexed columns.
_, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox&q=Relat", nil)
_ = json.Unmarshal(body, &messages)
if len(messages) != 1 {
t.Errorf("search returned %d messages, want 1", len(messages))
}
_, body = call(http.MethodGet, "/api/v1/mail/messages?folder=inbox&q=nothing-matches-this", nil)
_ = json.Unmarshal(body, &messages)
if len(messages) != 0 {
t.Errorf("search returned %d messages for a non-matching term", len(messages))
}
}
// One account must not be able to read another's mail by guessing a UID.
func TestWebmailApiRejectsCrossMailboxAccess(t *testing.T) {
if testing.Short() {
t.Skip("e2e test skipped in -short mode")
}
ctx := context.Background()
runtimeDir := t.TempDir()
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
Port(54347).
RuntimePath(runtimeDir).
StartTimeout(120 * time.Second))
if err := pg.Start(); err != nil {
t.Fatalf("start embedded postgres: %v", err)
}
defer pg.Stop()
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:54347/postgres?sslmode=disable")
if err != nil {
t.Fatalf("connect pool: %v", err)
}
defer pool.Close()
applyMigrations(t, ctx, pool)
domainID := uuid.New()
if _, err := pool.Exec(ctx,
`INSERT INTO domains (id, name, punycode_name) VALUES ($1, 'iso.example', 'iso.example')`, domainID); err != nil {
t.Fatal(err)
}
victim, attacker := uuid.New(), uuid.New()
for id, local := range map[uuid.UUID]string{victim: "victim", attacker: "attacker"} {
if _, err := pool.Exec(ctx,
`INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash)
VALUES ($1, $2, $3, $4, 'x')`, id, domainID, local, local+"@iso.example"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO folders (mailbox_id, name, special_use) VALUES ($1, 'INBOX', 'inbox')`, id); err != nil {
t.Fatal(err)
}
}
blobs := diskstorage.NewLocalDiskBlobStorage(pool, t.TempDir())
inbound := appusecase.NewProcessInboundEmailUseCase(
postgres.NewMailboxRepository(pool), blobs, postgres.NewInboundMessageRepository(pool))
targets, err := inbound.ResolveRecipient(ctx, "victim@iso.example")
if err != nil {
t.Fatal(err)
}
if err := inbound.Handle(ctx, appusecase.ProcessInboundEmailInput{
Sender: "someone@remote.test",
Recipients: targets,
RecipientAddresses: []string{"victim@iso.example"},
Body: strings.NewReader("From: s@remote.test\r\nSubject: private\r\n\r\nsecret\r\n"),
}); err != nil {
t.Fatal(err)
}
webmailUC := appusecase.NewWebmailUseCase(
postgres.NewWebmailRepository(pool), diskstorage.NewLocalDiskBlobReader(pool), nil, nil, "mail.iso.example")
router := httppresentation.NewRouter(nil, func() error { return nil })
router.SetMailAPI(webmailUC, webmailSecret)
server := httptest.NewServer(router)
defer server.Close()
// The victim's only message has UID 1; the attacker asks for exactly that.
req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/mail/message/1?folder=inbox", nil)
req.Header.Set("Authorization", "Bearer "+mintSession(t, "attacker@iso.example", "user", time.Hour))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404: one mailbox read another's message", resp.StatusCode)
}
}
// An admin token must not open the user mail API, and neither must no token.
func TestWebmailApiRequiresUserSession(t *testing.T) {
router := httppresentation.NewRouter(nil, func() error { return nil })
router.SetMailAPI(appusecase.NewWebmailUseCase(nil, nil, nil, nil, "mail.test"), webmailSecret)
server := httptest.NewServer(router)
defer server.Close()
for _, tc := range []struct{ name, token string }{
{"no token", ""},
{"admin token", mintSession(t, "admin@iso.example", "admin", time.Hour)},
{"forged token", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.AAAA"},
} {
t.Run(tc.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/mail/folders", nil)
if tc.token != "" {
req.Header.Set("Authorization", "Bearer "+tc.token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
})
}
}