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>
269 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|