189 lines
7.1 KiB
Go
189 lines
7.1 KiB
Go
package authz
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
func TestSurfaceCapabilities(t *testing.T) {
|
|
if Telegram.CanEmit("TaskCreated") || Ntfy.CanEmit("ApprovalRequested") {
|
|
t.Fatal("notify surface emitted an event")
|
|
}
|
|
if !TUI.CanEmit("TaskCreated") {
|
|
t.Fatal("control surface cannot emit")
|
|
}
|
|
if !MCP.CanEmit("ApprovalRequested") || MCP.CanEmit("TaskCreated") {
|
|
t.Fatal("mcp gate is wrong")
|
|
}
|
|
}
|
|
|
|
// TestSystemSurfaceDowngradedByHTTPMiddleware guards half of B8: System
|
|
// means "the plane itself, in-process" and is always FullControl with no
|
|
// token gate, since no deployment configures a token for a surface no HTTP
|
|
// caller is meant to use. HTTP() must never let a request pass through
|
|
// treated as System, or a caller declaring X-Orchestra-Surface: system gets
|
|
// an unconditional, unauthenticated bypass of the token check below it.
|
|
// (Downstream handlers must independently avoid re-deriving System from the
|
|
// raw header themselves — see cmd/orchestra/main.go's `surface` closure,
|
|
// which this package cannot test directly.)
|
|
func TestSystemSurfaceDowngradedByHTTPMiddleware(t *testing.T) {
|
|
tokens := map[Surface]string{System: "should-never-be-checked"}
|
|
h := HTTP(tokens, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/tasks/t1/complete", nil)
|
|
req.Header.Set("X-Orchestra-Surface", "system")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
// System's token check is intentionally skipped by HTTP() (it downgrades
|
|
// to Web before the token comparison), so the request reaching the
|
|
// handler at all is expected here — the guard that matters is that
|
|
// nothing downstream can observe "system" as the resolved surface. This
|
|
// test documents the middleware's half of the fix; main.go's `surface`
|
|
// closure carries the other half.
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected status %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// B18: the web UI is a full control plane. A session cookie must be an
|
|
// alternative *presentation* of the Web token, never a widening of it.
|
|
func TestWebSessionCookieGatesControlPathsOnly(t *testing.T) {
|
|
// An empty legacy Web bearer-token slot must not open the browser surface:
|
|
// providing Sessions means the caller needs a session cookie.
|
|
tokens := map[Surface]string{}
|
|
sessions := &Sessions{}
|
|
h := HTTPWithSessions(tokens, sessions, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
do := func(method, path string, c *http.Cookie, surface string) int {
|
|
req := httptest.NewRequest(method, path, nil)
|
|
if c != nil {
|
|
req.AddCookie(c)
|
|
}
|
|
if surface != "" {
|
|
req.Header.Set("X-Orchestra-Surface", surface)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
if got := do(http.MethodPost, "/v1/ui/tasks", nil, ""); got != http.StatusUnauthorized {
|
|
t.Fatalf("uncredentialed control path = %d, want 401", got)
|
|
}
|
|
// The SPA shell and the login endpoint must stay reachable, or no
|
|
// browser could ever obtain a cookie in the first place.
|
|
if got := do(http.MethodGet, "/index.html", nil, ""); got != http.StatusOK {
|
|
t.Fatalf("SPA shell = %d, want 200", got)
|
|
}
|
|
if got := do(http.MethodPost, SessionPath, nil, ""); got != http.StatusOK {
|
|
t.Fatalf("login endpoint = %d, want 200", got)
|
|
}
|
|
v, err := sessions.Issue()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: v}, ""); got != http.StatusOK {
|
|
t.Fatalf("session-cookie control path = %d, want 200", got)
|
|
}
|
|
if got := do(http.MethodPost, "/v1/ui/tasks", &http.Cookie{Name: SessionCookie, Value: "forged"}, ""); got != http.StatusUnauthorized {
|
|
t.Fatalf("forged cookie = %d, want 401", got)
|
|
}
|
|
// A cookie must not authenticate a non-browser surface.
|
|
tokens[TUI] = "tui-secret"
|
|
if got := do(http.MethodPost, "/v1/tasks", &http.Cookie{Name: SessionCookie, Value: v}, "tui"); got != http.StatusUnauthorized {
|
|
t.Fatalf("cookie on TUI surface = %d, want 401", got)
|
|
}
|
|
}
|
|
|
|
func TestWebCredentialsAuthenticate(t *testing.T) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("correct horse battery staple"), bcrypt.MinCost)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := WebCredentials{Username: "operator", PasswordHash: string(hash)}
|
|
if err := c.Validate(); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if !c.Authenticate("operator", "correct horse battery staple") {
|
|
t.Fatal("correct credentials rejected")
|
|
}
|
|
for _, attempt := range []struct{ username, password string }{{"operator", "wrong"}, {"other", "correct horse battery staple"}} {
|
|
if c.Authenticate(attempt.username, attempt.password) {
|
|
t.Fatalf("invalid credentials accepted: %+v", attempt)
|
|
}
|
|
}
|
|
if err := (WebCredentials{Username: "operator", PasswordHash: "not-a-bcrypt-hash"}).Validate(); err == nil {
|
|
t.Fatal("invalid bcrypt hash accepted")
|
|
}
|
|
}
|
|
|
|
func TestFederationRequestsUseTheirOwnCredentials(t *testing.T) {
|
|
tokens := map[Surface]string{Web: "web-secret"}
|
|
h := HTTPWithSessions(tokens, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
worker string
|
|
want int
|
|
}{
|
|
{name: "registration reaches admission handler", method: http.MethodPost, path: "/v1/federation/workers", want: http.StatusNoContent},
|
|
{name: "worker request reaches worker handler", method: http.MethodGet, path: "/v1/federation/events", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "worker task reconciliation reaches worker handler", method: http.MethodGet, path: "/v1/tasks", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "worker artifact read reaches worker handler", method: http.MethodGet, path: "/v1/artifacts/ref", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "worker artifact upload reaches worker handler", method: http.MethodPost, path: "/v1/artifacts", worker: "workpc-opencode", want: http.StatusNoContent},
|
|
{name: "unnamed worker request remains web gated", method: http.MethodGet, path: "/v1/federation/events", want: http.StatusUnauthorized},
|
|
{name: "worker list remains web gated", method: http.MethodGet, path: "/v1/federation/workers", want: http.StatusUnauthorized},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(tc.method, tc.path, nil)
|
|
if tc.worker != "" {
|
|
r.Header.Set("X-Orchestra-Worker", tc.worker)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != tc.want {
|
|
t.Fatalf("status = %d, want %d", w.Code, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionExpires(t *testing.T) {
|
|
s := &Sessions{TTL: time.Millisecond}
|
|
v, err := s.Issue()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
if s.Valid(v) {
|
|
t.Fatal("expired session accepted")
|
|
}
|
|
if s.Valid("") {
|
|
t.Fatal("empty session accepted")
|
|
}
|
|
}
|
|
|
|
func TestSessionRevoke(t *testing.T) {
|
|
s := &Sessions{}
|
|
v, err := s.Issue()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !s.Valid(v) {
|
|
t.Fatal("fresh session must be valid")
|
|
}
|
|
s.Revoke(v)
|
|
if s.Valid(v) {
|
|
t.Fatal("revoked session must not be valid")
|
|
}
|
|
}
|