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>
171 lines
6.0 KiB
Go
171 lines
6.0 KiB
Go
package publish
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/ingest"
|
|
)
|
|
|
|
// reportJSON marshals a Report to the scrubbed-JSON plaintext shape that
|
|
// crypto.Open yields (what formatIssue receives).
|
|
func reportJSON(t *testing.T, r ingest.Report) []byte {
|
|
t.Helper()
|
|
b, err := json.Marshal(r)
|
|
if err != nil {
|
|
t.Fatalf("marshal report: %v", err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// TestFormatIssueWellFormed checks the happy path renders a title and a body that
|
|
// carries the id, the metadata, and the report text inside a code fence.
|
|
func TestFormatIssueWellFormed(t *testing.T) {
|
|
id := "20260703T120000-aabbccddeeff00112233"
|
|
pt := reportJSON(t, ingest.Report{
|
|
AppVersion: "1.4.2 (142)",
|
|
Platform: "android",
|
|
OSVersion: "Android 14",
|
|
Device: "Pixel 7",
|
|
ClientTimestamp: "2026-07-02T12:34:56Z",
|
|
Report: "NullPointerException in SyncService\nat line 42",
|
|
})
|
|
|
|
title, body := formatIssue(id, pt)
|
|
|
|
if !strings.Contains(title, "1.4.2 (142)") || !strings.Contains(title, "android") {
|
|
t.Errorf("title missing app/platform: %q", title)
|
|
}
|
|
for _, want := range []string{
|
|
id, // report id for traceability
|
|
"1.4.2 (142)", // app version row
|
|
"Android 14", // os version row
|
|
"Pixel 7", // device row
|
|
"2026-07-02T12:34:56Z", // client timestamp row
|
|
"NullPointerException in SyncService", // the report text
|
|
"docs/privacy.md", // scrubbing disclosure
|
|
"bug-report", // labels footer
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("body missing %q\n---\n%s", want, body)
|
|
}
|
|
}
|
|
if utf8.RuneCountInString(body) > MaxIssueBodyRunes {
|
|
t.Errorf("body length %d exceeds cap %d", utf8.RuneCountInString(body), MaxIssueBodyRunes)
|
|
}
|
|
// The report text must sit inside a fenced code block (injection-safe).
|
|
if !strings.Contains(body, "```\nNullPointerException") {
|
|
t.Errorf("report text not wrapped in a code fence:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestFormatIssueTruncatesOversizeBody is the ADR #6 §2.1 requirement: a report
|
|
// whose rendered body would exceed 65,536 characters is truncated to fit.
|
|
func TestFormatIssueTruncatesOversizeBody(t *testing.T) {
|
|
huge := strings.Repeat("A", MaxIssueBodyRunes*2) // way over the cap
|
|
pt := reportJSON(t, ingest.Report{
|
|
AppVersion: "9.9.9",
|
|
Platform: "android",
|
|
Report: huge,
|
|
})
|
|
|
|
_, body := formatIssue("id-huge", pt)
|
|
|
|
if got := utf8.RuneCountInString(body); got > MaxIssueBodyRunes {
|
|
t.Fatalf("truncated body length %d exceeds cap %d", got, MaxIssueBodyRunes)
|
|
}
|
|
if !strings.Contains(body, "truncated") {
|
|
t.Errorf("oversize body lacks a truncation marker:\n%s…", body[:200])
|
|
}
|
|
// It should still be near the cap (we kept as much as fits), not tiny.
|
|
if got := utf8.RuneCountInString(body); got < MaxIssueBodyRunes-1024 {
|
|
t.Errorf("truncated body length %d is far below the cap; too aggressive", got)
|
|
}
|
|
}
|
|
|
|
// TestFormatIssueMarkdownInjectionSafe proves backtick runs in the report cannot
|
|
// break out of the code fence and @mentions are neutralised (kept inside code).
|
|
func TestFormatIssueMarkdownInjectionSafe(t *testing.T) {
|
|
// A report containing a triple-backtick run and an @mention.
|
|
body := "before ``` middle ```` end @maintainer please look"
|
|
pt := reportJSON(t, ingest.Report{AppVersion: "1", Platform: "android", Report: body})
|
|
|
|
_, out := formatIssue("id-x", pt)
|
|
|
|
// The fence chosen must be longer than the longest backtick run (4) → ≥5.
|
|
if !strings.Contains(out, "`````") {
|
|
t.Errorf("expected a fence of ≥5 backticks to survive the content, got:\n%s", out)
|
|
}
|
|
// The @mention is present but only ever inside the code block (we can at least
|
|
// assert it is not on a line by itself outside a fence — a coarse check: the
|
|
// content line is indented/enclosed, i.e. the raw text is retained verbatim).
|
|
if !strings.Contains(out, "@maintainer") {
|
|
t.Errorf("report content was dropped")
|
|
}
|
|
if utf8.RuneCountInString(out) > MaxIssueBodyRunes {
|
|
t.Errorf("body exceeds cap")
|
|
}
|
|
}
|
|
|
|
// TestFormatIssueUnparseablePayload: if the decrypted bytes are not the expected
|
|
// JSON shape, the content is shown verbatim rather than dropped.
|
|
func TestFormatIssueUnparseablePayload(t *testing.T) {
|
|
pt := []byte("this is not json, just raw text with a secret [REDACTED_EMAIL]")
|
|
|
|
title, body := formatIssue("id-raw", pt)
|
|
|
|
if title == "" {
|
|
t.Error("empty title for unparseable payload")
|
|
}
|
|
if !strings.Contains(body, "not json") {
|
|
t.Errorf("verbatim content missing:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "did not match the expected report schema") {
|
|
t.Errorf("expected a schema-mismatch note:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestFormatIssuePipeAndBacktickInMetadata: metadata values with '|' or '`' must
|
|
// not break the Markdown table or the inline-code span.
|
|
func TestFormatIssueMetadataSanitised(t *testing.T) {
|
|
pt := reportJSON(t, ingest.Report{
|
|
AppVersion: "1.0 | weird `build`",
|
|
Platform: "android",
|
|
Report: "x",
|
|
})
|
|
_, body := formatIssue("id", pt)
|
|
// The raw '|' and '`' must have been replaced in the rendered cell.
|
|
if strings.Contains(body, "1.0 | weird `build`") {
|
|
t.Errorf("metadata not sanitised for table/inline-code:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestFenceFor(t *testing.T) {
|
|
cases := map[string]int{
|
|
"no backticks": 3,
|
|
"one ` here": 3, // longest run 1 → max(3, 2)=3
|
|
"two `` here": 3, // longest run 2 → max(3,3)=3
|
|
"three ``` here": 4,
|
|
"four ```` here": 5,
|
|
}
|
|
for in, wantLen := range cases {
|
|
if got := fenceFor(in); len(got) != wantLen {
|
|
t.Errorf("fenceFor(%q) len = %d, want %d", in, len(got), wantLen)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTruncateRunes(t *testing.T) {
|
|
if got := truncateRunes("héllo", 3); got != "hél" {
|
|
t.Errorf("truncateRunes multibyte = %q, want %q", got, "hél")
|
|
}
|
|
if got := truncateRunes("abc", 10); got != "abc" {
|
|
t.Errorf("truncateRunes under limit = %q, want abc", got)
|
|
}
|
|
if got := truncateRunes("abc", 0); got != "" {
|
|
t.Errorf("truncateRunes zero = %q, want empty", got)
|
|
}
|
|
}
|