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>
149 lines
5.5 KiB
Go
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"))
|
|
}
|
|
}
|
|
}
|