package mcpauth import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "git.k6n.net/mats/platform/auth" ) // freshAuthz resets the package-level singleton so each test can configure // its own env. buildAuthz is sync.Once'd, so tests mutate the env BEFORE the // first Middleware() call. func resetAuthz(t *testing.T) { t.Helper() for _, k := range []string{"AUTH_APIKEYS_FILE", "AUTH_TOKEN_SECRET", "MCP_API_KEY"} { _ = os.Unsetenv(k) } authzOnce = sync.Once{} current = nil t.Cleanup(func() { authzOnce = sync.Once{} current = nil }) } func doReq(t *testing.T, r *http.Request) int { t.Helper() rr := httptest.NewRecorder() Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })).ServeHTTP(rr, r) return rr.Code } func TestNoSourceConfigured(t *testing.T) { resetAuthz(t) r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) if code := doReq(t, r); code != http.StatusServiceUnavailable { t.Fatalf("no source configured: got %d, want 503", code) } } func TestMintedKeyRequiresMCPOrAdminScope(t *testing.T) { resetAuthz(t) path := filepath.Join(t.TempDir(), "apikeys.json") ks, err := auth.OpenKeyStore(path) if err != nil { t.Fatal(err) } // Keys with mcp / admin scopes validate. mcpKey, _, err := ks.Mint("companion", []string{"mcp"}) if err != nil { t.Fatal(err) } adminKey, _, err := ks.Mint("ops", []string{"admin"}) if err != nil { t.Fatal(err) } // A read-only key must NOT validate against MCP surfaces. readKey, _, err := ks.Mint("reader", []string{"read"}) if err != nil { t.Fatal(err) } _ = os.Setenv("AUTH_APIKEYS_FILE", path) cases := []struct { name string key string want int }{ {"mcp scope", mcpKey, http.StatusOK}, {"admin scope", adminKey, http.StatusOK}, {"read-only rejected", readKey, http.StatusUnauthorized}, {"garbage rejected", "gck_bogus", http.StatusUnauthorized}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r.Header.Set("X-API-Key", tc.key) if code := doReq(t, r); code != tc.want { t.Fatalf("got %d, want %d", code, tc.want) } }) } } func TestKeyStoreReloadPicksUpNewKeys(t *testing.T) { resetAuthz(t) path := filepath.Join(t.TempDir(), "apikeys.json") ks, err := auth.OpenKeyStore(path) if err != nil { t.Fatal(err) } firstKey, _, err := ks.Mint("a", []string{"mcp"}) if err != nil { t.Fatal(err) } _ = os.Setenv("AUTH_APIKEYS_FILE", path) r := httptest.NewRequest(http.MethodGet, "/order-mcp", nil) r.Header.Set("X-API-Key", firstKey) if code := doReq(t, r); code != http.StatusOK { t.Fatalf("first key should validate: got %d", code) } // A second key minted into the same file AFTER the middleware loaded // must validate via the mtime-based reload. secondKey, _, err := ks.Mint("b", []string{"mcp"}) if err != nil { t.Fatal(err) } r2 := httptest.NewRequest(http.MethodGet, "/order-mcp", nil) r2.Header.Set("X-API-Key", secondKey) if code := doReq(t, r2); code != http.StatusOK { t.Fatalf("newly minted key should validate after reload: got %d", code) } } func TestDeviceToken(t *testing.T) { resetAuthz(t) _ = os.Setenv("AUTH_TOKEN_SECRET", "test-secret") jwt := auth.NewLocalJWT("test-secret") tok, err := jwt.Mint("user-1", []string{"mcp"}, 0) // ttl 0 → exp now (still valid) if err != nil { t.Fatal(err) } r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r.Header.Set("Authorization", "Bearer "+tok) if code := doReq(t, r); code != http.StatusOK { t.Fatalf("device token with mcp scope: got %d, want 200", code) } // A device token without mcp/admin scope must be rejected. tokNoScope, err := jwt.Mint("user-2", []string{"read"}, 0) if err != nil { t.Fatal(err) } r2 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r2.Header.Set("Authorization", "Bearer "+tokNoScope) if code := doReq(t, r2); code != http.StatusUnauthorized { t.Fatalf("device token without mcp scope: got %d, want 401", code) } // Garbage bearer with AUTH_TOKEN_SECRET set must be 401 (not 503). r3 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r3.Header.Set("Authorization", "Bearer not.a.jwt") if code := doReq(t, r3); code != http.StatusUnauthorized { t.Fatalf("garbage token: got %d, want 401", code) } } func TestLegacyKey(t *testing.T) { resetAuthz(t) _ = os.Setenv("MCP_API_KEY", "devkey") for _, hdr := range []string{"X-API-Key", "Authorization"} { r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) if hdr == "Authorization" { r.Header.Set(hdr, "Bearer devkey") } else { r.Header.Set(hdr, "devkey") } if code := doReq(t, r); code != http.StatusOK { t.Fatalf("legacy key via %s: got %d, want 200", hdr, code) } } r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r.Header.Set("X-API-Key", "wrong") if code := doReq(t, r); code != http.StatusUnauthorized { t.Fatalf("wrong legacy key: got %d, want 401", code) } } func TestSourcesCompose(t *testing.T) { resetAuthz(t) path := filepath.Join(t.TempDir(), "apikeys.json") ks, err := auth.OpenKeyStore(path) if err != nil { t.Fatal(err) } key, _, err := ks.Mint("companion", []string{"mcp"}) if err != nil { t.Fatal(err) } _ = os.Setenv("AUTH_APIKEYS_FILE", path) _ = os.Setenv("AUTH_TOKEN_SECRET", "test-secret") _ = os.Setenv("MCP_API_KEY", "devkey") // Minted key wins. r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r.Header.Set("X-API-Key", key) if code := doReq(t, r); code != http.StatusOK { t.Fatalf("minted key: got %d, want 200", code) } // Device token works alongside. jwt := auth.NewLocalJWT("test-secret") tok, err := jwt.Mint("u", []string{"admin"}, 0) if err != nil { t.Fatal(err) } r2 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r2.Header.Set("Authorization", "Bearer "+tok) if code := doReq(t, r2); code != http.StatusOK { t.Fatalf("device token: got %d, want 200", code) } // Legacy key still works. r3 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) r3.Header.Set("X-API-Key", "devkey") if code := doReq(t, r3); code != http.StatusOK { t.Fatalf("legacy key: got %d, want 200", code) } // Without credentials → 401. r4 := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) if code := doReq(t, r4); code != http.StatusUnauthorized { t.Fatalf("no creds: got %d, want 401", code) } } // Ensure the fail-closed behavior matches the documented contract even when // the env contains empty-but-set values. func TestEmptyEnvFallsClosed(t *testing.T) { resetAuthz(t) _ = os.Setenv("AUTH_APIKEYS_FILE", strings.Repeat(" ", 0)) r := httptest.NewRequest(http.MethodGet, "/cart-mcp", nil) if code := doReq(t, r); code != http.StatusServiceUnavailable { t.Fatalf("empty env should fall closed: got %d, want 503", code) } }