Files
Agent-System/internal/server/task_run_busy_preflight_test.go
weichen 6a3a0dc201 fix(security): harden bypass authz, gate blind spot, command exec, validated_only (review P0/P1)
- FIND-SEC-01: GetUserContext requires session ownership (handler 403 +
  SQL/memory owner filter); in-memory HTTP + isolated-PG regressions
- FIND-ARCH-01: archtest fails closed on untracked table writes (+noise
  exemption) and registers 50 existing writer tables in table_owners
- FIND-SEC-02: Authorizer without pool denies by default; explicit
  NewInMemoryAuthorizer seam opt-in via ALLOW_IN_MEMORY at composition
- FIND-SEC-03: swboot commands run argv-exec (no sh -c) with exact
  basename allowlist match; prefix-spoof and no-shell-eval tests
- FIND-FLOW-01: confirm rejects validated_only plans; ExecuteClaimed
  hard-refuses validated_only steps (new error codes + HTTP mapping)
- FIND-QUAL-01: intentflow.ValidateFlagConsistency fail-fast at Assemble
2026-09-26 02:00:14 +00:00

209 lines
8.0 KiB
Go

package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/projectinstance"
)
func TestCreateTaskRunBusyPreflightDoesNotPersistRunOrMessages(t *testing.T) {
srv := newServerWithMemoryIAM(t, nil) // FIND-SEC-02: 业务行为用例走显式内存授权
ctx := t.Context()
docID := "doc_proj143_intake"
if _, err := srv.gov().CreateOrGetDocument(
ctx, docID, 143, "intake", "用户初始需求收集表", "V0.1_triage", "",
1, "编制人", 1, "审核人", 1, "批准人",
); err != nil {
t.Fatalf("create document: %v", err)
}
if _, err := srv.gov().AcquireLease(ctx, docgov.LeaseAcquireRequest{
DocID: docID, HolderType: docgov.LeaseHolderHuman, HolderID: "editor-9", TTL: time.Minute,
}); err != nil {
t.Fatalf("acquire fixture lease: %v", err)
}
defer func() {
_, _ = srv.gov().ReleaseLease(t.Context(), docID, string(docgov.LeaseHolderHuman), "editor-9")
}()
requestBody, _ := json.Marshal(createTaskRunRequest{
TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 143, ProjectName: "隔离项目"},
SessionID: "fixture-busy-preflight", ClientTurnID: "turn-busy-1", UserMessage: "开始编写用户初始需求表",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(requestBody))
withBearerAuth(req, loginTestAdmin(t, srv))
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.NewDecoder(w.Body).Decode(&payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["error_code"] != "DOC_BUSY" {
t.Fatalf("payload=%+v", payload)
}
if _, exists := payload["run_id"]; exists {
t.Fatalf("busy preflight must not return run_id: %+v", payload)
}
history, err := srv.ctxMgr().GetChatHistory(ctx, "fixture-busy-preflight", 20, 0)
if err != nil {
t.Fatalf("get busy history: %v", err)
}
if len(history) != 2 {
t.Fatalf("busy preflight history=%d want user+assistant", len(history))
}
if history[1].Metadata["task_status"] != "failed" {
t.Fatalf("assistant metadata=%+v", history[1].Metadata)
}
errorInfo, ok := history[1].Metadata["error_info"].(map[string]any)
if !ok || errorInfo["code"] != "DOC_BUSY" {
t.Fatalf("error_info=%+v", history[1].Metadata["error_info"])
}
if actions, ok := errorInfo["actions"].([]string); !ok || len(actions) != 0 {
t.Fatalf("busy actions=%+v", errorInfo["actions"])
}
}
// 从未激活项目:任务作用域为 pre_activation,创建 run 必须 202 而非 TASK_SCOPE_UNRESOLVED 409。
func TestCreateTaskRun_PreActivationAcceptedNot409(t *testing.T) {
srv := newServerWithMemoryIAM(t, nil) // FIND-SEC-02: 业务行为用例走显式内存授权
store := projectinstance.NewMemoryStore()
_ = projectinstance.SeedProjectMeta(store, 9001, projectinstance.LifecyclePlanning, "", 1, 0)
srv.setOverrideInstSvc(projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil))
requestBody, _ := json.Marshal(createTaskRunRequest{
TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 9001, ProjectName: "预激活立项"},
SessionID: "sess-p9001-u1",
ClientTurnID: "turn-preact-1",
UserMessage: "请生成首轮摸底文档",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(requestBody))
withBearerAuth(req, loginTestAdmin(t, srv))
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code == http.StatusConflict {
var payload map[string]any
_ = json.NewDecoder(w.Body).Decode(&payload)
if payload["error_code"] == "TASK_SCOPE_UNRESOLVED" {
t.Fatalf("never-activated must not 409 TASK_SCOPE_UNRESOLVED: %+v", payload)
}
t.Fatalf("unexpected conflict: %+v", payload)
}
if w.Code != http.StatusAccepted {
t.Fatalf("want 202 Accepted, got %d body=%s", w.Code, w.Body.String())
}
var payload createTaskRunResponse
if err := json.NewDecoder(w.Body).Decode(&payload); err != nil {
t.Fatalf("decode: %v body=%s", err, w.Body.String())
}
if payload.Status != "blocked_pending_governance" {
t.Fatalf("status=%q want blocked_pending_governance body=%s", payload.Status, w.Body.String())
}
if rec, err := srv.taskRuns().GetRun(payload.RunID); err == nil && rec != nil && rec.Status == "running" {
t.Fatalf("pre-activation run must not start a live worker: %+v", rec)
}
history, err := srv.ctxMgr().GetChatHistory(t.Context(), "sess-p9001-u1", 20, 0)
if err != nil {
t.Fatalf("chat history: %v", err)
}
foundHint := false
for _, msg := range history {
if msg.Role == "assistant" && strings.Contains(msg.Content, projectinstance.GovernanceBlockedHint) {
foundHint = true
break
}
}
if !foundHint {
t.Fatalf("expected governance hint in chat, history=%d", len(history))
}
}
func TestCreateTaskRun_ScopeUnresolvedPersistsChat(t *testing.T) {
srv := newServerWithMemoryIAM(t, nil) // FIND-SEC-02: 业务行为用例走显式内存授权
token := loginTestAdmin(t, srv)
srv.cfg.AllowInMemory = false
srv.setOverrideInstSvc(nil)
requestBody, _ := json.Marshal(createTaskRunRequest{
TaskRunRequestDTO: TaskRunRequestDTO{TaskKey: "intake", ProjectID: 9102, ProjectName: "作用域失败"},
SessionID: "sess-scope-unresolved",
ClientTurnID: "turn-scope-1",
UserMessage: "编写初始用户需求表",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/runs", bytes.NewReader(requestBody))
withBearerAuth(req, token)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.NewDecoder(w.Body).Decode(&payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload["error_code"] != "TASK_SCOPE_UNRESOLVED" {
t.Fatalf("payload=%+v", payload)
}
history, err := srv.ctxMgr().GetChatHistory(t.Context(), "sess-scope-unresolved", 20, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 || history[0].Role != "user" || history[1].Role != "assistant" {
t.Fatalf("history=%+v", history)
}
if history[0].Content != "编写初始用户需求表" {
t.Fatalf("user content=%q", history[0].Content)
}
errorInfo, ok := history[1].Metadata["error_info"].(map[string]any)
if !ok || errorInfo["code"] != "TASK_SCOPE_UNRESOLVED" {
t.Fatalf("error_info=%+v", history[1].Metadata["error_info"])
}
if history[1].Metadata["task_status"] != "failed" {
t.Fatalf("assistant metadata=%+v", history[1].Metadata)
}
}
func TestDocGovActions_PreActivationRejected(t *testing.T) {
srv := newTestServer(t)
store := projectinstance.NewMemoryStore()
_ = projectinstance.SeedProjectMeta(store, 9002, projectinstance.LifecyclePlanning, "", 1, 0)
srv.setOverrideInstSvc(projectinstance.NewService(store, projectinstance.TestResolver(), nil, nil))
docID := "doc_proj9002_intake"
if _, err := srv.gov().CreateOrGetDocument(
t.Context(), docID, 9002, "intake", "立项文档", "V0.1_triage", "",
1, "编制人", 2, "审核人", 3, "批准人",
); err != nil {
t.Fatalf("create document: %v", err)
}
token := loginTestAdmin(t, srv)
for _, tc := range []struct {
path string
body any
}{
{"/api/v1/docgov/submit-review", DocGovSubmitReviewRequest{DocID: docID, UserID: 1, UserName: "编制人"}},
{"/api/v1/docgov/review-decision", DocGovDecisionRequest{DocID: docID, UserID: 2, UserName: "审核人", Action: "approve"}},
{"/api/v1/docgov/approver-decision", DocGovDecisionRequest{DocID: docID, UserID: 3, UserName: "批准人", Action: "approve"}},
} {
raw, _ := json.Marshal(tc.body)
req := httptest.NewRequest(http.MethodPost, tc.path, bytes.NewReader(raw))
withBearerAuth(req, token)
w := httptest.NewRecorder()
srv.Routes().ServeHTTP(w, req)
if w.Code < 400 {
t.Fatalf("%s want 4xx, got %d body=%s", tc.path, w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), projectinstance.AccessCodeNotActivated) {
t.Fatalf("%s want WORKFLOW_NOT_ACTIVATED, body=%s", tc.path, w.Body.String())
}
}
}