mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
326 lines
12 KiB
Go
326 lines
12 KiB
Go
package middleware
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// TestDaemonAuth_DaemonTokenCacheHit pins the daemon-token cache short-circuit:
|
|
// when the cache holds an entry for an mdt_ token, DaemonAuth must skip the DB
|
|
// lookup. nil queries would otherwise nil-deref on a miss.
|
|
func TestDaemonAuth_DaemonTokenCacheHit(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
cache := auth.NewDaemonTokenCache(rdb)
|
|
if cache == nil {
|
|
t.Fatal("expected non-nil cache")
|
|
}
|
|
|
|
const rawToken = "mdt_cache_hit_test_token"
|
|
hash := auth.HashToken(rawToken)
|
|
cache.Set(context.Background(), hash, auth.DaemonTokenIdentity{
|
|
WorkspaceID: "ws-cached",
|
|
DaemonID: "daemon-cached",
|
|
}, auth.AuthCacheTTL)
|
|
|
|
var gotWS, gotDaemon, gotPath string
|
|
mw := DaemonAuth(nil, nil, cache, nil) // nil queries — only safe on cache hit
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotWS = DaemonWorkspaceIDFromContext(r.Context())
|
|
gotDaemon = DaemonIDFromContext(r.Context())
|
|
gotPath = DaemonAuthPathFromContext(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer "+rawToken)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 on cache hit, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if gotWS != "ws-cached" || gotDaemon != "daemon-cached" {
|
|
t.Fatalf("expected (ws-cached, daemon-cached), got (%q, %q)", gotWS, gotDaemon)
|
|
}
|
|
if gotPath != DaemonAuthPathDaemonToken {
|
|
t.Fatalf("expected auth path %q, got %q", DaemonAuthPathDaemonToken, gotPath)
|
|
}
|
|
}
|
|
|
|
// TestDaemonAuth_PATCacheHit pins the PAT-fallback short-circuit. Production
|
|
// daemon traffic today uses mul_ PATs (mdt_ minting isn't wired up yet), so
|
|
// this is the cache hit that actually matters for /api/daemon/* DB load.
|
|
func TestDaemonAuth_PATCacheHit(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
cache := auth.NewPATCache(rdb)
|
|
if cache == nil {
|
|
t.Fatal("expected non-nil cache")
|
|
}
|
|
|
|
const rawToken = "mul_daemon_pat_cache_hit_test"
|
|
hash := auth.HashToken(rawToken)
|
|
cache.Set(context.Background(), hash, "cached-user-id", auth.AuthCacheTTL)
|
|
|
|
var gotUserID, gotPath string
|
|
mw := DaemonAuth(nil, cache, nil, nil)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotUserID = r.Header.Get("X-User-ID")
|
|
gotPath = DaemonAuthPathFromContext(r.Context())
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer "+rawToken)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if gotUserID != "cached-user-id" {
|
|
t.Fatalf("expected cached X-User-ID, got %q", gotUserID)
|
|
}
|
|
if gotPath != DaemonAuthPathPAT {
|
|
t.Fatalf("expected auth path %q, got %q", DaemonAuthPathPAT, gotPath)
|
|
}
|
|
}
|
|
|
|
func TestDaemonAuth_MissingAuth(t *testing.T) {
|
|
mw := DaemonAuth(nil, nil, nil, nil)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next must not be called")
|
|
}))
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestDaemonAuth_StripsClientSuppliedActorSource mirrors the
|
|
// TestAuth_StripsClientSuppliedActorSource invariant for the daemon
|
|
// auth path: a client supplying X-Actor-Source must NOT leak that
|
|
// header through to the handler. Required for parity between the
|
|
// two middlewares — the regular Auth path strips at the top, and we
|
|
// added the same strip in DaemonAuth so account-level guards (e.g.
|
|
// handler.RequireHumanActor) can trust the header regardless of
|
|
// which auth chain a request arrived on.
|
|
//
|
|
// We exercise an mdt_ token with an attempted forged X-Actor-Source.
|
|
// On the mdt_ path no actor-source stamp is added (daemon tokens
|
|
// aren't a "machine credential" in the billing sense — they're a
|
|
// runtime-bound proof for the daemon API itself), so a clean strip
|
|
// leaves the header empty downstream.
|
|
func TestDaemonAuth_StripsClientSuppliedActorSource(t *testing.T) {
|
|
rdb := newRedisTestClient(t)
|
|
cache := auth.NewDaemonTokenCache(rdb)
|
|
|
|
const rawToken = "mdt_strip_test"
|
|
hash := auth.HashToken(rawToken)
|
|
cache.Set(context.Background(), hash, auth.DaemonTokenIdentity{
|
|
WorkspaceID: "ws-1",
|
|
DaemonID: "daemon-1",
|
|
}, auth.AuthCacheTTL)
|
|
|
|
var gotActorSource string
|
|
mw := DaemonAuth(nil, nil, cache, nil)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotActorSource = r.Header.Get("X-Actor-Source")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer "+rawToken)
|
|
// Forged value the client tries to smuggle in.
|
|
req.Header.Set("X-Actor-Source", "cloud_pat")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if gotActorSource != "" {
|
|
t.Fatalf("X-Actor-Source must be cleared on the mdt_ path, got %q", gotActorSource)
|
|
}
|
|
}
|
|
|
|
func TestDaemonAuth_InvalidMDT_NilQueries(t *testing.T) {
|
|
mw := DaemonAuth(nil, nil, nil, nil) // no caches, no DB
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next must not be called")
|
|
}))
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer mdt_unknown")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestDaemonAuth_MCN_NoVerifierConfigured pins the fail-closed
|
|
// behaviour when MULTICA_CLOUD_FLEET_URL is empty: an mcn_ token MUST
|
|
// be rejected at the prefix branch with 401, not silently fall
|
|
// through to the mul_/JWT paths (an mcn_ string would never match a
|
|
// valid PAT or JWT, but failing closed makes the contract explicit).
|
|
func TestDaemonAuth_MCN_NoVerifierConfigured(t *testing.T) {
|
|
mw := DaemonAuth(nil, nil, nil, nil)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next must not be called when verifier is unconfigured")
|
|
}))
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer mcn_anything")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 with no verifier, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestDaemonAuth_MCN_ValidTokenSetsUserID confirms that on a successful
|
|
// Fleet verify, DaemonAuth surfaces owner_id as X-User-ID and tags the
|
|
// auth path as cloud_pat for telemetry. We use a stub Fleet here
|
|
// (no Redis) so the test runs without external services.
|
|
func TestDaemonAuth_MCN_ValidTokenSetsUserID(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"valid": true,
|
|
"owner_id": "01972f7e-7e8d-77ef-a13d-1b0ce3e9c001",
|
|
"instance_id": "i-01"
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
|
|
|
|
var gotUser, gotPath, gotActorSource string
|
|
mw := DaemonAuth(nil, nil, nil, verifier)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotUser = r.Header.Get("X-User-ID")
|
|
gotPath = DaemonAuthPathFromContext(r.Context())
|
|
gotActorSource = r.Header.Get("X-Actor-Source")
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer mcn_some_token")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if gotUser != "01972f7e-7e8d-77ef-a13d-1b0ce3e9c001" {
|
|
t.Errorf("expected owner_id propagated as X-User-ID, got %q", gotUser)
|
|
}
|
|
if gotPath != DaemonAuthPathCloudPAT {
|
|
t.Errorf("expected auth path %q, got %q", DaemonAuthPathCloudPAT, gotPath)
|
|
}
|
|
// Mirror the regular Auth middleware's stamp. Daemon routes don't
|
|
// currently sit behind RequireHumanActor, but we want the two
|
|
// auth paths to behave identically on this header so an endpoint
|
|
// that ever moves between them, or shares both, can't be tricked
|
|
// into thinking an mcn_ caller is human.
|
|
if gotActorSource != "cloud_pat" {
|
|
t.Errorf("expected X-Actor-Source=cloud_pat, got %q", gotActorSource)
|
|
}
|
|
}
|
|
|
|
// TestDaemonAuth_MCN_FleetSaysInvalid confirms that a valid:false
|
|
// Fleet response maps to 401 (not 503) — the token IS known to be bad,
|
|
// retrying won't help.
|
|
func TestDaemonAuth_MCN_FleetSaysInvalid(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"valid":false,"reason":"token_revoked"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
|
|
mw := DaemonAuth(nil, nil, nil, verifier)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next must not be called when fleet says invalid")
|
|
}))
|
|
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer mcn_revoked")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 for invalid token, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestDaemonAuth_MCN_FleetUnreachable confirms the Unavailable branch
|
|
// emits 503 — the daemon must distinguish "your token is bad" (401, drop
|
|
// it) from "cloud is down" (503, retry later) so a brief outage doesn't
|
|
// invalidate everyone's PAT.
|
|
func TestDaemonAuth_MCN_FleetUnreachable(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
|
|
mw := DaemonAuth(nil, nil, nil, verifier)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next must not be called when fleet is unavailable")
|
|
}))
|
|
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer mcn_x")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 when fleet is unavailable, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
|
|
// TestDaemonAuth_MCN_OwnerNotInLocalDB pins the new owner-existence
|
|
// guard end-to-end through the middleware. Cloud verifies the token
|
|
// successfully and returns an owner_id that does not exist in our
|
|
// local user table — DaemonAuth must reject with 401 (not 503) and
|
|
// MUST NOT call the next handler with a phantom X-User-ID.
|
|
func TestDaemonAuth_MCN_OwnerNotInLocalDB(t *testing.T) {
|
|
pool := openPool(t)
|
|
defer pool.Close()
|
|
queries := db.New(pool)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Owner_id is syntactically valid but not seeded in the DB.
|
|
_, _ = w.Write([]byte(`{
|
|
"valid": true,
|
|
"owner_id": "00000000-0000-0000-0000-0000000feed1",
|
|
"instance_id": "i-99"
|
|
}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
verifier := auth.NewCloudPATVerifier(auth.CloudPATVerifierConfig{FleetBaseURL: srv.URL})
|
|
|
|
mw := DaemonAuth(queries, nil, nil, verifier)
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next must not be called when owner_id has no local user")
|
|
}))
|
|
|
|
req := httptest.NewRequest("POST", "/api/daemon/heartbeat", nil)
|
|
req.Header.Set("Authorization", "Bearer mcn_phantom_owner")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 when local user is missing, got %d", w.Code)
|
|
}
|
|
}
|