mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
163 lines
6.0 KiB
Go
163 lines
6.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// TestRequireHumanActor_AllowsHumanRequest pins the happy path: a
|
|
// request that passed Auth as a JWT or mul_ PAT does NOT carry
|
|
// X-Actor-Source, so the guard lets it through and the inner handler
|
|
// runs.
|
|
//
|
|
// We construct a bare http.Handler chain (no full router) so the test
|
|
// exercises only the middleware logic and is independent of any
|
|
// downstream wiring.
|
|
func TestRequireHumanActor_AllowsHumanRequest(t *testing.T) {
|
|
called := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
mw := RequireHumanActor(next)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/cloud-billing/balance", nil)
|
|
// No X-Actor-Source — this is the JWT / mul_ PAT shape.
|
|
w := httptest.NewRecorder()
|
|
mw.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", w.Code)
|
|
}
|
|
if !called {
|
|
t.Fatal("inner handler must run for non-task-token requests")
|
|
}
|
|
}
|
|
|
|
// TestRequireHumanActor_BlocksMachineCredentials walks every machine-
|
|
// credential X-Actor-Source value the auth middlewares stamp today
|
|
// and confirms each is rejected with 403. The two values must stay
|
|
// in lockstep with auth.go and daemon_auth.go: a new machine
|
|
// credential added there without a corresponding case here would
|
|
// silently grant agents/nodes account-level access.
|
|
func TestRequireHumanActor_BlocksMachineCredentials(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
actorSource string
|
|
}{
|
|
// mat_ task token — set in middleware/auth.go's mat_ branch.
|
|
// An agent process holding its task-scoped token must not be
|
|
// able to read its owner's billing data.
|
|
{name: "task_token", actorSource: "task_token"},
|
|
// mcn_ cloud-node PAT — set in BOTH middleware/auth.go and
|
|
// middleware/daemon_auth.go's mcn_ branches. A cloud-runtime
|
|
// EC2 node operating on the owner's behalf is the same kind
|
|
// of machine credential as mat_ for billing-authorization
|
|
// purposes.
|
|
{name: "cloud_pat", actorSource: "cloud_pat"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
t.Fatalf("inner handler must NOT run for actor source %q", tc.actorSource)
|
|
})
|
|
mw := RequireHumanActor(next)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/cloud-billing/balance", nil)
|
|
// This is what the Auth (or DaemonAuth) middleware sets
|
|
// for the matching token kind. Setting it directly here
|
|
// proves the gate triggers on the header regardless of
|
|
// upstream context — the auth middlewares strip any
|
|
// client-supplied value before stamping their own, so a
|
|
// non-empty value at this point IS authoritative.
|
|
req.Header.Set("X-Actor-Source", tc.actorSource)
|
|
w := httptest.NewRecorder()
|
|
mw.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", w.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRequireHumanActor_IgnoresUnknownActorSource pins the gate's
|
|
// scope: it is an explicit denylist against the known-bad
|
|
// "task_token" value, NOT an allowlist against "human only / empty".
|
|
//
|
|
// Why the denylist shape:
|
|
//
|
|
// - The Auth middleware today sets X-Actor-Source for exactly one
|
|
// case: mat_ task tokens. Every other authenticated path (JWT,
|
|
// mul_ PAT) leaves the header empty. So "non-empty AND not
|
|
// task_token" is unreachable in current production.
|
|
//
|
|
// - If a future actor kind is added (say a hypothetical
|
|
// `service_account` token), this gate's silence on the new value
|
|
// is a CONSCIOUS DECISION POINT, not an accident. The added auth
|
|
// branch is the right place to decide whether the new kind should
|
|
// be allowed at billing endpoints — and that decision belongs in
|
|
// a security review at the time, not in a default-deny rule here
|
|
// that pre-emptively shuts out hypothetical use cases we cannot
|
|
// reason about today.
|
|
//
|
|
// If you are reading this comment because a new actor kind needs to
|
|
// reach billing or needs to be blocked from it, update
|
|
// RequireHumanActor to handle the new kind explicitly (and update
|
|
// this test's expectation accordingly).
|
|
func TestRequireHumanActor_IgnoresUnknownActorSource(t *testing.T) {
|
|
called := false
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
called = true
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
mw := RequireHumanActor(next)
|
|
|
|
// A hypothetical future value the gate doesn't know about.
|
|
req := httptest.NewRequest(http.MethodGet, "/api/cloud-billing/balance", nil)
|
|
req.Header.Set("X-Actor-Source", "future_kind")
|
|
w := httptest.NewRecorder()
|
|
mw.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 — gate should only block exact 'task_token'", w.Code)
|
|
}
|
|
if !called {
|
|
t.Fatal("inner handler must run for unknown actor sources")
|
|
}
|
|
}
|
|
|
|
|
|
// TestRequireHumanActor_AppliedViaChiRouterUse pins the wiring side of
|
|
// the contract: when the guard is attached to a chi route group via
|
|
// r.Use, every endpoint in that group is protected, and a task-token
|
|
// request never reaches the handler — even one we add later. This is
|
|
// what router.go's r.Route("/api/cloud-billing", ...) + r.Use(...)
|
|
// guarantees in production; the test is small but a developer adding
|
|
// a new billing endpoint and forgetting to re-attach the middleware
|
|
// would not be caught by the per-handler tests above.
|
|
func TestRequireHumanActor_AppliedViaChiRouterUse(t *testing.T) {
|
|
// Use a real chi router so we exercise r.Use(), not just the
|
|
// middleware function in isolation.
|
|
r := chi.NewRouter()
|
|
r.Use(RequireHumanActor)
|
|
r.Get("/billing/probe", func(_ http.ResponseWriter, _ *http.Request) {
|
|
t.Fatal("inner handler must NOT run when guard rejects")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/billing/probe", nil)
|
|
req.Header.Set("X-Actor-Source", "task_token")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403", w.Code)
|
|
}
|
|
}
|