package server import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "chat-agent-system/internal/bizdev" "chat-agent-system/internal/config" ) func TestP0_AnonymousBusinessAPIReturns401(t *testing.T) { cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} srv := newServerWithMemoryIAM(t, cfg) srv.cfg.AllowInMemory = false cases := []struct { method string path string body string }{ {http.MethodPost, "/api/v1/docgov/content", `{"doc_id":"d1","markdown_body":"x"}`}, {http.MethodPost, "/api/v1/docgov/lease/acquire", `{"doc_id":"d1","holder_type":"human","holder_id":"1"}`}, {http.MethodPost, "/api/v1/admin/workspaces/reconcile", `{"run_id":"run_1"}`}, {http.MethodPost, "/api/v1/tasks/runs/run_test/cancel", ""}, {http.MethodGet, "/api/v1/projects", ""}, {http.MethodGet, "/api/v1/chat/sessions?user_id=1", ""}, {http.MethodGet, "/api/v1/freeform-documents?space=my_private", ""}, {http.MethodGet, "/api/v1/workflows", ""}, {http.MethodGet, "/api/v1/projects/1/members", ""}, {http.MethodPost, "/api/v1/chat", `{"session_id":"s1","message":"hi","user_id":999}`}, } for _, tc := range cases { var body *bytes.Reader if tc.body != "" { body = bytes.NewReader([]byte(tc.body)) } else { body = bytes.NewReader(nil) } req := httptest.NewRequest(tc.method, tc.path, body) if tc.body != "" { req.Header.Set("Content-Type", "application/json") } rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("%s %s expected 401 got %d body=%s", tc.method, tc.path, rec.Code, rec.Body.String()) } } } func TestP0_XUserIDCannotAuthenticate(t *testing.T) { cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} srv := newServerWithMemoryIAM(t, cfg) srv.cfg.AllowInMemory = false req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil) req.Header.Set("X-User-ID", "1") rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("X-User-ID must not authenticate, got %d", rec.Code) } } func TestP0_HealthDegradedWhenDBRequiredButUnavailable(t *testing.T) { cfg := &config.Config{ DataDir: t.TempDir(), AgentBackend: "stub", ContextDbHost: "127.0.0.1", ContextDbPort: 59999, AllowInMemory: false, } svc, err := bizdev.NewService(cfg) if err == nil { t.Fatal("expected NewService error when DB unavailable") } _ = svc srv := newServerWithMemoryIAM(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) srv.cfg = cfg req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) rec := httptest.NewRecorder() srv.handleHealth(rec, req) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503 health, got %d %s", rec.Code, rec.Body.String()) } var resp HealthResponse _ = json.Unmarshal(rec.Body.Bytes(), &resp) if resp.Status != "degraded" { t.Fatalf("expected degraded status, got %q", resp.Status) } } func TestP0_BodyUserIDCannotOverridePrincipal(t *testing.T) { cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", Env: "dev"} srv := newServerWithMemoryIAM(t, cfg) token := loginTestAdmin(t, srv) body := bytes.NewReader([]byte(`{"session_id":"p0-sess","message":"hello","user_id":99999}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", body) req.Header.Set("Content-Type", "application/json") withBearerAuth(req, token) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code == http.StatusUnauthorized { t.Fatalf("authenticated chat must not reject due to body user_id mismatch") } } func TestP0_CORSAllowlist(t *testing.T) { cfg := &config.Config{ DataDir: t.TempDir(), AgentBackend: "stub", Env: "dev", CORSAllowedOrigins: []string{"https://app.example.com"}, } srv := newServerWithMemoryIAM(t, cfg) req := httptest.NewRequest(http.MethodOptions, "/api/v1/projects", nil) req.Header.Set("Origin", "https://evil.example.com") rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got == "*" || got == "https://evil.example.com" { t.Fatalf("unexpected CORS header for disallowed origin: %q", got) } req2 := httptest.NewRequest(http.MethodOptions, "/api/v1/projects", nil) req2.Header.Set("Origin", "https://app.example.com") rec2 := httptest.NewRecorder() srv.Routes().ServeHTTP(rec2, req2) if got := rec2.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { t.Fatalf("expected allowlisted origin echoed, got %q", got) } } func TestP0_ReconcileRejectsClientWorkDir(t *testing.T) { cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"} srv := newServerWithMemoryIAM(t, cfg) srv.cfg.AllowInMemory = false body := bytes.NewReader([]byte(`{"work_dir":"/tmp/evil"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/workspaces/reconcile", body) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("anonymous reconcile must be 401, got %d", rec.Code) } } func TestP0_SecurityHeaders(t *testing.T) { cfg := &config.Config{DataDir: t.TempDir(), AgentBackend: "stub", Env: "dev"} srv := newServerWithMemoryIAM(t, cfg) req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { t.Fatalf("nosniff = %q", got) } if got := rec.Header().Get("X-Frame-Options"); got != "DENY" { t.Fatalf("frame options = %q", got) } if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { t.Fatalf("referrer = %q", got) } csp := rec.Header().Get("Content-Security-Policy") for _, want := range []string{"default-src 'self'", "script-src 'self'", "font-src 'self'", "frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"} { if !strings.Contains(csp, want) { t.Fatalf("csp %q missing %s", csp, want) } } if strings.Contains(csp, "unsafe-eval") || strings.Contains(csp, "*") { t.Fatalf("csp must not allow eval or wildcard: %s", csp) } }