|
|
@@ -0,0 +1,218 @@
|
|
|
+// 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"])
|
|
|
+ }
|
|
|
+}
|