diff --git a/.doc/audit/ISSUES.md b/.doc/audit/ISSUES.md index c9230f56..17927579 100644 --- a/.doc/audit/ISSUES.md +++ b/.doc/audit/ISSUES.md @@ -2,10 +2,11 @@ 维护规则:[问题记录与开发经验闭环](../ops/2026-09-23-issue-learning-workflow.md)。每个问题一个稳定 ID;主记录是事实与状态来源,本表为可检索投影。新发现及时登记,修复后保留记录;发布 blocker 仍见 [gap-matrix](../remediation/gap-matrix.md)。 -更新:2026-09-28。test 注册 INTERNAL:标准角色 role-guest 缺失导致 FK 23503,已补种并重启 test(goose 77,resolved)。重复用户名 400 在新代码单测通过;当前 rc 二进制仍返回 INTERNAL,随下次含本提交的 test 制品生效。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。 +更新:2026-09-28。非编制人触发小队账本提交被 `uow: permission denied` 拒绝(fixing,待 dev 重跑)。test 注册 INTERNAL:标准角色 role-guest 缺失导致 FK 23503,已补种并重启 test(goose 77,resolved)。重复用户名 400 在新代码单测通过;当前 rc 二进制仍返回 INTERNAL,随下次含本提交的 test 制品生效。管理台重置密码入口被 F4-A 误删(端点仍在),已恢复 UI(resolved)。2026-09-27。上传链路:登记并修复上传文档跨端不可访问(占位模板退化 + 元数据/正文不落服务端,fixing 待 dev 集成)。2026-09-26。docgov 批准链:登记并修复 review_snapshot_id 装载缺失与 uow 行锁自死锁(resolved);memDocs 持久化失败缓存投毒(investigating)。历史 resolved 不表示本日产品回归通过。系统 code review P0/P1 批次修复落账(5 resolved + 1 deferred + 1 open,主记录见 [整改文档](2026-09-26-system-code-review-fixes.md),来源 [审计](2026-09-26-system-code-review.md))。 | issue_id | 模块 / 检索词 | 问题 | 严重度 | issue_state | owner | 证据日期 / 范围 | 唯一主记录 | 经验 | |---|---|---|---|---|---|---|---|---| +| ISSUE-20260928-squad-commit-authority | bizdev / docflow / dutyAuthorizer / uow permission denied / OnBehalfOf / bd_intake | 非编制人触发受控小队任务时,账本提交用触发者身份,被编制人职责门拒绝,正文无法落盘 | high | fixing | Cursor | 2026-09-28;dev task_runs user 74 ≠ author 4(doc_proj6022_bd_intake);单测已绿,dev 重跑未做 | [主记录](../ops/short-term/2026-09-28-squad-commit-authority.md#issue-20260928-squad-commit-authority) | [LESSON-20260928-squad-commit-as-author](../ops/lessons/2026-09-28-squad-commit-as-author.md) | | ISSUE-20260928-register-internal | auth / register / role-guest / system_roles / 23503 / 23505 / INTERNAL | test 缺 role-guest 时注册 FK 失败被脱敏为 INTERNAL;重复用户名唯一冲突同样 500 | high | resolved | Cursor | 2026-09-28;test:8081 补种并重启后注册 200(goose 77,req_regfix_after_restart);重复用户名 400 由单测覆盖,rc 二进制仍为 INTERNAL | [主记录](../ops/short-term/2026-09-28-auth-register-internal.md#issue-20260928-register-internal) | [LESSON-20260928-standard-role-reseed](../ops/lessons/2026-09-28-standard-role-reseed.md) | | ISSUE-20260928-admin-reset-ui-removed | web / UsersSection / 重置密码 / F4-A / adminResetPassword | F4-A 在 F4-B 端点已合入的基线上删除管理台重置密码按钮,管理员无法重置口令 | high | resolved | Cursor | 2026-09-28;dev API :8080 + worktree Vite :3110 真实 UI 探针 PASS(临时口令长度 16、强制改密仅一次、旧口令 401) | [主记录](../ops/short-term/2026-09-28-restore-admin-password-reset.md#issue-20260928-admin-reset-ui-removed) | [LESSON-20260928-ui-dead-control](../ops/lessons/2026-09-28-ui-dead-control.md) | | ISSUE-20260927-upload-doc-cross-device | freedoc / upload / freeform / fileMeta / 占位模板 / 跨端 | 上传文档源文件元数据与正文不落服务端:换浏览器/清存储/共享他人查看时退化为注册占位模板,文本类连二进制都未存(伴生 <512B 显示 "0 KB") | high | resolved | pi | 2026-09-27;dev `86efba2c` 合入并在 dev 环境验证:FS-UPLOAD-01/02 真实浏览器 PASS(跨上下文可见真实正文),DB 核验元数据+asset 落库;存量无元数据旧文档不可恢复(重新上传即可) | [主记录](../ops/short-term/2026-09-27-upload-doc-access.md) | [LESSON-20260927-server-facts-and-iso-db-recipe](../ops/lessons/2026-09-27-server-facts-and-iso-db-recipe.md) | diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 1ca7c547..88c2634e 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -18,6 +18,7 @@ | owner | task | branch | worktree | mutex | status | |---|---|---|---|---|---| +| Cursor | squad-commit-authority-2026-09-28 | fix/squad-commit-authority-2026-09-28 | .worktrees/squad-commit-authority-2026-09-28 | bizdev+docflow | in_progress | | Cursor | auth-register-internal-2026-09-28 | fix/auth-register-internal-2026-09-28 | .worktrees/auth-register-internal-2026-09-28 | auth-register | done | | Cursor | restore-admin-password-reset-2026-09-28 | feature/restore-admin-password-reset-2026-09-28 | .worktrees/restore-admin-password-reset-2026-09-28 | auth | done | | pi | test-single-binary-2026-09-27 | feature/test-single-binary-2026-09-27 | .worktrees/test-single-binary-2026-09-27 | env:dev+env:test+git:dev+git:test | done | diff --git a/.doc/ops/lessons/2026-09-28-squad-commit-as-author.md b/.doc/ops/lessons/2026-09-28-squad-commit-as-author.md new file mode 100644 index 00000000..65a3d6af --- /dev/null +++ b/.doc/ops/lessons/2026-09-28-squad-commit-as-author.md @@ -0,0 +1,20 @@ +--- +lesson_id: LESSON-20260928-squad-commit-as-author +lesson_state: candidate +owner: Cursor +reviewer: Cursor(自审,待 dev 重跑后升 validated) +last_reviewed: 2026-09-28 +reviewed_revision: 774df0599813fb8d7115f95ce255384542d6a0f9 +source_issues: [ISSUE-20260928-squad-commit-authority] +keywords: [bizdev, docflow, dutyAuthorizer, AutoCommitUserID, OnBehalfOf, uow permission denied, 非编制人] +--- + +# 小队账本自动提交要用编制人身份 + +- 触发条件:受控文档小队任务成功生成正文,但落盘报 `uow: permission denied` 或「版本账本/变更报告落盘失败」。 +- 机制与来源:`dutyAuthorizer` 对 `ActionCommit` 要求 principal 等于 `document_records.author_id`。触发者常常是其他项目成员。见 ISSUE-20260928-squad-commit-authority。 +- 开发检查:自动提交的 `AutoCommitUserID` 用文档编制人;实际触发用户写入 `OnBehalfOf`。不要为了小队路径放宽编制人校验。触发者或编制人为 0 时不要伪造代理身份。权限失败文案要写明「非编制人」,同时保留 `uow.ErrPermissionDenied` 哨兵。 +- 验证方式:非编制人触发应成功且 `OnBehalfOf` 为触发者;编制人触发不设置 `OnBehalfOf`;非编制人直接 `ActionCommit` 仍拒绝。关联 `internal/bizdev/squad_commit_actor_test.go` 与 `TestDutyAuthorizerAllowsDocumentAuthorCommit`。dev 原例重跑完成前本条保持 candidate。 +- 适用边界:只覆盖小队/agent 自动提交。人工编辑、批准、驳回仍按各自职责人校验。高风险候选仍要编制人确认。 +- 防护落实:上述单测已落地。原例验收是 manual(dev 非编制人重跑)。 +- 修订与失效:若产品改为「非编制人不得触发生成」,本条应退役并改回触发者身份。 diff --git a/.doc/ops/lessons/INDEX.md b/.doc/ops/lessons/INDEX.md index d9cf32fe..2b5a6165 100644 --- a/.doc/ops/lessons/INDEX.md +++ b/.doc/ops/lessons/INDEX.md @@ -4,6 +4,7 @@ | lesson_id | 触发条件 / 关键词 | 注意事项 | lesson_state | 主记录 / 最近复核 | |---|---|---|---|---| +| LESSON-20260928-squad-commit-as-author | 小队落盘、uow permission denied、AutoCommitUserID、OnBehalfOf、非编制人 | 自动提交身份用文档编制人,触发者只写入 OnBehalfOf;不要放宽 ActionCommit | candidate | [编制人代理提交](2026-09-28-squad-commit-as-author.md);2026-09-28 | | LESSON-20260928-standard-role-reseed | 注册、role-guest、system_roles、is_system、23503、23505、INTERNAL | 硬编码标准角色须幂等补种并标 is_system;用户名冲突返回领域 400,不要包进 insert user | validated | [标准角色可自愈](2026-09-28-standard-role-reseed.md);2026-09-28 | | LESSON-20260928-ui-dead-control | 死按钮、端点未开通、UsersSection、重置密码、merge-base | 删除「未开通」控件前用当前基线核对路由已存在;注释不能代替 merge-base | validated | [删控件前核对端点](2026-09-28-ui-dead-control.md);2026-09-28 | | LESSON-20260927-server-facts-and-iso-db-recipe | 上传持久化、fileMeta、占位模板、config.Load 默认 DSN、TEST_DATABASE_URL、隔离库、RefuseSharedEnvDSN、goose 顺序 | 服务端事实源优先(本地仅缓存);worktree 测试必须显式钉隔离库并避开 5433/5434/5435 子串端口;隔离库配方=扩展→goose→init SQL,重组件用例先做基线四格对照 | validated | [服务端事实源与隔离库配方](2026-09-27-server-facts-and-iso-db-recipe.md) | diff --git a/.doc/ops/short-term/2026-09-28-squad-commit-authority.md b/.doc/ops/short-term/2026-09-28-squad-commit-authority.md new file mode 100644 index 00000000..3af3e662 --- /dev/null +++ b/.doc/ops/short-term/2026-09-28-squad-commit-authority.md @@ -0,0 +1,74 @@ +--- +title: "修复非编制人触发小队账本提交被拒" +task_id: squad-commit-authority-2026-09-28 +status: in_progress +created: 2026-09-28 +updated: 2026-09-28 +owner: Cursor +task_type: fix +policy_revision: "AGENTS.md + agent-worktree-workflow @ 2026-09-21" +branch: fix/squad-commit-authority-2026-09-28 +worktree: .worktrees/squad-commit-authority-2026-09-28 +base_dev_sha: "774df0599813fb8d7115f95ce255384542d6a0f9" +write_scope: + - internal + - .doc +mutex: bizdev+docflow +review_state: pending +integration_state: pending +deployment: pending +cleanup: pending +remote_sync: not_requested +--- + +# 修复非编制人触发小队账本提交被拒 + +## 用户目标与授权 + +用户生成《用户初始需求收集表》时小队任务失败,诊断为 `TASK_FAILED` / `uow: permission denied`。授权:以编制人身份自动提交账本,并用 `OnBehalfOf` 记录实际触发用户;先用 dev 库核实身份后再改代码。 + +## 检索 + +- 检索词:`uow: permission denied`、`ErrPermissionDenied`、`文档版本账本/变更报告落盘失败`、`bd_intake`、非编制。 +- 命中 [ISSUE-20260924-non-author-400-duty](../../audit/ISSUES.md) 与 [LESSON-20260924-deny-only-submit](../lessons/2026-09-24-deny-only-submit.md):成员可以跑草稿,但下游门必须一起放行。本问题是账本 `ActionCommit` 仍按触发者比对编制人,属于该教训的下游缺口,不是同一 HTTP 400。 +- 命中 [ISSUE-20260926-docgov-uow-deadlock](../../audit/ISSUES.md):同为 uow 失败,机制是行锁自死锁,不适用。 +- catalog overlap(mutex `bizdev+docflow`)hard/soft 为空。登记时 `integrate-restore-admin-password-reset-2026-09-28` 与其 close 任务仍 `active` 且 scope 为 `.`,队列已 completed、提交已是 dev 祖先,已 `task close --status done` 以释放仓库级写范围,未删除其 worktree。 + +## ISSUE-20260928-squad-commit-authority + +issue_id: ISSUE-20260928-squad-commit-authority +issue_state: fixing +severity: high +owner: Cursor +task_ids: [squad-commit-authority-2026-09-28] +created: 2026-09-28 +occurred_at: 2026-09-28 +updated: 2026-09-28 +recurrence_count: 0 +evidence_basis: current +lesson_ids: [LESSON-20260928-squad-commit-as-author] + +### 事实与证据 + +预期:项目成员触发受控文档生成后,小队正文能写入版本账本。实际:任务 `run_1790556318606671340`、`run_1790556736148378090`(project 6022,task_key `intake`,user_id 74)失败,`error_message=uow: permission denied`。界面文案为「《用户初始需求收集表》未成功落盘」加上裸 `uow: permission denied`。 + +dev 库只读核实(2026-09-28,`agent_system_dev`):`document_records.doc_proj6022_bd_intake` 的 `author_id=4`(admin,项目 owner,`document_duty=author`);触发者 74 是成员 `ceshi`,`project_role=viewer`。项目创建者是 user 4。 + +### 根因与同类范围 + +已证实:`finalizeSquadDeliverable` 把 `AutoCommitUserID` 设为触发者 `req.UserID`。生产路径经 `docflow.Commands.MaterializeAgent` 用该 id 做 `ActionCommit`。`dutyAuthorizer` 在编制人与触发者都非 0 且不相等时返回 `uow.ErrPermissionDenied`。小队正文与质量门禁已通过,失败只发生在账本提交。 + +同类:API 直连已把该哨兵映射为 403。缺口只在小队自动提交。编制人自己触发、高风险 `needs_confirmation`、批准/驳回职责检查应保持不变。 + +### 修复思路与过程 + +选择以文档编制人作为自动提交身份,触发者写入已有 `OnBehalfOf`。不放宽 `dutyAuthorizer`,避免 viewer 直接获得编制人编辑权。高风险候选仍待编制人确认。 + +| 日期 | 动作 | 结果 | 下一步 | +|---|---|---|---| +| 2026-09-28 | dev 库核对 author 与 task_runs.user_id | 4 ≠ 74,两次失败均为 permission denied | 按编制人代理提交实现 | +| 2026-09-28 | `squadCommitActor` + 回归 | 内存单测通过;dev 真实重跑未做 | 合入 dev 后用非编制人重跑 | + +### 验收与关闭 + +尚未关闭。原例待 dev 重跑;变体与正常路径见 `TestExecuteTask_NonAuthorTriggerMaterializesAsAuthor`、`TestExecuteTask_AuthorTriggerDoesNotSetOnBehalfOf`、`TestExecuteTask_NonAuthorPassesDutyAuthorizer`、`TestDutyAuthorizerAllowsDocumentAuthorCommit`。 diff --git a/internal/app/docflow/commands_test.go b/internal/app/docflow/commands_test.go index 7a08648f..9179a15c 100644 --- a/internal/app/docflow/commands_test.go +++ b/internal/app/docflow/commands_test.go @@ -145,6 +145,20 @@ func TestIdempotentHumanEditSameDigest(t *testing.T) { } } +func TestDutyAuthorizerAllowsDocumentAuthorCommit(t *testing.T) { + rec := &docgov.DocumentRecord{DocID: "doc_flow_author_commit", AuthorID: 4, ApproverID: 9} + auth := DutyAuthorizer() + if err := auth.Authorize(context.Background(), Principal{UserID: 4}, rec, ActionCommit); err != nil { + t.Fatalf("document author must pass agent commit: %v", err) + } + if err := auth.Authorize(context.Background(), Principal{UserID: 74}, rec, ActionCommit); err != uow.ErrPermissionDenied { + t.Fatalf("non-author commit must be denied, got %v", err) + } + if err := auth.Authorize(context.Background(), Principal{UserID: 4}, rec, ActionEdit); err != nil { + t.Fatalf("document author must still pass edit: %v", err) + } +} + func TestReauthDeniesWrongAuthor(t *testing.T) { ctx := context.Background() gov := docgov.NewGovernanceEngine(nil) diff --git a/internal/bizdev/service_task_finalize.go b/internal/bizdev/service_task_finalize.go index 761f5763..5cbecb6e 100644 --- a/internal/bizdev/service_task_finalize.go +++ b/internal/bizdev/service_task_finalize.go @@ -4,10 +4,12 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "strings" "time" + "chat-agent-system/internal/app/uow" "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/projectworkflow" @@ -27,6 +29,17 @@ func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docg return reqAutoRepair } +// squadCommitActor 让受控账本提交通过编制人职责门。 +// 触发者不是编制人时,以文档编制人作为 AutoCommitUserID,并把触发者写入 OnBehalfOf。 +// 触发者或编制人缺失时保持原触发者,避免把 0 写成代理身份。 +func squadCommitActor(triggerUserID int64, docRec *docgov.DocumentRecord) (int64, *int64) { + if docRec == nil || docRec.AuthorID <= 0 || triggerUserID <= 0 || docRec.AuthorID == triggerUserID { + return triggerUserID, nil + } + onBehalf := triggerUserID + return docRec.AuthorID, &onBehalf +} + func qualityFailedErrorReason(report *squad.QualityReport) string { if report == nil { return "质量门禁未通过" @@ -87,15 +100,20 @@ func (s *Service) finalizeSquadDeliverable( // Squad may have already written candidate body to workDir/output.md — pending must restore ledger head. if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" { actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} + commitUserID, onBehalfOf := squadCommitActor(req.UserID, docRec) mat, matErr := s.materializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody, ProjectID: req.ProjectID, WorkDir: res.WorkDir, ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor, - AutoCommitUserID: req.UserID, + OnBehalfOf: onBehalfOf, + AutoCommitUserID: commitUserID, }) if matErr != nil { reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr) + if errors.Is(matErr, uow.ErrPermissionDenied) { + reason = fmt.Sprintf("触发用户非文档编制人,账本提交被拒: %v", matErr) + } return &TaskRunResult{ TaskKey: res.TaskKey, ArtifactTitle: res.ArtifactTitle, diff --git a/internal/bizdev/squad_commit_actor_test.go b/internal/bizdev/squad_commit_actor_test.go new file mode 100644 index 00000000..88c66c86 --- /dev/null +++ b/internal/bizdev/squad_commit_actor_test.go @@ -0,0 +1,178 @@ +package bizdev + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "chat-agent-system/internal/app/docflow" + "chat-agent-system/internal/app/uow" + "chat-agent-system/internal/config" + "chat-agent-system/internal/docgov" + "chat-agent-system/internal/docrevision" +) + +type recordingMaterializer struct { + inner AgentOutputMaterializer + got docrevision.AgentMaterializeRequest + err error +} + +func (r *recordingMaterializer) MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) { + r.got = req + if r.err != nil { + return nil, r.err + } + if r.inner == nil { + return nil, errors.New("recording materializer has no inner") + } + return r.inner.MaterializeAgentOutput(ctx, req) +} + +func TestSquadCommitActor_UsesAuthorWhenTriggerDiffers(t *testing.T) { + author, behalf := squadCommitActor(74, &docgov.DocumentRecord{AuthorID: 4}) + if author != 4 { + t.Fatalf("commit actor want 4 got %d", author) + } + if behalf == nil || *behalf != 74 { + t.Fatalf("on behalf want 74 got %v", behalf) + } + same, behalfSame := squadCommitActor(4, &docgov.DocumentRecord{AuthorID: 4}) + if same != 4 || behalfSame != nil { + t.Fatalf("author trigger must not proxy: actor=%d behalf=%v", same, behalfSame) + } + missing, behalfMissing := squadCommitActor(74, &docgov.DocumentRecord{}) + if missing != 74 || behalfMissing != nil { + t.Fatalf("unset author must keep trigger: actor=%d behalf=%v", missing, behalfMissing) + } +} + +func TestExecuteTask_NonAuthorTriggerMaterializesAsAuthor(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const ( + projectID = int64(6022) + authorID = int64(4) + triggerID = int64(74) + docID = "doc_proj6022_bd_intake" + ) + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", authorID, "admin"); err != nil { + t.Fatal(err) + } + rec := &recordingMaterializer{inner: revMaterializer{rev: svc.docRev}} + svc.SetAgentMaterializer(rec) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "intake", ProjectID: projectID, ProjectName: "测试项目", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: triggerID, + }) + if err != nil { + t.Fatalf("ExecuteTask: %v reason=%v", err, res) + } + if res == nil || !res.Success { + t.Fatalf("non-author trigger must succeed, got %#v", res) + } + if rec.got.AutoCommitUserID != authorID { + t.Fatalf("AutoCommitUserID want %d got %d", authorID, rec.got.AutoCommitUserID) + } + if rec.got.OnBehalfOf == nil || *rec.got.OnBehalfOf != triggerID { + t.Fatalf("OnBehalfOf want %d got %v", triggerID, rec.got.OnBehalfOf) + } +} + +func TestExecuteTask_AuthorTriggerDoesNotSetOnBehalfOf(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const ( + projectID = int64(6023) + authorID = int64(4) + docID = "doc_proj6023_bd_intake" + ) + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", 0, ""); err != nil { + t.Fatal(err) + } + rec := &recordingMaterializer{inner: revMaterializer{rev: svc.docRev}} + svc.SetAgentMaterializer(rec) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "intake", ProjectID: projectID, ProjectName: "P", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: authorID, + }) + if err != nil || res == nil || !res.Success { + t.Fatalf("author trigger failed: err=%v res=%#v", err, res) + } + if rec.got.AutoCommitUserID != authorID || rec.got.OnBehalfOf != nil { + t.Fatalf("author path changed: user=%d behalf=%v", rec.got.AutoCommitUserID, rec.got.OnBehalfOf) + } +} + +func TestExecuteTask_NonAuthorPassesDutyAuthorizer(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const ( + projectID = int64(6024) + authorID = int64(4) + triggerID = int64(74) + docID = "doc_proj6024_bd_intake" + ) + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", 0, ""); err != nil { + t.Fatal(err) + } + svc.SetAgentMaterializer(&docflow.Commands{Gov: svc.docGov, Rev: svc.docRev, Auth: docflow.DutyAuthorizer()}) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "intake", ProjectID: projectID, ProjectName: "P", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: triggerID, + }) + if err != nil || res == nil || !res.Success { + t.Fatalf("duty authorizer must accept author-proxy commit: err=%v reason=%v", err, errorReason(res)) + } + if strings.Contains(errorReason(res), "permission denied") { + t.Fatalf("permission denied leaked: %s", res.ErrorReason) + } +} + +func TestExecuteTask_PermissionDeniedNamesNonAuthor(t *testing.T) { + svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"}) + ctx := context.Background() + const docID = "doc_perm_denied" + if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, 77, "research", "调研", "V0.5", "", 4, "admin", 0, "", 0, ""); err != nil { + t.Fatal(err) + } + svc.SetAgentMaterializer(&recordingMaterializer{err: uow.ErrPermissionDenied}) + + res, err := svc.ExecuteTask(ctx, TaskRunRequest{ + TaskKey: "research", ProjectID: 77, ProjectName: "P", + CustomerName: "C", DeviceType: "医疗器械", Backend: "stub", + Timeout: 10 * time.Second, DocID: docID, UserID: 74, + }) + if err == nil || res == nil || res.Success { + t.Fatalf("want visible permission failure, err=%v res=%#v", err, res) + } + if !strings.Contains(res.ErrorReason, "触发用户非文档编制人") { + t.Fatalf("reason must name non-author denial, got %q", res.ErrorReason) + } + if !errors.Is(err, uow.ErrPermissionDenied) { + t.Fatalf("sentinel must stay errors.Is compatible, got %v", err) + } +} + +type revMaterializer struct { + rev *docrevision.Service +} + +func (r revMaterializer) MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) { + return r.rev.MaterializeAgentOutput(ctx, req) +} + +func errorReason(res *TaskRunResult) string { + if res == nil { + return "" + } + return res.ErrorReason +}