Files
Agent-System/internal/server/chat_project_access_authz_test.go
T
weichenandCursor cc394c815c fix(api): require project membership before chat and chat SSE
Unrestricted stage access on projects without a live workflow must not
let arbitrary authenticated users join by project_id. Enforce member or
creator (admin per existing policy) before stage ABAC, and reject chat
streams with JSON 403 before SSE headers/flush.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 12:25:22 +00:00

149 lines
5.5 KiB
Go

package server_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"chat-agent-system/internal/projectworkflow"
)
// 与 tasks/run/stream 对齐:无-live 项目上非成员不得因 stage unrestricted 而进入对话;
// chat/stream 拒绝必须发生在 SSE headers/Flush 之前(结构化 JSON 403)。
func TestChatProjectAccess_NonMemberDeniedBeforeSSE_PG(t *testing.T) {
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
handler, _ := setupPGGovernanceServer(t, pool)
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
outsiderToken := loginTestUser(t, handler, "lizhuguan", "123456")
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Chat Access NoLive " + time.Now().Format("150405.000")})
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
withBearerAuth(reqCreate, creatorToken)
recCreate := httptest.NewRecorder()
handler.ServeHTTP(recCreate, reqCreate)
if recCreate.Code != http.StatusCreated {
t.Fatalf("create project: %d %s", recCreate.Code, recCreate.Body.String())
}
var proj projectworkflow.Project
_ = json.Unmarshal(recCreate.Body.Bytes(), &proj)
t.Cleanup(func() { cleanupProject(t, pool, proj.ID) })
syncBody, _ := json.Marshal(map[string]any{
"session_id": "sess-chat-outsider-sync",
"message": "首轮 intake 试探",
"project_id": proj.ID,
})
reqSync := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(syncBody))
withBearerAuth(reqSync, outsiderToken)
recSync := httptest.NewRecorder()
handler.ServeHTTP(recSync, reqSync)
if recSync.Code != http.StatusForbidden {
t.Fatalf("outsider sync chat expected 403, got %d: %s", recSync.Code, recSync.Body.String())
}
if !strings.Contains(recSync.Body.String(), "ERR_FORBIDDEN") {
t.Fatalf("expected ERR_FORBIDDEN json, got %s", recSync.Body.String())
}
streamBody, _ := json.Marshal(map[string]any{
"session_id": "sess-chat-outsider-stream",
"message": "首轮 intake 流式",
"project_id": proj.ID,
})
reqStream := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(streamBody))
withBearerAuth(reqStream, outsiderToken)
recStream := httptest.NewRecorder()
handler.ServeHTTP(recStream, reqStream)
if recStream.Code != http.StatusForbidden {
t.Fatalf("outsider chat/stream expected 403 (not 200 SSE), got %d body=%s", recStream.Code, recStream.Body.String())
}
ct := recStream.Header().Get("Content-Type")
if strings.Contains(ct, "text/event-stream") {
t.Fatalf("forbidden chat/stream must not set text/event-stream before reject, got %q", ct)
}
if !strings.Contains(recStream.Body.String(), "ERR_FORBIDDEN") {
t.Fatalf("expected structured ERR_FORBIDDEN before flush, got %s", recStream.Body.String())
}
}
func TestChatProjectAccess_CreatorAndMemberAllowed_PG(t *testing.T) {
pool := openPGPoolWithMigrations(t)
if pool == nil {
return
}
defer pool.Close()
handler, _ := setupPGGovernanceServer(t, pool)
creatorToken := loginTestUser(t, handler, "zhanggong", "123456")
memberToken := loginTestUser(t, handler, "lizhuguan", "123456")
memberID := fetchAuthUserID(t, handler, memberToken)
createBody, _ := json.Marshal(projectworkflow.CreateProjectInput{Name: "Chat Access Member " + time.Now().Format("150405.000")})
reqCreate := httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
withBearerAuth(reqCreate, creatorToken)
recCreate := httptest.NewRecorder()
handler.ServeHTTP(recCreate, reqCreate)
if recCreate.Code != http.StatusCreated {
t.Fatalf("create: %d %s", recCreate.Code, recCreate.Body.String())
}
var proj projectworkflow.Project
_ = json.Unmarshal(recCreate.Body.Bytes(), &proj)
t.Cleanup(func() { cleanupProject(t, pool, proj.ID) })
addBody, _ := json.Marshal(map[string]any{"user_id": memberID, "project_role": "member"})
reqAdd := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+strconv.FormatInt(proj.ID, 10)+"/members", bytes.NewReader(addBody))
withBearerAuth(reqAdd, creatorToken)
recAdd := httptest.NewRecorder()
handler.ServeHTTP(recAdd, reqAdd)
if recAdd.Code != http.StatusCreated && recAdd.Code != http.StatusOK {
t.Fatalf("add member: %d %s", recAdd.Code, recAdd.Body.String())
}
for _, tc := range []struct {
name string
token string
}{
{"creator", creatorToken},
{"member", memberToken},
} {
body, _ := json.Marshal(map[string]any{
"session_id": "sess-chat-ok-" + tc.name,
"message": "hello",
"project_id": proj.ID,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", bytes.NewReader(body))
withBearerAuth(req, tc.token)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s sync chat want 200 got %d %s", tc.name, rec.Code, rec.Body.String())
}
sBody, _ := json.Marshal(map[string]any{
"session_id": "sess-chat-ok-stream-" + tc.name,
"message": "hello stream",
"project_id": proj.ID,
})
sReq := httptest.NewRequest(http.MethodPost, "/api/v1/chat/stream", bytes.NewReader(sBody))
withBearerAuth(sReq, tc.token)
sRec := httptest.NewRecorder()
handler.ServeHTTP(sRec, sReq)
if sRec.Code != http.StatusOK {
t.Fatalf("%s chat/stream want 200 got %d %s", tc.name, sRec.Code, sRec.Body.String())
}
if !strings.Contains(sRec.Header().Get("Content-Type"), "text/event-stream") {
t.Fatalf("%s chat/stream should be SSE after auth, ct=%q", tc.name, sRec.Header().Get("Content-Type"))
}
}
}