main_test.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. // Tests for the M13a W3 JWT gate in admind. We test the gate wiring
  2. // (which routes are protected, which roles are allowed) in
  3. // isolation. The real handlers (handleListDLQ etc.) need a real
  4. // pool + broker; those live in their own integration tests.
  5. package main
  6. import (
  7. "crypto/hmac"
  8. "crypto/sha256"
  9. "encoding/base64"
  10. "encoding/json"
  11. "io"
  12. "log/slog"
  13. "net/http"
  14. "net/http/httptest"
  15. "os"
  16. "testing"
  17. "time"
  18. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  19. )
  20. // newStubAuthd builds an Authd that only validates tokens (no pool
  21. // needed; we never issue).
  22. func newStubAuthd(t *testing.T, secret string) *authd.Authd {
  23. t.Helper()
  24. a, err := authd.New(nil, authd.Config{
  25. JWTSecret: []byte(secret),
  26. Issuer: "broad-announce",
  27. AccessTokenTTL: 1 * time.Minute,
  28. })
  29. if err != nil {
  30. t.Fatalf("authd.New: %v", err)
  31. }
  32. return a
  33. }
  34. func mintTestJWT(t *testing.T, secret, role string) string {
  35. t.Helper()
  36. hb, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
  37. body, _ := json.Marshal(map[string]any{
  38. "sub": "u-test",
  39. "tid": "t-test",
  40. "role": role,
  41. "typ": "access",
  42. "iss": "broad-announce",
  43. "exp": time.Now().Add(15 * time.Minute).Unix(),
  44. "iat": time.Now().Unix(),
  45. })
  46. enc := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(body)
  47. mac := hmac.New(sha256.New, []byte(secret))
  48. mac.Write([]byte(enc))
  49. sig := mac.Sum(nil)
  50. return enc + "." + base64.RawURLEncoding.EncodeToString(sig)
  51. }
  52. func reqWithToken(method, path, token string) *http.Request {
  53. r := httptest.NewRequest(method, path, nil)
  54. if token != "" {
  55. r.Header.Set("Authorization", "Bearer "+token)
  56. }
  57. return r
  58. }
  59. func discardLogger() *slog.Logger {
  60. return slog.New(slog.NewTextHandler(io.Discard, nil))
  61. }
  62. // safeServeHTTP runs mux.ServeHTTP in a recover() to catch panics
  63. // from handlers that need real pool/broker. Returns the status
  64. // (or 0 if the handler panicked — which is fine for our purposes,
  65. // the test only cares about NOT being 401).
  66. func safeServeHTTP(t *testing.T, mux http.Handler, r *http.Request) int {
  67. t.Helper()
  68. rr := httptest.NewRecorder()
  69. done := make(chan struct{})
  70. go func() {
  71. defer close(done)
  72. defer func() {
  73. if rec := recover(); rec != nil {
  74. // expected: handlers panic with nil pool
  75. }
  76. }()
  77. mux.ServeHTTP(rr, r)
  78. }()
  79. <-done
  80. return rr.Code
  81. }
  82. // TestWireDLQRoutes_NoSecret_Unauthenticated verifies that with
  83. // BA_AUTHD_JWT_SECRET unset, the routes accept any request (the
  84. // M8 backward-compatible behavior).
  85. func TestWireDLQRoutes_NoSecret_Unauthenticated(t *testing.T) {
  86. os.Unsetenv("BA_AUTHD_JWT_SECRET")
  87. mux := http.NewServeMux()
  88. logger := discardLogger()
  89. wireDLQRoutes(mux, nil, nil, logger)
  90. // Hit each route without a token — the real handlers panic
  91. // (pool is nil) but the test only cares about NOT being 401.
  92. for _, tc := range []struct{ method, path string }{
  93. {"GET", "/v1/dlq"},
  94. {"GET", "/v1/dlq/1"},
  95. {"POST", "/v1/dlq/1/replay"},
  96. {"POST", "/v1/dlq/1/discard"},
  97. } {
  98. code := safeServeHTTP(t, mux, httptest.NewRequest(tc.method, tc.path, nil))
  99. if code == http.StatusUnauthorized {
  100. t.Errorf("%s %s with no secret: got 401, want open route (panic/500 OK)", tc.method, tc.path)
  101. }
  102. }
  103. }
  104. // TestWireDLQRoutes_WithSecret_Gated verifies that with the secret
  105. // set, all /v1/dlq* routes are 401 without a token.
  106. func TestWireDLQRoutes_WithSecret_Gated(t *testing.T) {
  107. const secret = "test-secret-with-32-bytes-min-len-abc"
  108. t.Setenv("BA_AUTHD_JWT_SECRET", secret)
  109. t.Setenv("BA_AUTHD_ISSUER", "broad-announce")
  110. mux := http.NewServeMux()
  111. logger := discardLogger()
  112. wireDLQRoutes(mux, nil, nil, logger)
  113. for _, tc := range []struct{ method, path string }{
  114. {"GET", "/v1/dlq"},
  115. {"GET", "/v1/dlq/1"},
  116. {"POST", "/v1/dlq/1/replay"},
  117. {"POST", "/v1/dlq/1/discard"},
  118. } {
  119. rr := httptest.NewRecorder()
  120. mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
  121. if rr.Code != http.StatusUnauthorized {
  122. t.Errorf("%s %s with secret+no-token: status = %d, want 401", tc.method, tc.path, rr.Code)
  123. }
  124. }
  125. }
  126. // TestDLQGate_RolePolicy verifies the read/destructive split:
  127. // - GET /v1/dlq* accept any authenticated user
  128. // - POST /v1/dlq/*/{replay,discard} require admin role
  129. func TestDLQGate_RolePolicy(t *testing.T) {
  130. const secret = "test-secret-with-32-bytes-min-len-abc"
  131. a := newStubAuthd(t, secret)
  132. mux := http.NewServeMux()
  133. ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
  134. mux.Handle("GET /v1/dlq", a.RequireAuth(ok))
  135. mux.Handle("GET /v1/dlq/{id}", a.RequireAuth(ok))
  136. mux.Handle("POST /v1/dlq/{id}/replay", a.RequireRole("super_admin", "tenant_admin")(ok))
  137. mux.Handle("POST /v1/dlq/{id}/discard", a.RequireRole("super_admin", "tenant_admin")(ok))
  138. cases := []struct {
  139. name string
  140. role string
  141. method string
  142. path string
  143. want int
  144. }{
  145. // viewer can list
  146. {"viewer-list", "viewer", "GET", "/v1/dlq", 200},
  147. {"viewer-get", "viewer", "GET", "/v1/dlq/1", 200},
  148. // viewer CANNOT replay/discard
  149. {"viewer-replay", "viewer", "POST", "/v1/dlq/1/replay", 403},
  150. {"viewer-discard", "viewer", "POST", "/v1/dlq/1/discard", 403},
  151. // tenant_admin can do everything
  152. {"ta-list", "tenant_admin", "GET", "/v1/dlq", 200},
  153. {"ta-replay", "tenant_admin", "POST", "/v1/dlq/1/replay", 200},
  154. {"ta-discard", "tenant_admin", "POST", "/v1/dlq/1/discard", 200},
  155. // super_admin can do everything
  156. {"sa-replay", "super_admin", "POST", "/v1/dlq/1/replay", 200},
  157. {"sa-discard", "super_admin", "POST", "/v1/dlq/1/discard", 200},
  158. }
  159. for _, tc := range cases {
  160. t.Run(tc.name, func(t *testing.T) {
  161. tok := mintTestJWT(t, secret, tc.role)
  162. rr := httptest.NewRecorder()
  163. mux.ServeHTTP(rr, reqWithToken(tc.method, tc.path, tok))
  164. if rr.Code != tc.want {
  165. t.Errorf("%s %s as %s: status = %d, want %d (body: %s)",
  166. tc.method, tc.path, tc.role, rr.Code, tc.want, rr.Body.String())
  167. }
  168. })
  169. }
  170. }
  171. // TestEnvEnabled verifies the helper that main() uses to decide
  172. // whether to wire the gate.
  173. func TestEnvEnabled(t *testing.T) {
  174. os.Unsetenv("BA_AUTHD_JWT_SECRET")
  175. if authd.EnvEnabled() {
  176. t.Error("EnvEnabled: unset secret, got true, want false")
  177. }
  178. t.Setenv("BA_AUTHD_JWT_SECRET", "x")
  179. if !authd.EnvEnabled() {
  180. t.Error("EnvEnabled: set secret, got false, want true")
  181. }
  182. }