Files
LibreMail-Bug-Report-Ingest/internal/publish/github_test.go
T
JMR-devandClaude Opus 4.8 f9ec7978b8 Add internal/publish: the real schedule.Publisher that turns pending
encrypted reports into labeled GitHub issues.

- GitHub REST client on net/http (host-testable via httptest; works under
  TinyGo js/wasm per #26). Encodes ADR #6 §3.2: serial mutations spaced
  >=1s, honour Retry-After, wait until x-ratelimit-reset, >=60s floor for
  secondary-limit 403s, full-jitter exponential backoff (base 1s, cap 60s,
  <=5 attempts). Ensures the three ADR #6 labels (create-or-ignore).
- Publisher: GetPending -> crypto.Open -> format -> CreateIssue per id,
  with the ADR #6 per-run cap (50) and 65,536-char body cap (truncate).
  Per-report failures are isolated and surfaced, never abort the batch.
- onPublished(ctx, id) seam, called only after a confirmed 201, default
  no-op: #15 wires it to lifecycle.MarkPublished to complete cross-run
  de-dup. #14 does not implement the mark-published transition.
- Issue body wraps report free-text in a length-adaptive code fence and
  metadata in inline code, neutralising Markdown/@mention injection.
- Worker: swap schedule.LogPublisher for the real publisher in
  scheduled_wasm.go; read GITHUB_TOKEN (Secrets Store) + GITHUB_REPO (var);
  pre-gate so the sibling cron fire does no secret I/O. worker/main.go
  untouched. Export storage.GetSecret for the token read.
- wrangler.jsonc: add GITHUB_TOKEN secret + GITHUB_REPO var (my keys only).

Tests (host, httptest mock, virtual clock): N reports -> N labeled issues
+ onPublished per success; >65,536-char body truncated; transient 5xx and
Retry-After retried per policy; persistent failure isolated (no
onPublished); permission 403 not retried; per-run cap; decrypt failure
isolated. go vet + go test ./... green; GOOS=js GOARCH=wasm build compiles.

Closes #14

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:30:33 -05:00

368 lines
11 KiB
Go

package publish
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// --- test doubles: a virtual clock and a scriptable GitHub API mock ---
// fakeClock is a virtual clock whose time only advances when Sleep is called, so
// backoff/spacing are exercised with zero real delay and are assertable.
type fakeClock struct {
mu sync.Mutex
t time.Time
sleeps []time.Duration
}
func newFakeClock() *fakeClock {
return &fakeClock{t: time.Unix(1_700_000_000, 0).UTC()}
}
func (f *fakeClock) now() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.t
}
func (f *fakeClock) sleep(_ context.Context, d time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
if d > 0 {
f.t = f.t.Add(d)
f.sleeps = append(f.sleeps, d)
}
return nil
}
func (f *fakeClock) sleepCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.sleeps)
}
func (f *fakeClock) lastSleep() time.Duration {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.sleeps) == 0 {
return 0
}
return f.sleeps[len(f.sleeps)-1]
}
// scriptedResp is one canned HTTP response for the mock.
type scriptedResp struct {
status int
header map[string]string
body string
}
// ghServer is a scriptable mock of the GitHub REST API for POST /labels and
// POST /issues. issueScript/labelScript are consumed front-to-back; when empty a
// default 201 is returned (and, for issues, a synthetic created-issue body).
type ghServer struct {
mu sync.Mutex
labelReqs []labelRequest
createdIssues []issueRequest // recorded only on a 201 response
issueAttempts int
labelAttempts int
issueScript []scriptedResp
labelScript []scriptedResp
nextIssueNum int
authHeaders []string
uaHeaders []string
apiVersions []string
}
func (g *ghServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
g.authHeaders = append(g.authHeaders, r.Header.Get("Authorization"))
g.uaHeaders = append(g.uaHeaders, r.Header.Get("User-Agent"))
g.apiVersions = append(g.apiVersions, r.Header.Get("X-GitHub-Api-Version"))
switch {
case strings.HasSuffix(r.URL.Path, "/labels") && r.Method == http.MethodPost:
var lr labelRequest
_ = json.NewDecoder(r.Body).Decode(&lr)
g.labelReqs = append(g.labelReqs, lr)
g.labelAttempts++
resp := popScript(&g.labelScript, scriptedResp{status: http.StatusCreated, body: `{}`})
writeResp(w, resp)
case strings.HasSuffix(r.URL.Path, "/issues") && r.Method == http.MethodPost:
var ir issueRequest
_ = json.NewDecoder(r.Body).Decode(&ir)
g.issueAttempts++
resp := popScript(&g.issueScript, scriptedResp{})
if resp.status == 0 { // default: synthesize a success
g.nextIssueNum++
n := g.nextIssueNum
resp = scriptedResp{
status: http.StatusCreated,
body: fmt.Sprintf(`{"number":%d,"html_url":"https://github.com/o/r/issues/%d"}`, n, n),
}
}
if resp.status == http.StatusCreated {
g.createdIssues = append(g.createdIssues, ir)
}
writeResp(w, resp)
default:
w.WriteHeader(http.StatusNotFound)
}
}
func popScript(q *[]scriptedResp, def scriptedResp) scriptedResp {
if len(*q) == 0 {
return def
}
r := (*q)[0]
*q = (*q)[1:]
return r
}
func writeResp(w http.ResponseWriter, r scriptedResp) {
for k, v := range r.header {
w.Header().Set(k, v)
}
w.WriteHeader(r.status)
_, _ = w.Write([]byte(r.body))
}
// testClient builds a Client aimed at srvURL with a virtual clock and max-jitter
// (randf==1) so exponential backoff is the full computed value, deterministically.
func testClient(srvURL string, fc *fakeClock, opts ...ClientOption) *Client {
base := []ClientOption{
WithBaseURL(srvURL),
WithClock(fc.now, fc.sleep),
WithRand(func() float64 { return 1.0 }),
}
return NewClient("test-token", "o", "r", append(base, opts...)...)
}
// --- tests ---
func TestCreateIssueSuccess(t *testing.T) {
g := &ghServer{}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, newFakeClock())
issue, err := c.CreateIssue(context.Background(), "the title", "the body", []string{"bug-report", "automated"})
if err != nil {
t.Fatalf("CreateIssue: %v", err)
}
if issue.Number != 1 || !strings.Contains(issue.HTMLURL, "/issues/1") {
t.Errorf("unexpected issue: %+v", issue)
}
if len(g.createdIssues) != 1 {
t.Fatalf("server recorded %d issues, want 1", len(g.createdIssues))
}
got := g.createdIssues[0]
if got.Title != "the title" || got.Body != "the body" {
t.Errorf("issue payload = %+v", got)
}
if len(got.Labels) != 2 || got.Labels[0] != "bug-report" {
t.Errorf("labels = %v", got.Labels)
}
// Required GitHub headers.
if g.authHeaders[0] != "Bearer test-token" {
t.Errorf("auth header = %q", g.authHeaders[0])
}
if g.uaHeaders[0] == "" {
t.Error("missing User-Agent")
}
if g.apiVersions[0] != apiVersion {
t.Errorf("api version = %q, want %q", g.apiVersions[0], apiVersion)
}
}
func TestEnsureLabelsCreateOrIgnore(t *testing.T) {
g := &ghServer{
// First label already exists (422), the rest are created (201, the default).
labelScript: []scriptedResp{{status: http.StatusUnprocessableEntity, body: `{"message":"Validation Failed"}`}},
}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, newFakeClock())
if err := c.EnsureLabels(context.Background(), DefaultLabels); err != nil {
t.Fatalf("EnsureLabels: %v", err)
}
if g.labelAttempts != len(DefaultLabels) {
t.Fatalf("label POSTs = %d, want %d", g.labelAttempts, len(DefaultLabels))
}
// Names and colours must match the ADR contract.
names := map[string]string{}
for _, l := range g.labelReqs {
names[l.Name] = l.Color
}
for _, want := range DefaultLabels {
if names[want.Name] != want.Color {
t.Errorf("label %q colour = %q, want %q", want.Name, names[want.Name], want.Color)
}
}
}
func TestRetryOn5xxThenSucceeds(t *testing.T) {
fc := newFakeClock()
g := &ghServer{issueScript: []scriptedResp{{status: http.StatusInternalServerError, body: "boom"}}}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, fc)
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
t.Fatalf("CreateIssue should recover after a transient 500: %v", err)
}
if g.issueAttempts != 2 {
t.Errorf("issue attempts = %d, want 2 (one 500, one success)", g.issueAttempts)
}
// One backoff sleep of the full attempt-0 backoff (1s, randf==1).
if fc.sleepCount() != 1 || fc.lastSleep() != 1*time.Second {
t.Errorf("backoff sleeps = %v (count %d), want a single 1s sleep", fc.sleeps, fc.sleepCount())
}
}
func TestRetryAfterHonoredExactly(t *testing.T) {
fc := newFakeClock()
g := &ghServer{issueScript: []scriptedResp{{
status: http.StatusTooManyRequests,
header: map[string]string{"Retry-After": "3"},
body: "slow down",
}}}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, fc)
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
t.Fatalf("CreateIssue: %v", err)
}
// Retry-After overrides computed backoff: exactly 3s, not jittered.
if fc.lastSleep() != 3*time.Second {
t.Errorf("waited %v, want exactly 3s (Retry-After honoured)", fc.lastSleep())
}
}
func TestRateLimitResetHonored(t *testing.T) {
fc := newFakeClock()
reset := fc.now().Add(30 * time.Second).Unix()
g := &ghServer{issueScript: []scriptedResp{{
status: http.StatusForbidden,
header: map[string]string{
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": fmt.Sprintf("%d", reset),
},
body: "rate limited",
}}}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, fc)
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
t.Fatalf("CreateIssue: %v", err)
}
// Waits until reset (30s) plus the 1s wake buffer.
if fc.lastSleep() != 31*time.Second {
t.Errorf("waited %v, want 31s (until x-ratelimit-reset + 1s)", fc.lastSleep())
}
}
func TestSecondaryRateLimitFloor(t *testing.T) {
fc := newFakeClock()
// A secondary-rate-limit 403 with no Retry-After: floor of 60s applies even
// though the attempt-0 exponential backoff would be only ~1s.
g := &ghServer{issueScript: []scriptedResp{{
status: http.StatusForbidden,
body: `{"message":"You have exceeded a secondary rate limit"}`,
}}}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, fc)
if _, err := c.CreateIssue(context.Background(), "t", "b", nil); err != nil {
t.Fatalf("CreateIssue: %v", err)
}
if fc.lastSleep() < 60*time.Second {
t.Errorf("secondary-limit wait = %v, want ≥60s", fc.lastSleep())
}
}
func TestPermission403NotRetried(t *testing.T) {
fc := newFakeClock()
// A plain 403 with no rate-limit signals is a permission error: surface it
// immediately, do not retry.
g := &ghServer{issueScript: []scriptedResp{{
status: http.StatusForbidden,
body: `{"message":"Resource not accessible by personal access token"}`,
}}}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, fc)
_, err := c.CreateIssue(context.Background(), "t", "b", nil)
if err == nil {
t.Fatal("expected an error for a permission 403")
}
if g.issueAttempts != 1 {
t.Errorf("issue attempts = %d, want 1 (permission 403 must not retry)", g.issueAttempts)
}
if fc.sleepCount() != 0 {
t.Errorf("slept %d times on a non-retryable 403, want 0", fc.sleepCount())
}
}
func TestMaxAttemptsExhausted(t *testing.T) {
fc := newFakeClock()
// Every attempt fails with 500; with maxAttempts=3 the client gives up after 3.
g := &ghServer{issueScript: []scriptedResp{
{status: http.StatusBadGateway, body: "1"},
{status: http.StatusBadGateway, body: "2"},
{status: http.StatusBadGateway, body: "3"},
{status: http.StatusBadGateway, body: "4"},
}}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, fc, WithMaxAttempts(3))
_, err := c.CreateIssue(context.Background(), "t", "b", nil)
if err == nil {
t.Fatal("expected an error after exhausting attempts")
}
if g.issueAttempts != 3 {
t.Errorf("issue attempts = %d, want 3", g.issueAttempts)
}
// Two backoffs between three attempts.
if fc.sleepCount() != 2 {
t.Errorf("backoff sleeps = %d, want 2", fc.sleepCount())
}
}
func TestSpacingBetweenMutations(t *testing.T) {
fc := newFakeClock()
g := &ghServer{}
srv := httptest.NewServer(g)
defer srv.Close()
c := testClient(srv.URL, fc)
// Two consecutive creates with no time passing between them: the second must
// be spaced by ≥1s (ADR #6).
if _, err := c.CreateIssue(context.Background(), "a", "b", nil); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := c.CreateIssue(context.Background(), "c", "d", nil); err != nil {
t.Fatalf("second create: %v", err)
}
if fc.sleepCount() != 1 || fc.lastSleep() != 1*time.Second {
t.Errorf("spacing sleeps = %v, want a single 1s spacing before the 2nd create", fc.sleeps)
}
}