package api
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/ops"
)
type fakeReplyStore struct {
secret string
seen map[string]string
}
func (f *fakeReplyStore) NotifyHookSecret() (string, error) { return f.secret, nil }
func (f *fakeReplyStore) InboundReplySeen(id string) (string, bool, error) {
o, ok := f.seen[id]
return o, ok, nil
}
func (f *fakeReplyStore) RecordInboundReply(id, _, _, outcome string) error {
if f.seen == nil {
f.seen = map[string]string{}
}
f.seen[id] = outcome
return nil
}
type capsOf string
func (c capsOf) HasAny(letters string) bool { return strings.ContainsAny(string(c), letters) }
type posted struct {
user string
in forumReplyIn
}
func newReplyRig(t *testing.T) (*ReplyReceiver, *fakeReplyStore, *[]posted) {
t.Helper()
var log []posted
store := &fakeReplyStore{secret: "s3cret"}
h := &ReplyReceiver{
Store: store,
CapsFor: func(account, repo string) CapSet {
if account == "lou" {
return capsOf("3")
}
return capsOf("")
},
Find: func(hash string) (string, string, string, bool) {
if hash == "abc123" {
return "quarry", "abc123full", "root999", true
}
return "", "", "", false
},
Post: func(ctx context.Context, in forumReplyIn) (string, error) {
log = append(log, posted{ops.User(ctx), in})
return "newhash", nil
},
Now: func() time.Time { return time.Unix(1_800_000_000, 0) },
}
return h, store, &log
}
func deliver(h http.Handler, secret string, ts int64, ev any) *httptest.ResponseRecorder {
body, _ := json.Marshal(ev)
stamp := strconv.FormatInt(ts, 10)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stamp + "."))
mac.Write(body)
req := httptest.NewRequest("POST", "/api/v1/notify/events", strings.NewReader(string(body)))
req.Header.Set(replyStampHdr, stamp)
req.Header.Set(replySigHdr, "sha256="+hex.EncodeToString(mac.Sum(nil)))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
return w
}
func replyEvent(txn, who, text string) map[string]any {
return map[string]any{"type": "email.reply", "subscriberId": who, "transactionId": txn,
"workflow": "quarry-forum-reply", "text": text, "messageId": "<[email protected]>"}
}
// A signed reply to a forum notification lands as a reply on that
// thread, written as the account the notification was addressed to.
func TestInboundReplyLandsOnTheThread(t *testing.T) {
h, store, log := newReplyRig(t)
w := deliver(h, "s3cret", 1_800_000_000, replyEvent("forum-reply:abc123:lou", "lou", " Sounds right to me.\n"))
if w.Code != http.StatusCreated {
t.Fatalf("FAIL: status %d: %s", w.Code, w.Body)
}
if len(*log) != 1 {
t.Fatalf("FAIL: %d posts, want 1", len(*log))
}
p := (*log)[0]
if p.user != "lou" || p.in.Repo != "quarry" || p.in.Root != "root999" || p.in.To != "abc123full" || p.in.Body != "Sounds right to me." {
t.Errorf("FAIL: posted %+v", p)
}
if store.seen["<[email protected]>"] != "forum:newhash" {
t.Errorf("FAIL: outcome not recorded: %+v", store.seen)
}
// A mention about the same post maps the same way.
ev := replyEvent("mention:forum:abc123:lou", "lou", "yes")
ev["messageId"] = "<[email protected]>"
if w := deliver(h, "s3cret", 1_800_000_000, ev); w.Code != http.StatusCreated {
t.Errorf("FAIL: mention reply: %d %s", w.Code, w.Body)
}
t.Log("ok: the reply is a forum reply by the addressed account")
}
// Unsigned, mis-signed or stale deliveries are refused before they are
// read: the route is public and the signature is the only proof it
// came from notify.
func TestInboundReplyRefusesBadSignature(t *testing.T) {
h, _, log := newReplyRig(t)
ev := replyEvent("forum-reply:abc123:lou", "lou", "hi")
if w := deliver(h, "wrong", 1_800_000_000, ev); w.Code != http.StatusUnauthorized {
t.Errorf("FAIL: wrong secret answered %d", w.Code)
}
if w := deliver(h, "s3cret", 1_800_000_000-3600, ev); w.Code != http.StatusUnauthorized {
t.Errorf("FAIL: an hour-old signature answered %d", w.Code)
}
body, _ := json.Marshal(ev)
req := httptest.NewRequest("POST", "/api/v1/notify/events", strings.NewReader(string(body)))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("FAIL: unsigned answered %d", w.Code)
}
if len(*log) != 0 {
t.Error("FAIL: something was posted")
}
t.Log("ok: only notify's signature opens the door")
}
// Every refusal names its reason and is final: the same mail is never
// retried into a different answer, and a duplicate delivery posts
// nothing twice.
func TestInboundReplyRefusalsAndDuplicates(t *testing.T) {
h, _, log := newReplyRig(t)
cases := []struct {
name string
ev map[string]any
want int
}{
{"other event type", map[string]any{"type": "delivery.sent", "messageId": "<x@m>"}, http.StatusOK},
{"unmapped transaction", replyEvent("err:2026-09-09", "lou", "hi"), http.StatusUnprocessableEntity},
{"wrong subscriber", replyEvent("forum-reply:abc123:lou", "mallory", "hi"), http.StatusForbidden},
{"empty text", replyEvent("forum-reply:abc123:lou", "lou", " \n"), http.StatusUnprocessableEntity},
{"unknown post", replyEvent("forum-reply:nope:lou", "lou", "hi"), http.StatusUnprocessableEntity},
{"no capability", replyEvent("forum-reply:abc123:guest", "guest", "hi"), http.StatusForbidden},
}
for i, c := range cases {
c.ev["messageId"] = "<" + strconv.Itoa(i) + "@m>"
w := deliver(h, "s3cret", 1_800_000_000, c.ev)
if w.Code != c.want {
t.Errorf("FAIL: %s answered %d, want %d: %s", c.name, w.Code, c.want, w.Body)
}
if c.want != http.StatusOK && !strings.Contains(w.Body.String(), "error") {
t.Errorf("FAIL: %s gave no reason", c.name)
}
}
if len(*log) != 0 {
t.Fatalf("FAIL: refusals posted %d replies", len(*log))
}
ev := replyEvent("forum-reply:abc123:lou", "lou", "once")
if w := deliver(h, "s3cret", 1_800_000_000, ev); w.Code != http.StatusCreated {
t.Fatalf("FAIL: %d %s", w.Code, w.Body)
}
if w := deliver(h, "s3cret", 1_800_000_000, ev); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "duplicate") {
t.Errorf("FAIL: redelivery answered %d %s", w.Code, w.Body)
}
if len(*log) != 1 {
t.Errorf("FAIL: the redelivery posted again (%d posts)", len(*log))
}
t.Log("ok: refusals carry a reason, a redelivery is answered from the record")
}
// A transient failure is not recorded, so notify's retry gets a real
// second attempt.
func TestInboundReplyTransientFailureIsRetryable(t *testing.T) {
h, store, _ := newReplyRig(t)
h.Post = func(context.Context, forumReplyIn) (string, error) { return "", errors.New("store is locked") }
ev := replyEvent("forum-reply:abc123:lou", "lou", "hi")
if w := deliver(h, "s3cret", 1_800_000_000, ev); w.Code != http.StatusInternalServerError {
t.Fatalf("FAIL: %d", w.Code)
}
if _, seen := store.seen["<[email protected]>"]; seen {
t.Error("FAIL: a transient failure was recorded as final")
}
t.Log("ok: a 5xx leaves the door open for the retry")
}