fa3c4f2657
Raw-MIME passthrough (STARTTLS, AUTH LOGIN) — separate from the notify HTTP-API alert path (which drops inline CID images). Per-customer token-bucket rate limit, From-header allowlist backstop. Resend key stays hub-side. No new external dep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
6.7 KiB
Go
213 lines
6.7 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// fakeSender records every Send so tests can assert call count + byte fidelity.
|
|
type fakeSender struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
lastRaw []byte
|
|
from string
|
|
to []string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeSender) Send(_ context.Context, raw []byte, mailFrom string, rcptTo []string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls++
|
|
f.lastRaw = append([]byte(nil), raw...)
|
|
f.from = mailFrom
|
|
f.to = append([]string(nil), rcptTo...)
|
|
return f.err
|
|
}
|
|
|
|
func (f *fakeSender) callCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.calls
|
|
}
|
|
|
|
func rawFrom(from string) []byte {
|
|
return []byte("From: " + from + "\r\nTo: recipient@example.com\r\nSubject: test\r\n\r\nbody\r\n")
|
|
}
|
|
|
|
func mailBody(t *testing.T, fromAddr string) string {
|
|
t.Helper()
|
|
b, err := json.Marshal(map[string]interface{}{
|
|
"raw_mime": rawFrom(fromAddr),
|
|
"mail_from": fromAddr,
|
|
"rcpt_to": []string{"recipient@example.com"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func withCustomer(t *testing.T, st *store.Store, id, key string) {
|
|
t.Helper()
|
|
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: id, APIKey: key, RetrievalPassword: "p"}); err != nil {
|
|
t.Fatalf("SaveCustomerConfig: %v", err)
|
|
}
|
|
}
|
|
|
|
// §7 A / §10: happy path — the hub passes the RAW bytes through unchanged to the sender,
|
|
// NOT a parsed/structured payload.
|
|
func TestMail_HappyPath_PassthroughRawBytes(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
fake := &fakeSender{}
|
|
h.SetMailRelay(fake, 30, []string{"felhom.eu"})
|
|
|
|
rr := do(h, "POST", "/mail", "ckey", mailBody(t, "vaultwarden@felhom.eu"))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("expected 200, got %d (%s)", rr.Code, rr.Body.String())
|
|
}
|
|
if fake.callCount() != 1 {
|
|
t.Fatalf("expected 1 send, got %d", fake.callCount())
|
|
}
|
|
// Byte-equality: the sender must receive exactly what the box sent — no parse/re-encode
|
|
// (the parse-then-API path drops inline CID images; §10).
|
|
if !bytes.Equal(fake.lastRaw, rawFrom("vaultwarden@felhom.eu")) {
|
|
t.Fatalf("sender got mutated bytes:\n got=%q", fake.lastRaw)
|
|
}
|
|
}
|
|
|
|
func TestMail_Unauthorized(t *testing.T) {
|
|
h, _, _ := newTestHandler(t)
|
|
h.SetMailRelay(&fakeSender{}, 30, []string{"felhom.eu"})
|
|
if rr := do(h, "POST", "/mail", "wrongkey", mailBody(t, "x@felhom.eu")); rr.Code != 401 {
|
|
t.Fatalf("expected 401, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestMail_NotConfigured_503(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
// No SetMailRelay → sender nil.
|
|
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "x@felhom.eu")); rr.Code != 503 {
|
|
t.Fatalf("expected 503, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// §7 B: From outside the allowlist is refused and the sender is NEVER called.
|
|
func TestMail_FromOutsideAllowlist_Rejected_NoSend(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
fake := &fakeSender{}
|
|
h.SetMailRelay(fake, 30, []string{"felhom.eu"})
|
|
|
|
rr := do(h, "POST", "/mail", "ckey", mailBody(t, "evil@notfelhom.example"))
|
|
if rr.Code != 403 {
|
|
t.Fatalf("expected 403, got %d", rr.Code)
|
|
}
|
|
if fake.callCount() != 0 {
|
|
t.Fatalf("sender must not be called on From-reject, got %d", fake.callCount())
|
|
}
|
|
}
|
|
|
|
// Companion red-proof for §7 B: allow the bad domain → the same message reaches the sender.
|
|
func TestMail_FromReject_CompanionProof(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
fake := &fakeSender{}
|
|
h.SetMailRelay(fake, 30, []string{"notfelhom.example"}) // gate removed
|
|
|
|
rr := do(h, "POST", "/mail", "ckey", mailBody(t, "evil@notfelhom.example"))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("with the domain allowed, expected 200, got %d", rr.Code)
|
|
}
|
|
if fake.callCount() != 1 {
|
|
t.Fatalf("companion: wrong-From should reach the sender once, got %d", fake.callCount())
|
|
}
|
|
}
|
|
|
|
// §7 C: per-box rate limit — over the limit returns 429; a different customer is unaffected.
|
|
func TestMail_RateLimit_PerCustomer(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
withCustomer(t, st, "c2", "ckey2")
|
|
h.SetMailRelay(&fakeSender{}, 1, []string{"felhom.eu"}) // 1/min
|
|
|
|
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "a@felhom.eu")); rr.Code != 200 {
|
|
t.Fatalf("first send should pass, got %d", rr.Code)
|
|
}
|
|
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "a@felhom.eu")); rr.Code != 429 {
|
|
t.Fatalf("second send (over limit) should be 429, got %d", rr.Code)
|
|
}
|
|
// Different customer's quota is independent — one box can't starve the fleet.
|
|
if rr := do(h, "POST", "/mail", "ckey2", mailBody(t, "b@felhom.eu")); rr.Code != 200 {
|
|
t.Fatalf("other customer should be unaffected, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// Companion red-proof for §7 C: a generous limit lets the N+1th through (proves the limiter bites).
|
|
func TestMail_RateLimit_CompanionProof(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
h.SetMailRelay(&fakeSender{}, 1000, []string{"felhom.eu"})
|
|
for i := 0; i < 5; i++ {
|
|
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "a@felhom.eu")); rr.Code != 200 {
|
|
t.Fatalf("call %d should pass under a generous limit, got %d", i, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// §7 A failure surface: a Resend send failure becomes 502 (the box's shim maps that to a
|
|
// permanent SMTP error to the app).
|
|
func TestMail_SendFailure_502(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
h.SetMailRelay(&fakeSender{err: fmt.Errorf("resend refused")}, 30, []string{"felhom.eu"})
|
|
if rr := do(h, "POST", "/mail", "ckey", mailBody(t, "vaultwarden@felhom.eu")); rr.Code != 502 {
|
|
t.Fatalf("expected 502 on send failure, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestMail_BadBody_400(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
withCustomer(t, st, "c1", "ckey")
|
|
h.SetMailRelay(&fakeSender{}, 30, []string{"felhom.eu"})
|
|
if rr := do(h, "POST", "/mail", "ckey", "{not json"); rr.Code != 400 {
|
|
t.Fatalf("expected 400 for bad body, got %d", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- rate limiter unit (deterministic clock) ---
|
|
|
|
func TestMailRateLimiter_ThresholdRefillIsolation(t *testing.T) {
|
|
rl := newMailRateLimiter(2)
|
|
now := time.Unix(1000, 0)
|
|
rl.now = func() time.Time { return now }
|
|
|
|
if !rl.allow("a") || !rl.allow("a") {
|
|
t.Fatal("first two for key a should pass (burst=2)")
|
|
}
|
|
if rl.allow("a") {
|
|
t.Fatal("third for key a should be denied")
|
|
}
|
|
// Different key is independent.
|
|
if !rl.allow("b") {
|
|
t.Fatal("key b should be unaffected")
|
|
}
|
|
// Advance 30s → 2/min refills 1 token.
|
|
now = now.Add(30 * time.Second)
|
|
if !rl.allow("a") {
|
|
t.Fatal("after 30s, key a should have refilled one token")
|
|
}
|
|
if rl.allow("a") {
|
|
t.Fatal("only one token should have refilled")
|
|
}
|
|
}
|