admin_test.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. // Tests for the M13a W5 JWT gate in routerd. We test the gate
  2. // wiring (which routes are protected, which roles are allowed)
  3. // in isolation. The dedupe flush handler is exercised end-to-end
  4. // against a real dedupe.Collapser (no broker needed; the onFlush
  5. // callback is a no-op in the test).
  6. package main
  7. import (
  8. "crypto/hmac"
  9. "crypto/sha256"
  10. "encoding/base64"
  11. "encoding/json"
  12. "io"
  13. "log/slog"
  14. "net/http"
  15. "net/http/httptest"
  16. "os"
  17. "testing"
  18. "time"
  19. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  20. "git3.techno-world.net/lrosales/broad-announce/internal/dedupe"
  21. )
  22. const testSecret = "test-secret-with-32-bytes-min-len-abc"
  23. // mintTestJWT signs a HS256 access token using the given secret
  24. // and role. Mirrors the helper in cmd/admind/main_test.go — we
  25. // don't go through authd.mintAccessToken because it's unexported.
  26. func mintTestJWT(t *testing.T, secret, role string) string {
  27. t.Helper()
  28. hb, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
  29. body, _ := json.Marshal(map[string]any{
  30. "sub": "u-test",
  31. "tid": "t-test",
  32. "role": role,
  33. "typ": "access",
  34. "iss": "broad-announce",
  35. "exp": time.Now().Add(15 * time.Minute).Unix(),
  36. "iat": time.Now().Unix(),
  37. })
  38. enc := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(body)
  39. mac := hmac.New(sha256.New, []byte(secret))
  40. mac.Write([]byte(enc))
  41. sig := mac.Sum(nil)
  42. return enc + "." + base64.RawURLEncoding.EncodeToString(sig)
  43. }
  44. // newTestCollapser returns a Collapser wired to a no-op onFlush
  45. // callback, plus the matching fanoutState. Used by the gate tests
  46. // to construct the dependencies wireAdminRoutes needs.
  47. func newTestCollapser() (*dedupe.Collapser, *fanoutState) {
  48. state := newFanoutState()
  49. noop := func(string, string, alert.Alert) {}
  50. c := dedupe.NewCollapser(50*time.Millisecond, noop)
  51. return c, state
  52. }
  53. func discardLogger() *slog.Logger {
  54. return slog.New(slog.NewTextHandler(io.Discard, nil))
  55. }
  56. // TestWireAdminRoutes_NoSecret_Disabled verifies that with
  57. // BA_AUTHD_JWT_SECRET unset, NO admin routes are registered. The
  58. // M-series LAN deploy path stays the same as before.
  59. func TestWireAdminRoutes_NoSecret_Disabled(t *testing.T) {
  60. os.Unsetenv("BA_AUTHD_JWT_SECRET")
  61. mux := http.NewServeMux()
  62. c, state := newTestCollapser()
  63. wireAdminRoutes(mux, c, state, discardLogger())
  64. // Each route should NOT be registered. A 404 (the mux's
  65. // default for an unknown path) is the proof.
  66. for _, tc := range []struct{ method, path string }{
  67. {"GET", "/v1/admin/dedupe/state"},
  68. {"POST", "/v1/admin/dedupe/flush"},
  69. } {
  70. rr := httptest.NewRecorder()
  71. mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
  72. if rr.Code != http.StatusNotFound {
  73. t.Errorf("%s %s with no secret: got %d, want 404 (route should be unregistered)",
  74. tc.method, tc.path, rr.Code)
  75. }
  76. }
  77. }
  78. // TestWireAdminRoutes_WithSecret_Gated verifies that with the
  79. // secret set, both admin routes are 401 without a token.
  80. func TestWireAdminRoutes_WithSecret_Gated(t *testing.T) {
  81. t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
  82. t.Setenv("BA_AUTHD_ISSUER", "broad-announce")
  83. mux := http.NewServeMux()
  84. c, state := newTestCollapser()
  85. wireAdminRoutes(mux, c, state, discardLogger())
  86. for _, tc := range []struct{ method, path string }{
  87. {"GET", "/v1/admin/dedupe/state"},
  88. {"POST", "/v1/admin/dedupe/flush"},
  89. } {
  90. rr := httptest.NewRecorder()
  91. mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
  92. if rr.Code != http.StatusUnauthorized {
  93. t.Errorf("%s %s with secret+no-token: status = %d, want 401",
  94. tc.method, tc.path, rr.Code)
  95. }
  96. }
  97. }
  98. // TestAdminDedupeState_Auth verifies that an authenticated user
  99. // can read the dedupe state. The endpoint returns JSON with
  100. // pending_collapses and cached_target_lists counts (both 0 in
  101. // the fresh-test scenario).
  102. func TestAdminDedupeState_Auth(t *testing.T) {
  103. t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
  104. tok := mintTestJWT(t, testSecret, "super_admin")
  105. mux := http.NewServeMux()
  106. c, state := newTestCollapser()
  107. wireAdminRoutes(mux, c, state, discardLogger())
  108. req := httptest.NewRequest("GET", "/v1/admin/dedupe/state", nil)
  109. req.Header.Set("Authorization", "Bearer "+tok)
  110. rr := httptest.NewRecorder()
  111. mux.ServeHTTP(rr, req)
  112. if rr.Code != http.StatusOK {
  113. t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
  114. }
  115. var body map[string]any
  116. if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
  117. t.Fatalf("decode: %v", err)
  118. }
  119. if _, ok := body["pending_collapses"]; !ok {
  120. t.Errorf("body missing pending_collapses: %+v", body)
  121. }
  122. }
  123. // TestAdminRolePolicy_ViewerReadOnly_AdminCanFlush verifies the
  124. // read/destructive split:
  125. // - GET /v1/admin/dedupe/state — any authenticated user
  126. // - POST /v1/admin/dedupe/flush — super_admin or tenant_admin
  127. func TestAdminRolePolicy_ViewerReadOnly_AdminCanFlush(t *testing.T) {
  128. t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
  129. mux := http.NewServeMux()
  130. c, state := newTestCollapser()
  131. wireAdminRoutes(mux, c, state, discardLogger())
  132. mintFor := func(role string) string {
  133. return mintTestJWT(t, testSecret, role)
  134. }
  135. cases := []struct {
  136. name string
  137. role string
  138. method string
  139. path string
  140. want int
  141. }{
  142. {"viewer-state", "viewer", "GET", "/v1/admin/dedupe/state", 200},
  143. {"viewer-flush-403", "viewer", "POST", "/v1/admin/dedupe/flush", 403},
  144. {"ta-flush-200", "tenant_admin", "POST", "/v1/admin/dedupe/flush", 200},
  145. {"sa-flush-200", "super_admin", "POST", "/v1/admin/dedupe/flush", 200},
  146. }
  147. for _, tc := range cases {
  148. t.Run(tc.name, func(t *testing.T) {
  149. tok := mintFor(tc.role)
  150. req := httptest.NewRequest(tc.method, tc.path, nil)
  151. req.Header.Set("Authorization", "Bearer "+tok)
  152. rr := httptest.NewRecorder()
  153. mux.ServeHTTP(rr, req)
  154. if rr.Code != tc.want {
  155. t.Errorf("%s %s as %s: status = %d, want %d (body: %s)",
  156. tc.method, tc.path, tc.role, rr.Code, tc.want, rr.Body.String())
  157. }
  158. })
  159. }
  160. }
  161. // TestAdminDedupeFlush_DrainsPending exercises the flush handler
  162. // against a real Collapser. We Observe one alert to arm a pending
  163. // collapse, then POST /v1/admin/dedupe/flush and verify the
  164. // pending count drops to 0.
  165. func TestAdminDedupeFlush_DrainsPending(t *testing.T) {
  166. t.Setenv("BA_AUTHD_JWT_SECRET", testSecret)
  167. tok := mintTestJWT(t, testSecret, "super_admin")
  168. mux := http.NewServeMux()
  169. c, state := newTestCollapser()
  170. wireAdminRoutes(mux, c, state, discardLogger())
  171. // Arm one pending collapse. We use a long flush window so
  172. // the auto-flush doesn't fire before our test call.
  173. c2 := dedupe.NewCollapser(5*time.Second, func(string, string, alert.Alert) {})
  174. c2.Observe("src-test", "k1", alert.Alert{ID: "a1", SourceID: "src-test", DedupeKey: "k1"})
  175. if c2.Pending() != 1 {
  176. t.Fatalf("after Observe: Pending = %d, want 1", c2.Pending())
  177. }
  178. // Wire a fresh mux with c2 instead of c (the original).
  179. mux2 := http.NewServeMux()
  180. wireAdminRoutes(mux2, c2, state, discardLogger())
  181. req := httptest.NewRequest("POST", "/v1/admin/dedupe/flush", nil)
  182. req.Header.Set("Authorization", "Bearer "+tok)
  183. rr := httptest.NewRecorder()
  184. mux2.ServeHTTP(rr, req)
  185. if rr.Code != http.StatusOK {
  186. t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
  187. }
  188. var body map[string]any
  189. if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
  190. t.Fatalf("decode: %v", err)
  191. }
  192. if got, _ := body["pending_before"].(float64); int(got) != 1 {
  193. t.Errorf("pending_before = %v, want 1", body["pending_before"])
  194. }
  195. if flushed, _ := body["flushed"].(bool); !flushed {
  196. t.Errorf("flushed = %v, want true", body["flushed"])
  197. }
  198. }