Add project:manage_members, CSP frame-src, task-run member view, CreateUser tx error handling, ERR_NO_PROJECT chat gate, and creator-scoped project name/client_key uniqueness to stop duplicate creates and auth gaps. Co-authored-by: Cursor <cursoragent@cursor.com>
182 lines
6.2 KiB
Go
182 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|