| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- // 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"])
- }
- }
|