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")) } } }