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