Files
LibreMail-Bug-Report-Ingest/internal/handler/admin_test.go
T
JMR-devandClaude Opus 4.8 047391d01c #11 Manual review/removal path for maintainers
Add an authenticated admin API to the ingest Worker so the single maintainer
can review the pending queue and pull a report before Friday's publish run.

Endpoints (on the existing handler):
  GET    /v1/admin/reports              list pending report ids
  POST   /v1/admin/reports/{id}/remove  mark a report removed
  DELETE /v1/admin/reports/{id}         remove alias

Remove calls lifecycle.MarkRemoved (#10), transitioning pending -> removed so
#13's ListPending excludes it from the next publish. Codes: 200 list/remove,
404 unknown id, 401 missing/bad/unset-secret token, 405 wrong method.

Auth: shared-secret Bearer token compared with crypto/subtle.ConstantTimeCompare,
fail-closed when the secret is unset. Injected via handler.New's new AdminBackend
arg: the dev server and tests wire a memory-backed lifecycle.Manager + ADMIN_TOKEN
env; the Worker reads ADMIN_TOKEN from Secrets Store and builds an R2-backed
Manager per request. Choice documented in docs/decisions/admin-auth.md.

Tests: Go httptest unit tests (list, remove+exclusion, 404, 401 incl. fail-closed,
405) and a Bruno api-tests flow (seed, authed list/remove, exclusion, no/bad
token 401). wrangler.jsonc gains only the ADMIN_TOKEN secret binding; worker
triggers untouched (owned by #13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:56:59 -05:00

269 lines
11 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/lifecycle"
"github.com/JMR-dev/LibreMail-Bug-Report-Ingest/internal/storage"
)
const testToken = "s3cret-admin-token"
// adminHarness builds a handler whose admin API is backed by an in-memory store
// seeded with the given pending report ids, authenticated by token. It returns
// the handler and the store so tests can assert post-conditions.
func adminHarness(t *testing.T, token string, pendingIDs ...string) (http.Handler, storage.ObjectStore) {
t.Helper()
store := storage.NewMemoryStore()
for _, id := range pendingIDs {
// The admin API only lists keys and moves opaque bytes, so a placeholder
// ciphertext is enough; no real crypto is needed to exercise it.
if err := store.Put(context.Background(), storage.ReportKey(storage.StatusPending, id), []byte("frame:"+id)); err != nil {
t.Fatalf("seed pending %q: %v", id, err)
}
}
h := New(nil, NewManagerBackend(lifecycle.New(store), token))
return h, store
}
// adminReq issues one admin request with an optional bearer token ("" sends no
// Authorization header) and returns the recorder.
func adminReq(t *testing.T, h http.Handler, method, target, bearer string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// adminBody is a decode target spanning every admin response field (list, remove,
// and error bodies), so one helper can parse any admin response in the tests.
type adminBody struct {
Status string `json:"status"`
Reports []string `json:"reports"`
ID string `json:"id"`
Error string `json:"error"`
}
// decodeAdmin parses an admin JSON response body.
func decodeAdmin(t *testing.T, rec *httptest.ResponseRecorder) adminBody {
t.Helper()
var got adminBody
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("admin response is not valid JSON: %v (body=%q)", err, rec.Body.String())
}
return got
}
// TestAdminListReturnsPendingIDs is the acceptance path: an authenticated
// maintainer lists exactly the pending report ids.
func TestAdminListReturnsPendingIDs(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a", "id-b", "id-c")
rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("GET list status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want application/json; charset=utf-8", ct)
}
got := decodeAdmin(t, rec)
if got.Status != "ok" {
t.Errorf("status field = %q, want ok", got.Status)
}
want := []string{"id-a", "id-b", "id-c"} // sorted, MemoryStore lists ascending
if !slices.Equal(got.Reports, want) {
t.Errorf("reports = %v, want %v", got.Reports, want)
}
}
// TestAdminListEmptyIsArray confirms an empty pending set marshals as [] (not null),
// which the API consumers (and the Bruno tests) rely on.
func TestAdminListEmptyIsArray(t *testing.T) {
h, _ := adminHarness(t, testToken)
rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if body := rec.Body.String(); !strings.Contains(body, `"reports":[]`) {
t.Errorf("empty list body = %q, want it to contain \"reports\":[]", body)
}
}
// TestAdminRemoveExcludesFromPending is the ticket's core acceptance: a removed
// report transitions to removed and disappears from the pending list, so the next
// publish run will not see it.
func TestAdminRemoveExcludesFromPending(t *testing.T) {
h, store := adminHarness(t, testToken, "keep-1", "drop-me", "keep-2")
rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/drop-me/remove", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("remove status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
}
if got := decodeAdmin(t, rec); got.Status != "removed" || got.ID != "drop-me" {
t.Errorf("remove body = %+v, want {status:removed id:drop-me}", got)
}
// It is gone from the pending list...
list := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if got := decodeAdmin(t, list).Reports; slices.Contains(got, "drop-me") {
t.Errorf("pending list still contains drop-me: %v", got)
} else if !slices.Equal(got, []string{"keep-1", "keep-2"}) {
t.Errorf("pending list = %v, want [keep-1 keep-2]", got)
}
// ...and it now lives under the removed prefix (excluded from publishing).
removed, _ := store.List(context.Background(), storage.StatusPrefix(storage.StatusRemoved))
if !slices.Equal(removed, []string{storage.ReportKey(storage.StatusRemoved, "drop-me")}) {
t.Errorf("removed keys = %v, want the single removed report", removed)
}
}
// TestAdminRemoveViaDelete checks the DELETE alias behaves like POST .../remove.
func TestAdminRemoveViaDelete(t *testing.T) {
h, _ := adminHarness(t, testToken, "gone")
rec := adminReq(t, h, http.MethodDelete, "/v1/admin/reports/gone", testToken)
if rec.Code != http.StatusOK {
t.Fatalf("DELETE status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
}
list := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if got := decodeAdmin(t, list).Reports; len(got) != 0 {
t.Errorf("pending after DELETE = %v, want empty", got)
}
}
// TestAdminRemoveIdempotent proves removing the same report twice still succeeds
// (200), matching the lifecycle Manager's idempotent transition.
func TestAdminRemoveIdempotent(t *testing.T) {
h, _ := adminHarness(t, testToken, "twice")
if rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/twice/remove", testToken); rec.Code != http.StatusOK {
t.Fatalf("first remove status = %d, want 200", rec.Code)
}
if rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/twice/remove", testToken); rec.Code != http.StatusOK {
t.Errorf("second remove status = %d, want 200 (idempotent)", rec.Code)
}
}
// TestAdminRemoveUnknownIs404 covers an id that was never pending.
func TestAdminRemoveUnknownIs404(t *testing.T) {
h, _ := adminHarness(t, testToken, "real")
rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/ghost/remove", testToken)
if rec.Code != http.StatusNotFound {
t.Fatalf("remove unknown status = %d, want 404 (body=%q)", rec.Code, rec.Body.String())
}
if got := decodeAdmin(t, rec); got.Status != "error" {
t.Errorf("404 status field = %q, want error", got.Status)
}
}
// TestAdminMissingTokenIs401 covers a request with no Authorization header.
func TestAdminMissingTokenIs401(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
for _, tc := range []struct {
name, method, target string
}{
{"list", http.MethodGet, "/v1/admin/reports"},
{"remove", http.MethodPost, "/v1/admin/reports/id-a/remove"},
{"delete", http.MethodDelete, "/v1/admin/reports/id-a"},
} {
t.Run(tc.name, func(t *testing.T) {
rec := adminReq(t, h, tc.method, tc.target, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("no-token status = %d, want 401", rec.Code)
}
if wa := rec.Header().Get("WWW-Authenticate"); wa != "Bearer" {
t.Errorf("WWW-Authenticate = %q, want Bearer", wa)
}
})
}
// A no-op check that the guarded report is still pending (auth blocked the write).
list := adminReq(t, h, http.MethodGet, "/v1/admin/reports", testToken)
if got := decodeAdmin(t, list).Reports; !slices.Contains(got, "id-a") {
t.Errorf("report was mutated despite unauthorized requests: %v", got)
}
}
// TestAdminInvalidTokenIs401 covers a well-formed header carrying the wrong token.
func TestAdminInvalidTokenIs401(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", "wrong-token")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("bad-token status = %d, want 401", rec.Code)
}
}
// TestAdminMalformedAuthHeaderIs401 covers non-Bearer / malformed schemes.
func TestAdminMalformedAuthHeaderIs401(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
for _, hdr := range []string{"Basic abc123", "Bearer", "token " + testToken, testToken} {
req := httptest.NewRequest(http.MethodGet, "/v1/admin/reports", nil)
req.Header.Set("Authorization", hdr)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("Authorization %q: status = %d, want 401", hdr, rec.Code)
}
}
}
// TestAdminUnsetSecretFailsClosed is the fail-closed requirement: with no server
// secret configured, every request is rejected even when the client presents a
// (any) bearer token, so a missing binding can never leave the API open.
func TestAdminUnsetSecretFailsClosed(t *testing.T) {
h, _ := adminHarness(t, "" /* no server secret */, "id-a")
// Client presents a plausible token; the server has none, so 401.
if rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", "any-token"); rec.Code != http.StatusUnauthorized {
t.Errorf("list with unset server secret: status = %d, want 401", rec.Code)
}
if rec := adminReq(t, h, http.MethodPost, "/v1/admin/reports/id-a/remove", "any-token"); rec.Code != http.StatusUnauthorized {
t.Errorf("remove with unset server secret: status = %d, want 401", rec.Code)
}
// The empty string as a token must not authenticate against an unset secret.
if rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("list with no token and unset secret: status = %d, want 401", rec.Code)
}
}
// TestAdminNilBackendFailsClosed proves New(sink, nil) still fails closed (401),
// never panics, and never allows access.
func TestAdminNilBackendFailsClosed(t *testing.T) {
h := New(nil, nil)
if rec := adminReq(t, h, http.MethodGet, "/v1/admin/reports", "any-token"); rec.Code != http.StatusUnauthorized {
t.Errorf("nil backend list: status = %d, want 401", rec.Code)
}
}
// TestAdminWrongMethodIs405 checks the mux answers 405 for a wrong method on a
// known admin path, with an Allow header, before auth runs.
func TestAdminWrongMethodIs405(t *testing.T) {
h, _ := adminHarness(t, testToken, "id-a")
for _, tc := range []struct {
name, method, target, allow string
}{
{"put-list", http.MethodPut, "/v1/admin/reports", http.MethodGet},
{"delete-list", http.MethodDelete, "/v1/admin/reports", http.MethodGet},
{"get-remove", http.MethodGet, "/v1/admin/reports/id-a/remove", http.MethodPost},
{"put-report", http.MethodPut, "/v1/admin/reports/id-a", http.MethodDelete},
} {
t.Run(tc.name, func(t *testing.T) {
rec := adminReq(t, h, tc.method, tc.target, testToken)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
if allow := rec.Header().Get("Allow"); allow != tc.allow {
t.Errorf("Allow = %q, want %q", allow, tc.allow)
}
})
}
}