// Tests for the M13a W5 JWT gate in routerd. We test the gate // wiring (which routes are protected, which roles are allowed) // in isolation. The dedupe flush handler is exercised end-to-end // against a real dedupe.Collapser (no broker needed; the onFlush // callback is a no-op in the test). package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "testing" "time" "git3.techno-world.net/lrosales/broad-announce/internal/alert" "git3.techno-world.net/lrosales/broad-announce/internal/dedupe" ) const testSecret = "test-secret-with-32-bytes-min-len-abc" // mintTestJWT signs a HS256 access token using the given secret // and role. Mirrors the helper in cmd/admind/main_test.go — we // don't go through authd.mintAccessToken because it's unexported. func mintTestJWT(t *testing.T, secret, role string) string { t.Helper() hb, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"}) body, _ := json.Marshal(map[string]any{ "sub": "u-test", "tid": "t-test", "role": role, "typ": "access", "iss": "broad-announce", "exp": time.Now().Add(15 * time.Minute).Unix(), "iat": time.Now().Unix(), }) enc := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(body) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(enc)) sig := mac.Sum(nil) return enc + "." + base64.RawURLEncoding.EncodeToString(sig) } // newTestCollapser returns a Collapser wired to a no-op onFlush // callback, plus the matching fanoutState. Used by the gate tests // to construct the dependencies wireAdminRoutes needs. func newTestCollapser() (*dedupe.Collapser, *fanoutState) { state := newFanoutState() noop := func(string, string, alert.Alert) {} c := dedupe.NewCollapser(50*time.Millisecond, noop) return c, state } func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // TestWireAdminRoutes_NoSecret_Disabled verifies that with // BA_AUTHD_JWT_SECRET unset, NO admin routes are registered. The // M-series LAN deploy path stays the same as before. func TestWireAdminRoutes_NoSecret_Disabled(t *testing.T) { os.Unsetenv("BA_AUTHD_JWT_SECRET") mux := http.NewServeMux() c, state := newTestCollapser() wireAdminRoutes(mux, c, state, discardLogger()) // Each route should NOT be registered. A 404 (the mux's // default for an unknown path) is the proof. for _, tc := range []struct{ method, path string }{ {"GET", "/v1/admin/dedupe/state"}, {"POST", "/v1/admin/dedupe/flush"}, } { rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) if rr.Code != http.StatusNotFound { t.Errorf("%s %s with no secret: got %d, want 404 (route should be unregistered)", tc.method, tc.path, rr.Code) } } } // TestWireAdminRoutes_WithSecret_Gated verifies that with the // secret set, both admin routes are 401 without a token. func TestWireAdminRoutes_WithSecret_Gated(t *testing.T) { t.Setenv("BA_AUTHD_JWT_SECRET", testSecret) t.Setenv("BA_AUTHD_ISSUER", "broad-announce") mux := http.NewServeMux() c, state := newTestCollapser() wireAdminRoutes(mux, c, state, discardLogger()) for _, tc := range []struct{ method, path string }{ {"GET", "/v1/admin/dedupe/state"}, {"POST", "/v1/admin/dedupe/flush"}, } { rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) if rr.Code != http.StatusUnauthorized { t.Errorf("%s %s with secret+no-token: status = %d, want 401", tc.method, tc.path, rr.Code) } } } // TestAdminDedupeState_Auth verifies that an authenticated user // can read the dedupe state. The endpoint returns JSON with // pending_collapses and cached_target_lists counts (both 0 in // the fresh-test scenario). func TestAdminDedupeState_Auth(t *testing.T) { t.Setenv("BA_AUTHD_JWT_SECRET", testSecret) tok := mintTestJWT(t, testSecret, "super_admin") mux := http.NewServeMux() c, state := newTestCollapser() wireAdminRoutes(mux, c, state, discardLogger()) req := httptest.NewRequest("GET", "/v1/admin/dedupe/state", nil) req.Header.Set("Authorization", "Bearer "+tok) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } var body map[string]any if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if _, ok := body["pending_collapses"]; !ok { t.Errorf("body missing pending_collapses: %+v", body) } } // TestAdminRolePolicy_ViewerReadOnly_AdminCanFlush verifies the // read/destructive split: // - GET /v1/admin/dedupe/state — any authenticated user // - POST /v1/admin/dedupe/flush — super_admin or tenant_admin func TestAdminRolePolicy_ViewerReadOnly_AdminCanFlush(t *testing.T) { t.Setenv("BA_AUTHD_JWT_SECRET", testSecret) mux := http.NewServeMux() c, state := newTestCollapser() wireAdminRoutes(mux, c, state, discardLogger()) mintFor := func(role string) string { return mintTestJWT(t, testSecret, role) } cases := []struct { name string role string method string path string want int }{ {"viewer-state", "viewer", "GET", "/v1/admin/dedupe/state", 200}, {"viewer-flush-403", "viewer", "POST", "/v1/admin/dedupe/flush", 403}, {"ta-flush-200", "tenant_admin", "POST", "/v1/admin/dedupe/flush", 200}, {"sa-flush-200", "super_admin", "POST", "/v1/admin/dedupe/flush", 200}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { tok := mintFor(tc.role) req := httptest.NewRequest(tc.method, tc.path, nil) req.Header.Set("Authorization", "Bearer "+tok) rr := httptest.NewRecorder() mux.ServeHTTP(rr, req) if rr.Code != tc.want { t.Errorf("%s %s as %s: status = %d, want %d (body: %s)", tc.method, tc.path, tc.role, rr.Code, tc.want, rr.Body.String()) } }) } } // TestAdminDedupeFlush_DrainsPending exercises the flush handler // against a real Collapser. We Observe one alert to arm a pending // collapse, then POST /v1/admin/dedupe/flush and verify the // pending count drops to 0. func TestAdminDedupeFlush_DrainsPending(t *testing.T) { t.Setenv("BA_AUTHD_JWT_SECRET", testSecret) tok := mintTestJWT(t, testSecret, "super_admin") mux := http.NewServeMux() c, state := newTestCollapser() wireAdminRoutes(mux, c, state, discardLogger()) // Arm one pending collapse. We use a long flush window so // the auto-flush doesn't fire before our test call. c2 := dedupe.NewCollapser(5*time.Second, func(string, string, alert.Alert) {}) c2.Observe("src-test", "k1", alert.Alert{ID: "a1", SourceID: "src-test", DedupeKey: "k1"}) if c2.Pending() != 1 { t.Fatalf("after Observe: Pending = %d, want 1", c2.Pending()) } // Wire a fresh mux with c2 instead of c (the original). mux2 := http.NewServeMux() wireAdminRoutes(mux2, c2, state, discardLogger()) req := httptest.NewRequest("POST", "/v1/admin/dedupe/flush", nil) req.Header.Set("Authorization", "Bearer "+tok) rr := httptest.NewRecorder() mux2.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String()) } var body map[string]any if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if got, _ := body["pending_before"].(float64); int(got) != 1 { t.Errorf("pending_before = %v, want 1", body["pending_before"]) } if flushed, _ := body["flushed"].(bool); !flushed { t.Errorf("flushed = %v, want true", body["flushed"]) } }