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