From 18a323c4dc35ca55818e43e941d6b78eedb10c15 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:18:36 +0000 Subject: [PATCH] test: cover asset raw round-trip and harden cross-user session upsert Deny foreign user_id upsert without manage rights (403); unique project names in ctxmgr PG fixture for global name index; add test-env data repair SQL. Co-authored-by: Cursor --- internal/ctxmgr/manager_test.go | 5 +- internal/server/asset_handlers_test.go | 72 ++++++++++++++++++++++++++ internal/server/handlers.go | 6 +-- scripts/fix-test-env-triage-data.sql | 48 +++++++++++++++++ 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 scripts/fix-test-env-triage-data.sql diff --git a/internal/ctxmgr/manager_test.go b/internal/ctxmgr/manager_test.go index 3628b46a..4baa4ca2 100644 --- a/internal/ctxmgr/manager_test.go +++ b/internal/ctxmgr/manager_test.go @@ -153,11 +153,12 @@ func TestContextManager_DatabaseIntegration(t *testing.T) { // 准备真实项目与用户 var projID int64 projCode := "PROJ_MGR_" + time.Now().Format("20060102150405") + projName := "智能微创内窥镜-" + time.Now().Format("150405.000") err = pool.QueryRow(ctx, ` INSERT INTO projects (code, name, customer_name, device_type, current_stage) - VALUES ($1, '智能微创内窥镜', '水木创新中心', '内窥镜', 'V0.5_feasibility') + VALUES ($1, $2, '水木创新中心', '内窥镜', 'V0.5_feasibility') RETURNING id - `, projCode).Scan(&projID) + `, projCode, projName).Scan(&projID) if err != nil { t.Fatalf("insert project failed: %v", err) } diff --git a/internal/server/asset_handlers_test.go b/internal/server/asset_handlers_test.go index 69eb7bad..32c523b7 100644 --- a/internal/server/asset_handlers_test.go +++ b/internal/server/asset_handlers_test.go @@ -3,9 +3,11 @@ package server import ( "bytes" "encoding/json" + "io" "mime/multipart" "net/http" "net/http/httptest" + "strconv" "strings" "testing" @@ -96,3 +98,73 @@ func TestUploadProjectAsset_RejectsOversizedFilename(t *testing.T) { t.Fatalf("expected 415 for long filename, got %d", resp.StatusCode) } } + +func TestGetProjectAssetRaw_RoundTrip(t *testing.T) { + cfg := &config.Config{Env: "test", DataDir: t.TempDir()} + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + ts := httptest.NewServer(srv.Routes()) + defer ts.Close() + + payload := []byte("raw asset preview payload") + var body bytes.Buffer + w := multipart.NewWriter(&body) + part, _ := w.CreateFormFile("file", "preview.txt") + _, _ = part.Write(payload) + _ = w.Close() + + token := loginTestAdmin(t, srv) + upReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/projects/7/assets", &body) + upReq.Header.Set("Content-Type", w.FormDataContentType()) + withBearerAuth(upReq, token) + upResp, err := http.DefaultClient.Do(upReq) + if err != nil { + t.Fatalf("upload: %v", err) + } + defer upResp.Body.Close() + if upResp.StatusCode != http.StatusOK { + t.Fatalf("upload status=%d", upResp.StatusCode) + } + var uploaded projectAssetResponse + if err := json.NewDecoder(upResp.Body).Decode(&uploaded); err != nil { + t.Fatalf("decode upload: %v", err) + } + + rawReq, _ := http.NewRequest( + http.MethodGet, + ts.URL+"/api/v1/projects/7/assets/"+strconv.FormatInt(uploaded.AssetID, 10)+"/raw", + nil, + ) + withBearerAuth(rawReq, token) + rawResp, err := http.DefaultClient.Do(rawReq) + if err != nil { + t.Fatalf("raw: %v", err) + } + defer rawResp.Body.Close() + if rawResp.StatusCode != http.StatusOK { + t.Fatalf("raw status=%d", rawResp.StatusCode) + } + got, _ := io.ReadAll(rawResp.Body) + if !bytes.Equal(got, payload) { + t.Fatalf("raw body mismatch: got %q want %q", got, payload) + } + if ct := rawResp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Fatalf("unexpected content-type: %s", ct) + } + + // Wrong project id must not leak the blob. + wrongReq, _ := http.NewRequest( + http.MethodGet, + ts.URL+"/api/v1/projects/999/assets/"+strconv.FormatInt(uploaded.AssetID, 10)+"/raw", + nil, + ) + withBearerAuth(wrongReq, token) + wrongResp, err := http.DefaultClient.Do(wrongReq) + if err != nil { + t.Fatalf("wrong project raw: %v", err) + } + defer wrongResp.Body.Close() + if wrongResp.StatusCode == http.StatusOK { + t.Fatalf("expected non-200 for mismatched project, got %d", wrongResp.StatusCode) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index b11de1a9..f0acf80a 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1091,9 +1091,9 @@ func (s *Server) handleUpsertChatSession(w http.ResponseWriter, r *http.Request) ownerID := user.ID if req.UserID > 0 && req.UserID != user.ID { if req.ProjectID <= 0 { - writeJSONResponse(w, http.StatusBadRequest, map[string]string{ - "error_code": "ERR_INVALID_PARAM", - "message": "代注册他人会话时必须指定 project_id", + writeJSONResponse(w, http.StatusForbidden, map[string]string{ + "error_code": "ERR_FORBIDDEN", + "message": "无权为其他用户注册项目会话", }) return } diff --git a/scripts/fix-test-env-triage-data.sql b/scripts/fix-test-env-triage-data.sql new file mode 100644 index 00000000..998ce461 --- /dev/null +++ b/scripts/fix-test-env-triage-data.sql @@ -0,0 +1,48 @@ +-- One-shot data repair for test env triage (project 4/5 dup + member sessions). +-- Usage: +-- docker exec -i shuimu-system-pg-test psql -U app -d agent_system_test < scripts/fix-test-env-triage-data.sql +-- Safe to re-run (idempotent where possible). + +BEGIN; + +-- 1) Retire duplicate project 4 (wanjiahao); keep project 5 (admin + members + assets). +UPDATE projects +SET status = 'demo_retired', + updated_at = now() +WHERE id = 4 + AND name = '全自动冲击波治疗仪' + AND COALESCE(status, 'active') <> 'demo_retired'; + +-- 2) Fix mis-owned member sessions created by creator upsert (owner was forced to admin). +UPDATE chat_sessions +SET user_id = 32, + updated_at = now() +WHERE session_id = 'sess-p5-u32' + AND project_id = 5 + AND user_id = 4; + +UPDATE chat_sessions +SET user_id = 33, + updated_at = now() +WHERE session_id = 'sess-p5-u33' + AND project_id = 5 + AND user_id = 4; + +-- 3) Ensure chenwei (35) private session on project 5. +INSERT INTO chat_sessions (session_id, user_id, project_id, title, created_at, updated_at) +SELECT 'sess-p5-u35', 35, 5, '全自动冲击波治疗仪', now(), now() +WHERE EXISTS ( + SELECT 1 FROM project_members + WHERE project_id = 5 AND user_id = 35 AND status = 'active' +) +ON CONFLICT (session_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + project_id = EXCLUDED.project_id, + title = COALESCE(NULLIF(EXCLUDED.title, ''), chat_sessions.title), + updated_at = now(); + +COMMIT; + +-- Verification queries (run after): +-- SELECT id, name, creator_user_id, status FROM projects WHERE name LIKE '%冲击波%' OR id IN (4,5); +-- SELECT session_id, user_id, project_id FROM chat_sessions WHERE project_id = 5 ORDER BY session_id;