| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199 |
- // Tests for the M13a W3 JWT gate in admind. We test the gate wiring
- // (which routes are protected, which roles are allowed) in
- // isolation. The real handlers (handleListDLQ etc.) need a real
- // pool + broker; those live in their own integration tests.
- 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/authd"
- )
- // newStubAuthd builds an Authd that only validates tokens (no pool
- // needed; we never issue).
- func newStubAuthd(t *testing.T, secret string) *authd.Authd {
- t.Helper()
- a, err := authd.New(nil, authd.Config{
- JWTSecret: []byte(secret),
- Issuer: "broad-announce",
- AccessTokenTTL: 1 * time.Minute,
- })
- if err != nil {
- t.Fatalf("authd.New: %v", err)
- }
- return a
- }
- 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)
- }
- func reqWithToken(method, path, token string) *http.Request {
- r := httptest.NewRequest(method, path, nil)
- if token != "" {
- r.Header.Set("Authorization", "Bearer "+token)
- }
- return r
- }
- func discardLogger() *slog.Logger {
- return slog.New(slog.NewTextHandler(io.Discard, nil))
- }
- // safeServeHTTP runs mux.ServeHTTP in a recover() to catch panics
- // from handlers that need real pool/broker. Returns the status
- // (or 0 if the handler panicked — which is fine for our purposes,
- // the test only cares about NOT being 401).
- func safeServeHTTP(t *testing.T, mux http.Handler, r *http.Request) int {
- t.Helper()
- rr := httptest.NewRecorder()
- done := make(chan struct{})
- go func() {
- defer close(done)
- defer func() {
- if rec := recover(); rec != nil {
- // expected: handlers panic with nil pool
- }
- }()
- mux.ServeHTTP(rr, r)
- }()
- <-done
- return rr.Code
- }
- // TestWireDLQRoutes_NoSecret_Unauthenticated verifies that with
- // BA_AUTHD_JWT_SECRET unset, the routes accept any request (the
- // M8 backward-compatible behavior).
- func TestWireDLQRoutes_NoSecret_Unauthenticated(t *testing.T) {
- os.Unsetenv("BA_AUTHD_JWT_SECRET")
- mux := http.NewServeMux()
- logger := discardLogger()
- wireDLQRoutes(mux, nil, nil, logger)
- // Hit each route without a token — the real handlers panic
- // (pool is nil) but the test only cares about NOT being 401.
- for _, tc := range []struct{ method, path string }{
- {"GET", "/v1/dlq"},
- {"GET", "/v1/dlq/1"},
- {"POST", "/v1/dlq/1/replay"},
- {"POST", "/v1/dlq/1/discard"},
- } {
- code := safeServeHTTP(t, mux, httptest.NewRequest(tc.method, tc.path, nil))
- if code == http.StatusUnauthorized {
- t.Errorf("%s %s with no secret: got 401, want open route (panic/500 OK)", tc.method, tc.path)
- }
- }
- }
- // TestWireDLQRoutes_WithSecret_Gated verifies that with the secret
- // set, all /v1/dlq* routes are 401 without a token.
- func TestWireDLQRoutes_WithSecret_Gated(t *testing.T) {
- const secret = "test-secret-with-32-bytes-min-len-abc"
- t.Setenv("BA_AUTHD_JWT_SECRET", secret)
- t.Setenv("BA_AUTHD_ISSUER", "broad-announce")
- mux := http.NewServeMux()
- logger := discardLogger()
- wireDLQRoutes(mux, nil, nil, logger)
- for _, tc := range []struct{ method, path string }{
- {"GET", "/v1/dlq"},
- {"GET", "/v1/dlq/1"},
- {"POST", "/v1/dlq/1/replay"},
- {"POST", "/v1/dlq/1/discard"},
- } {
- 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)
- }
- }
- }
- // TestDLQGate_RolePolicy verifies the read/destructive split:
- // - GET /v1/dlq* accept any authenticated user
- // - POST /v1/dlq/*/{replay,discard} require admin role
- func TestDLQGate_RolePolicy(t *testing.T) {
- const secret = "test-secret-with-32-bytes-min-len-abc"
- a := newStubAuthd(t, secret)
- mux := http.NewServeMux()
- ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
- mux.Handle("GET /v1/dlq", a.RequireAuth(ok))
- mux.Handle("GET /v1/dlq/{id}", a.RequireAuth(ok))
- mux.Handle("POST /v1/dlq/{id}/replay", a.RequireRole("super_admin", "tenant_admin")(ok))
- mux.Handle("POST /v1/dlq/{id}/discard", a.RequireRole("super_admin", "tenant_admin")(ok))
- cases := []struct {
- name string
- role string
- method string
- path string
- want int
- }{
- // viewer can list
- {"viewer-list", "viewer", "GET", "/v1/dlq", 200},
- {"viewer-get", "viewer", "GET", "/v1/dlq/1", 200},
- // viewer CANNOT replay/discard
- {"viewer-replay", "viewer", "POST", "/v1/dlq/1/replay", 403},
- {"viewer-discard", "viewer", "POST", "/v1/dlq/1/discard", 403},
- // tenant_admin can do everything
- {"ta-list", "tenant_admin", "GET", "/v1/dlq", 200},
- {"ta-replay", "tenant_admin", "POST", "/v1/dlq/1/replay", 200},
- {"ta-discard", "tenant_admin", "POST", "/v1/dlq/1/discard", 200},
- // super_admin can do everything
- {"sa-replay", "super_admin", "POST", "/v1/dlq/1/replay", 200},
- {"sa-discard", "super_admin", "POST", "/v1/dlq/1/discard", 200},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- tok := mintTestJWT(t, secret, tc.role)
- rr := httptest.NewRecorder()
- mux.ServeHTTP(rr, reqWithToken(tc.method, tc.path, tok))
- 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())
- }
- })
- }
- }
- // TestEnvEnabled verifies the helper that main() uses to decide
- // whether to wire the gate.
- func TestEnvEnabled(t *testing.T) {
- os.Unsetenv("BA_AUTHD_JWT_SECRET")
- if authd.EnvEnabled() {
- t.Error("EnvEnabled: unset secret, got true, want false")
- }
- t.Setenv("BA_AUTHD_JWT_SECRET", "x")
- if !authd.EnvEnabled() {
- t.Error("EnvEnabled: set secret, got false, want true")
- }
- }
|