360 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|