Skip to content
quarry Artifact [6d736cfefc]
Login

Artifact [6d736cfefc]

Artifact 6d736cfefc4c9973f23efb91c2ec8a2abf53021fd13aca6d629697e05160ff49:


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, store, 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)
	}
	// No secret on file yet — notify has not shown one — and a
	// correctly signed delivery still cannot be told from a forgery.
	store.secret = ""
	if w := deliver(h, "", 1_800_000_000, ev); w.Code != http.StatusUnauthorized {
		t.Errorf("FAIL: with no secret on file, 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")
}