Files
LambdaMail/services/protocols/internal/e2e/webmail_api_e2e_test.go
T

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(15446).
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:15446/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(15447).
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:15447/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)
}
})
}
}