chore(tooling): reconcile PI executor branch with current dev
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# 管理员重置密码 / 登录凭据缺陷 — 问题主记录
|
||||
|
||||
更新:2026-09-24。任务:`admin-password-reset-2026-09-24`。
|
||||
|
||||
## ISSUE-20260924-admin-reset-dead
|
||||
|
||||
### 问题
|
||||
|
||||
管理台「重置密码」按钮调用 `authClient.adminResetPassword`,客户端硬 `throw`,后端无 `reset-password` 路由;UI 仍硬编码 `ResetPass123` 暗示成功。验收脚本曾重置 `chenwei` 哈希且不披露口令后,管理员无法通过 UI 恢复访问。
|
||||
|
||||
### 根因
|
||||
|
||||
T20-F4 已设计未实施(选项 B 卡产品)。`password_changed_at` 停在 2026-09-20 证明重置从未入库。
|
||||
|
||||
### 修复
|
||||
|
||||
`POST /api/v1/admin/users/{id}/reset-password`:服务端随机临时口令、`must_change_password=true`、撤销会话、审计表 `user_password_reset_audits`(goose 00067);前端接线并去掉硬编码。
|
||||
|
||||
### 状态
|
||||
|
||||
resolved(本任务合入后)。
|
||||
|
||||
---
|
||||
|
||||
## ISSUE-20260924-login-error-envelope
|
||||
|
||||
### 问题
|
||||
|
||||
登录失败响应 `code=AUTHZ` / `message=无权访问`,前端再把整段 JSON 原文塞进 `Error.message`,用户误判为权限问题。
|
||||
|
||||
### 根因
|
||||
|
||||
`httpx.CodeFromStatus(401)=AUTHZ` + `SafeMessage`;`auth.ts` login 未用信封解析、未优先 `error` 领域字段。
|
||||
|
||||
### 修复
|
||||
|
||||
`authErrorMessage` 优先取信封 `error`;不改全局 AUTHZ 映射(事件复用与安全取舍另立专项)。
|
||||
|
||||
### 状态
|
||||
|
||||
resolved。
|
||||
|
||||
---
|
||||
|
||||
## ISSUE-20260924-zombie-credentials
|
||||
|
||||
### 问题
|
||||
|
||||
`CreateUser` 在 `PasswordHash==""` 时静默跳过 `user_credentials` 插入;`LEFT JOIN` + `COALESCE` 使登录哈希为空串,任意密码均失败且报错与真凭据错误相同。
|
||||
|
||||
### 盘点(只读,2026-09-24)
|
||||
|
||||
| 环境 | total | missing_cred | empty_username |
|
||||
|---|---|---|---|
|
||||
| dev | 416 | 263 | 255 |
|
||||
| test | 9 | 0 | 0 |
|
||||
| main | 4 | 3 | 0 |
|
||||
|
||||
main 三行为 goose 00011 seed(`seed-admin` 等),非业务用户。本任务**不清理**存量;仅 fail-closed 防新增。
|
||||
|
||||
### 修复
|
||||
|
||||
PgStore/MemoryStore `CreateUser` 拒绝空哈希;`Login` 对空哈希记 `slog.Warn(user_id)` 仍统一 401。
|
||||
|
||||
### 状态
|
||||
|
||||
resolved(机制);存量清理 = open / 另授权。
|
||||
@@ -0,0 +1,37 @@
|
||||
# admin-password-reset-2026-09-24
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| status | in_progress |
|
||||
| write_scope | internal, web, deploy/postgres/goose, .doc |
|
||||
| mutex | auth+env:test |
|
||||
| base_dev_sha | 3de5bc5c396fc21887f0f19e6e3baa8784347f99 |
|
||||
| review_state | pending |
|
||||
| integration_state | pending |
|
||||
| deployment | not_required(合入 local_dev;晋升 test 另授权) |
|
||||
| cleanup | pending |
|
||||
| remote_sync | not_requested |
|
||||
| policy_revision | AGENTS.md + worktree workflow 2026-09-21 |
|
||||
|
||||
## 用户目标
|
||||
|
||||
恢复 test 环境 `chenwei` 登录;实现 T20-F4-B 管理员重置密码 API;修无凭据僵尸账号防护;修正登录错误文案误导。
|
||||
|
||||
## 范围
|
||||
|
||||
- Phase 0:test 库一次性恢复(已完成;临时口令仅 agent store)
|
||||
- Phase 1–3:后端 API + goose 00067 + 前端接线 + CreateUser fail-closed
|
||||
- Phase 4:单测/回归 + 报告 + ISSUES + 合入 local_dev
|
||||
|
||||
## 禁止
|
||||
|
||||
- 未授权 `git push` / 晋升 test/main
|
||||
- 临时口令写入 git / 聊天 / 报告正文
|
||||
- 写入 main/dev 生产僵尸账号数据清理
|
||||
|
||||
## 验证计划
|
||||
|
||||
- `./scripts/arch-check.sh --mode block`;`go vet ./...`;`go test -short ./...`
|
||||
- `web npm test` / `npm run build`
|
||||
- 新增 AdminResetPassword 正负例与 authErrorMessage 单测
|
||||
- Phase 0 实测 login 200(证据不披露口令)
|
||||
@@ -0,0 +1,97 @@
|
||||
# agentctl coordinator daemon 装载记录(Linux/systemd)
|
||||
|
||||
- task_id: agentctl-daemon-load-2026-09-24
|
||||
- owner: Cursor
|
||||
- 授权来源:用户会话确认(2026-09-24 09:37,"装载 systemd 单元"选项明示批准)
|
||||
- base_dev_sha: 5423b261f97f371bdf7fa5066e9305f9eddaef42
|
||||
- status: done(验证完成;文档合入待集成)
|
||||
- write_scope: .doc/ops/short-term/(本文档)、.doc/ops/CURRENT.md(docs apply 认领行)
|
||||
- mutex: agentctl-daemon-load
|
||||
- review_state: reviewed(自查,见下)
|
||||
- integration_state: pending
|
||||
- deployment: n/a(daemon 已在本机装载运行,即本文档记录的对象)
|
||||
- cleanup: pending
|
||||
- remote_sync: no(未 push)
|
||||
|
||||
## 一、背景
|
||||
|
||||
用户报告"本机的 agentctl 似乎没有在运作"。检查结论:
|
||||
|
||||
- CLI 管理面(`daemon status` / `doctor` / `queue list`)与 Git 门禁(pre-commit 钩子、`PROTECTED_BRANCH` 拒绝)均正常。
|
||||
- **coordinator daemon 从未装载**:`.locks/os/daemon.heartbeat` 与 `manager.lock` 不存在;`scripts/daemon/README.md` 标注 "Templates only. Not loaded on this machine"。本机为 Linux/systemd(规范模板中 launchd 适用于 macOS),systemd 模板从未实例化。
|
||||
- 附带发现两条死进程残留 run 卡住 `env:dev`(详见下节)。
|
||||
|
||||
## 二、操作记录
|
||||
|
||||
### 1. 残留 run 恢复(前置清理)
|
||||
|
||||
两条 run 属任务 `dev-full-gate-test-promo-2026-09-24`,进程经 `ps` 确认死亡、无运行副作用:
|
||||
|
||||
```bash
|
||||
python3 scripts/agentctl.py recover --run e072b136-c538-4d66-9100-f4966e02e8ec \
|
||||
--reason 'verified: pid 1993666/1993739 dead (ps confirmed, exit 143 SIGTERM); /proc absent; env:dev idle, no side effects; release stale lease from night supervisor kill'
|
||||
# → {"run_id": "e072b136-...", "status": "recovered"}
|
||||
|
||||
python3 scripts/agentctl.py recover --run d542d9d9-513e-4301-b303-8bf3c62bc681 \
|
||||
--reason 'verified: pid 1992327 dead (ps confirmed); busy placeholder from lock-wait timeout, no command executed; release stale env:dev slot'
|
||||
# → {"run_id": "d542d9d9-...", "status": "recovered"}
|
||||
```
|
||||
|
||||
恢复后 `doctor` 显示该任务新 run 正常 `running`(supervisor_alive=alive),租约链路恢复。
|
||||
|
||||
### 2. daemon 装载(本次主操作)
|
||||
|
||||
依据 `scripts/daemon/README.md` 的 fail-closed 装载门禁(显式授权 + 验证记录 + 回滚命令),按用户明示授权执行:
|
||||
|
||||
```bash
|
||||
# 实例化模板(替换 /path/to/Agent-System 占位符)
|
||||
sed -e "s|/path/to/Agent-System|/home/weichen/projects/Agent-System|g" \
|
||||
scripts/daemon/agentctl-coordinator.service \
|
||||
> ~/.config/systemd/user/agentctl-coordinator.service
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now agentctl-coordinator.service
|
||||
```
|
||||
|
||||
## 三、装载验证(fail-closed 证据)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| host | weichen(Linux 7.0.0-31-generic,systemd user session) |
|
||||
| unit | `~/.config/systemd/user/agentctl-coordinator.service`(enabled, preset: enabled) |
|
||||
| unit sha256 | `3ec73b4830a4f18c1b755ca750e146720362737fba445b39a8313be0131856a7` |
|
||||
| ActiveState/SubState | `active (running)` since 2026-09-24 01:34:59 UTC |
|
||||
| MainPID | 2120143 |
|
||||
| manager.lock | held=true, holder_pid=2120143 |
|
||||
| heartbeat | cycle 226(启动约 4.5 分钟),heartbeat_age_seconds≈0.5,持续 <2s |
|
||||
| queue | pending=0, claimed=0(无积压;daemon 轮转正常,每秒 1 周期 processed:0) |
|
||||
| 主目录分支 | dev(满足单元 WorkingDirectory 约束) |
|
||||
|
||||
journal 摘录(截选):
|
||||
|
||||
```text
|
||||
Sep 24 01:34:59 systemd[3852]: Started agentctl-coordinator.service - agentctl foreground coordinator (queue schedule under manager.lock).
|
||||
Sep 24 01:34:59 python3[2120143]: {"audit_every": 30, "interval": 1.0, "max_cycles": null, "pid": 2120143, "status": "daemon_started"}
|
||||
Sep 24 01:34:59 python3[2120143]: {"processed": 0, "results": [], "status": "ok"}
|
||||
Sep 24 01:39:07 python3[2120143]: {"processed": 0, "results": [], "status": "ok"}
|
||||
```
|
||||
|
||||
## 四、回滚(经确认后执行)
|
||||
|
||||
```bash
|
||||
systemctl --user disable --now agentctl-coordinator.service
|
||||
rm ~/.config/systemd/user/agentctl-coordinator.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
## 五、影响评估
|
||||
|
||||
- daemon 职责仅为驱动 `queue schedule`(已验证候选的 ff-only 集成调度);不触碰 `env:*`、不授予新能力(README 明示)。
|
||||
- 队列当前为空,装载不改变现有 `validate`+`integrate` 流程行为。
|
||||
- 未修改仓库内文件(单元文件在 `~/.config/systemd/user/`,仓库 tracked 文件零变更)。
|
||||
|
||||
## 六、后续
|
||||
|
||||
- daemon 已 `Restart=on-failure`,崩溃后 5s 自动拉起;无需人工干预。
|
||||
- 如需停用,见回滚一节(需重新确认)。
|
||||
- 本任务剩余收口:集成(integrate)到 local dev、CURRENT 行置 done、worktree 清理。
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "dev 全面门禁与 test 晋升(rc-v1.1.0-beta.3)"
|
||||
task_id: docs-gate-promo-report-2026-09-24
|
||||
status: done
|
||||
created: 2026-09-24
|
||||
updated: 2026-09-24
|
||||
owner: Cursor
|
||||
task_type: ops
|
||||
task_tier: multi_stage
|
||||
policy_revision: "AGENTS+worktree-ops @ 3de5bc5c"
|
||||
branch: feature/docs-gate-promo-report-2026-09-24
|
||||
worktree: .worktrees/docs-gate-promo-report-2026-09-24
|
||||
base_dev_sha: "8ca448ddca7682f851835978921473594eec0ad9"
|
||||
head_sha: ""
|
||||
write_scope: [".doc/"]
|
||||
allowed_environments: ["dev", "test"]
|
||||
mutex: git:dev+env:test+docs
|
||||
review_state: accepted
|
||||
integration_state: pending_merge
|
||||
deployment: test_ok_schema_66
|
||||
cleanup: pending
|
||||
cleanup_owner: 独立维护者
|
||||
cleanup_trigger: tip 为 local_dev/origin 祖先且无进程占用后 remove
|
||||
remote_sync: pending_push
|
||||
---
|
||||
|
||||
# dev 全面门禁与 test 晋升
|
||||
|
||||
## 用户目标与授权
|
||||
|
||||
- 用户目标:dev 全面深测后晋升 test(merge + `rc-v1.1.0-beta.3` + 部署验收 + 恢复基线 + push)
|
||||
- 授权来源:用户确认计划(推送远端 + test 验收后恢复基线);RERANKER 笔误已自改
|
||||
- 交付终点:见 [测试报告](../../test-reports/2026-09-24-dev-full-gate-test-promotion-test-report.md)
|
||||
|
||||
## 结果摘要
|
||||
|
||||
- nightly PASS @ `3de5bc5c`;promote M=`0e4b111d` + `rc-v1.1.0-beta.3`;test schema 66
|
||||
- 小队 intake completed / 6802 字 / 幂等;S1 restore MATCH
|
||||
@@ -0,0 +1,74 @@
|
||||
# 管理员重置密码与登录凭据修复 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
test 环境账号 `chenwei` 登录失败,表现为 `code=AUTHZ` / `message=无权访问` / `error=用户名或密码错误`。管理员 UI「重置密码」无法生效。根因调查确认:验收期曾重置哈希且不披露口令;重置按钮为 T20-F4 死功能;前端把信封 JSON 原文展示给用户。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- Phase 0:恢复 `chenwei` 可登录且 `must_change_password=true`
|
||||
- Admin 重置 API:成功路径 + 非管理员 403 + 目标不存在 4xx + 旧会话失效
|
||||
- 登录错误文案优先领域 `error`,不再展示裸 JSON / 「无权访问」误导
|
||||
- CreateUser 拒绝空哈希;空哈希登录统一 401
|
||||
- Go short 全量与前端测试通过
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
- OS: Linux;Go short;web npm test/build
|
||||
- test 环境:`http://127.0.0.1:8081`,schema 65(运行时);代码侧新增 goose 00067(合入后待迁移)
|
||||
- **NOT REQUIRED**:nightly 真实 PI 全链路(依据:本变更为 IAM/管理台,非产品 PI / 工作流模板 / 提示词;见 PROJECT.md §8 与测试报告规范「纯工程」例外说明)
|
||||
- **NOT REQUIRED(本报告)**:test 环境 live `reset-password` API 冒烟 — 代码尚未晋升/重建到 test 容器;以 httptest 集成测 + Phase 0 SQL 恢复为验收。晋升后应补跑 live API。
|
||||
|
||||
证据目录:[assets/2026-09-24-admin-password-reset/](./assets/2026-09-24-admin-password-reset/)
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
| 用例 | 预期 |
|
||||
|---|---|
|
||||
| chenwei 临时口令登录 | 200,token,must_change_password=true |
|
||||
| AdminResetPassword | 200,返回 temp_password;旧密 401;旧会话 401 |
|
||||
| 非管理员重置 | 403 |
|
||||
| authErrorMessage | 「用户名或密码错误」,无 `{`,非「无权访问」 |
|
||||
| CreateUser 空哈希 | 错误 `password hash required` |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 用例 1:Phase 0 chenwei 恢复登录
|
||||
|
||||
- **输入**:test 库 UPDATE credentials + must_change;`POST /api/v1/auth/login`
|
||||
- **实际输出**:见 `assets/.../phase0-login-verify.txt`:`HTTP 200` / `LOGIN_OK` / `must_change_password True`;`password_changed_at` 日期 2026-09-24
|
||||
- **判定**:PASS(口令不入报告,路径仅 agent store)
|
||||
|
||||
### 用例 2:AdminResetPassword 集成测
|
||||
|
||||
- **输入**:`go test -short -run AdminReset|... ./internal/auth/ ./internal/server/`
|
||||
- **实际输出**:见 `assets/.../go-focused.txt`:`ok auth` / `ok server`
|
||||
- **判定**:PASS
|
||||
|
||||
### 用例 3:前端信封解析
|
||||
|
||||
- **输入**:`authErrorMessage` 单测 + `npm test`
|
||||
- **实际输出**:497 + 34 vitest PASS;`authErrorMessage` 断言通过
|
||||
- **判定**:PASS
|
||||
|
||||
### 用例 4:三环境凭据盘点(只读)
|
||||
|
||||
- **输入**:SQL 聚合
|
||||
- **实际输出**:见 `cred-inventory.txt` — dev 263 missing / main 3 seed missing / test 0
|
||||
- **判定**:PASS(盘点);存量清理不在本任务
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
```text
|
||||
./scripts/arch-check.sh --mode block # PASS
|
||||
go vet ./... # PASS
|
||||
go test -short ./... # PASS(server ~18s)
|
||||
cd web && npm test # 497 + 34 PASS
|
||||
cd web && npm run build # PASS
|
||||
```
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
- 达到合入 local_dev 标准。
|
||||
- 合入后:授权晋升 test → 迁移 00067 → rebuild → 用管理台真实重置 API 验收。
|
||||
- 存量僵尸账号清理需独立授权;main seed 三行非业务账号。
|
||||
@@ -0,0 +1,107 @@
|
||||
# 2026-09-24 dev 全面门禁与 test 晋升 — 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
本地 `dev` 领先 `origin/test@9984c68d`(schema 65)多轮合入(含 go-agentx、FAV、jevdecide assist、intentflow U9–U12、squad/coordinator/workspace)。本轮按验证契约做 dev 全面深测(**重点保障真实智能体小队链路**),全绿后晋升 test 并打 `rc-v1.1.0-beta.3`,部署验收后恢复 S1 基线。
|
||||
|
||||
过程中发现并修复:`cleanHeadingTitle` 多位中文序数未剥离、nightly 相对 `DATA_DIR` 导致 workspace 与 pi-agent 挂载分叉、FullChain 固定项目名 409;另修正宿主机 `.env` 中 `RERANKER_MODEL` 笔误(`qwen3-reranky`→`qwen3-rerank`,reranker 503 fail-open 不挡门禁)。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- 静态/单元门禁全绿;真实 PI nightly(U8 + FullChain×3 + FullNoneMixed)**全部 PASS** 并写 promotion receipt
|
||||
- 合入修复后冻结 tip,晋升 test:`--no-ff` 第一父=`origin/test@9984c68d`,tag `rc-v1.1.0-beta.3`,schema **66**
|
||||
- test 验收:冒烟、真实小队 intake、助手多轮/stream、演示数据负例;`pg_restore` 回 S1
|
||||
- push `origin/dev` + `origin/test` + rc tag;文档收口
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| OS | Linux |
|
||||
| Go | 仓库 go.mod 基线 |
|
||||
| 冻结 nightly tip | `3de5bc5c`(abs DATA_DIR + unique FullChain + heading 祖先) |
|
||||
| promote tip M | `0e4b111d`(P1=`9984c68d`,含 `3de5bc5c`) |
|
||||
| rc tag | `rc-v1.1.0-beta.3` → `0e4b111d` |
|
||||
| dev Runner | `127.0.0.1:18865` / MiniMax-M2.7-highspeed |
|
||||
| test Runner | `127.0.0.1:18866` / MiniMax-M2.7-highspeed |
|
||||
| 隔离治理 PG | gate-pg `:5436` / `agent_gate` |
|
||||
| S0 | `/tmp/agent-test-snap-s0-pre-beta3-20260924T043533Z` |
|
||||
| S1 | `/tmp/agent-test-snap-s1-post-beta3-20260924T044504Z`(goose 66 / projects 9 / task_runs 9) |
|
||||
|
||||
分层:arch-check / `go vet` / `go test -short` / npm test+build / agentctl unittest → nightly 真实 PI → test 冒烟+小队+助手 → restore。
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
- nightly 五例均 `PASS`,receipt `verdict=pass`,candidate images digests 齐备
|
||||
- test health `schema_version=66`,pinned image digest 与 receipt 一致
|
||||
- 小队 intake:`completed`、正文 ≥1500 字、`client_turn_id` 幂等
|
||||
- stream 含 `turn_ack`→`delta`→`done`;演示项目 id 6001–6010 交集为空
|
||||
- restore 后 goose/projects/task_runs 与 S1 manifest 一致
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 静态与单元(dev)
|
||||
|
||||
- arch-check / go vet / go test -short / npm test+build / scripts unittest:前序门禁轮次 PASS(证据目录 `/tmp/dev-full-gate-20260924/`)
|
||||
- 定向:`go test ./internal/squad ./internal/coordinator ./internal/workspace` PASS
|
||||
|
||||
### 5.2 真实 PI nightly(重点)
|
||||
|
||||
- **Input**:`run-nightly-real-pi.sh` @ `3de5bc5c`,`TEST_DATABASE_URL=…:5436/agent_gate`,绝对 `DATA_DIR=…/data/dev`,导出 `KB_DB_PASSWORD`
|
||||
- **Actual**(`/tmp/dev-full-gate-20260924/nightly-r9-033243`):
|
||||
|
||||
| 用例 | 结果 | 耗时 |
|
||||
|------|------|------|
|
||||
| TestMultiIntentU8_RealPI_ConfirmGenerate_PG | PASS | 214.76s |
|
||||
| TestWorkflowGovernance_PiAgent_PG_FullNoneMixedChain | PASS | 932.89s |
|
||||
| TestWorkflowGovernance_PiAgent_PG_FullChain | PASS | 307.07s |
|
||||
| …_ProductManager_FullChain | PASS | 894.51s |
|
||||
| …_TestManager_FullChain | PASS | 1316.52s |
|
||||
|
||||
- receipt:[`3de5bc5c….json`](./assets/promotion-receipts/3de5bc5c396fc21887f0f19e6e3baa8784347f99.json) `verdict=pass`;images:同前缀 `.images.json`
|
||||
- **Verdict**: **PASS**
|
||||
|
||||
### 5.3 test 晋升与部署
|
||||
|
||||
- **Input**:候选 `0e4b111d`(共享 CURRENT/MATRIX 保留 test 侧以避免 DOCUMENT_RECEIPT);`candidate-images-apply.sh test`;`env-service.sh test rebuild`(`SEED_DEMO_USERS=false`,pinned)
|
||||
- **Actual**:health schema **66**;app image `sha256:3bbf11c6…`(与 receipt 一致);端口 8081/5434/18866
|
||||
- **Verdict**: **PASS**
|
||||
|
||||
### 5.4 test 冒烟 / 负例
|
||||
|
||||
- smoke ready/health/SPA/runner:PASS([`smoke.txt`](./assets/2026-09-24-dev-full-gate-test-promotion/smoke.txt)、[`health.json`](./assets/2026-09-24-dev-full-gate-test-promotion/health.json))
|
||||
- 演示 id 负例:`overlap=[]`([`demo-negative.txt`](./assets/2026-09-24-dev-full-gate-test-promotion/demo-negative.txt))
|
||||
- **Verdict**: **PASS**
|
||||
|
||||
### 5.5 真实小队 intake(test)
|
||||
|
||||
- **Input**:项目 11 激活 main(SoD 26/27/28);`mtx_owner` `POST /api/v1/tasks/runs` `task_key=intake` `backend=pi-agent`
|
||||
- **Actual**:
|
||||
- create 202,`run_id=run_1790225874439969653`,≈300s 后 `completed`([`34b-summary.txt`](./assets/2026-09-24-dev-full-gate-test-promotion/34b-summary.txt))
|
||||
- `output.md` chars=6802 ≥1500([`36b-intake-output-summary.txt`](./assets/2026-09-24-dev-full-gate-test-promotion/36b-intake-output-summary.txt))
|
||||
- 同 `client_turn_id` 幂等 `same_run=True`([`37b-idempotent-summary.txt`](./assets/2026-09-24-dev-full-gate-test-promotion/37b-idempotent-summary.txt))
|
||||
- **Verdict**: **PASS**
|
||||
|
||||
### 5.6 助手多轮 / stream
|
||||
|
||||
- sync chat:turn1 `CASUAL_CHAT` `reply_len=1322`(`reply_source=template`);turn2 `SUPPLEMENT_INFO`(见 summaries)
|
||||
- stream:`kinds=['turn_ack','delta','done',…]`([`chat-stream-summary.txt`](./assets/2026-09-24-dev-full-gate-test-promotion/chat-stream-summary.txt)、[`chat-stream.sse`](./assets/2026-09-24-dev-full-gate-test-promotion/chat-stream.sse))
|
||||
- **Verdict**: **PASS**(stream 契约满足;sync 路径存在 template 回落,记为观察项,不挡本轮晋升——小队真实 PI 与 nightly 已覆盖核心链路)
|
||||
|
||||
### 5.7 恢复 S1
|
||||
|
||||
- **Actual**:[`50-restore-s1.txt`](./assets/2026-09-24-dev-full-gate-test-promotion/50-restore-s1.txt) — goose=66、projects=9、task_runs=9、users=9;与 S1 manifest 一致;验证 workspace 已清
|
||||
- S0 保留于 `/tmp/agent-test-snap-s0-pre-beta3-20260924T043533Z`
|
||||
- **Verdict**: **PASS**
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
- `go test -short ./...`:门禁轮次 PASS(`/tmp/dev-full-gate-20260924/03-go-test-short*.txt`)
|
||||
- nightly 真实 PI:见 §5.2(非 stub)
|
||||
- 工程规范变更不适用产品 PI 全量冒充:本报告产品链路均为真实 Runner
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
- **结论**:达到合并/晋升标准。`rc-v1.1.0-beta.3` @ `0e4b111d` 已在 test 落地 schema **66**;小队真实 intake + nightly 五链 PASS;库已回 S1。
|
||||
- **非阻塞**:embedding 401 运维债;sync chat `reply_source=template` 观察;FAV live 不放量、U9–U12 tier 停 confirm、D7 不物理删除——约束不变。
|
||||
- **收口**:`review_state=accepted`;`integration_state=merged_local_dev` + test 已晋升;`deployment=test_ok`;`remote_sync` 见 push 结果;`cleanup=pending`。
|
||||
@@ -0,0 +1,19 @@
|
||||
=== INV inventory 2026-09-24T04:15:07Z ===
|
||||
--- dev ---
|
||||
total_users | missing_cred | empty_username
|
||||
-------------+--------------+----------------
|
||||
416 | 263 | 255
|
||||
(1 row)
|
||||
|
||||
--- test ---
|
||||
total_users | missing_cred | empty_username
|
||||
-------------+--------------+----------------
|
||||
9 | 0 | 0
|
||||
(1 row)
|
||||
|
||||
--- main ---
|
||||
total_users | missing_cred | empty_username
|
||||
-------------+--------------+----------------
|
||||
4 | 3 | 0
|
||||
(1 row)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ok chat-agent-system/internal/auth 0.500s
|
||||
ok chat-agent-system/internal/server 0.852s
|
||||
@@ -0,0 +1,8 @@
|
||||
HTTP 200
|
||||
username chenwei
|
||||
user_id 35
|
||||
must_change_password True
|
||||
has_token True
|
||||
LOGIN_OK
|
||||
password_redacted=true
|
||||
store_path=/run/user/1000/cursor_agent_stores/2a07f859-5ca4-479f-b61d-b5c62ea42dee/files/chenwei-test-temp-password.json
|
||||
@@ -0,0 +1 @@
|
||||
admin_id=4 mtx_id=26 must_change_admin=False
|
||||
@@ -0,0 +1 @@
|
||||
11
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"project_id": 11,
|
||||
"lifecycle_status": "active",
|
||||
"active_workflow_key": "main",
|
||||
"instance": {
|
||||
"id": 8,
|
||||
"project_id": 11,
|
||||
"workflow_version_id": 13,
|
||||
"instance_no": 1,
|
||||
"status": "active",
|
||||
"config_revision": 1,
|
||||
"plan_hash": "eb9bde6660dbf70d1dcbe6e4a323db1d79b88ed2edac8003741ff8d7d8b0e3ce",
|
||||
"config_snapshot": {
|
||||
"workflow_definition_key": "main",
|
||||
"workflow_version": 2,
|
||||
"workflow_version_id": 13,
|
||||
"stages": [
|
||||
{
|
||||
"stage_key": "V0.1_triage",
|
||||
"stage_name": "V0.1 需求摸底",
|
||||
"enabled": true,
|
||||
"doc_tasks": [
|
||||
{
|
||||
"task_key": "bd_intake",
|
||||
"task_title": "用户初始需求收集表",
|
||||
"enabled": true,
|
||||
"approval_configurable": true,
|
||||
"duties": {
|
||||
"author_user_id": 26,
|
||||
"reviewer_user_id": 27,
|
||||
"approver_user_id": 28
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stage_key": "V0.5_feasibility",
|
||||
"stage_name": "V0.5 方案与可行性",
|
||||
"enabled": true,
|
||||
"doc_tasks": [
|
||||
{
|
||||
"task_key": "bd_feasibility_dev",
|
||||
"task_title": "设计开发可行性评估报告",
|
||||
"enabled": false,
|
||||
"approval_configurable": true,
|
||||
"duties": {}
|
||||
},
|
||||
{
|
||||
"task_key": "bd_feasibility_reg",
|
||||
"task_title": "注册路径可行性评估报告",
|
||||
"enabled": false,
|
||||
"approval_configurable": true,
|
||||
"duties": {}
|
||||
},
|
||||
{
|
||||
"task_key": "bd_prelim_design",
|
||||
"task_title": "系统初步设计方案",
|
||||
"enabled": false,
|
||||
"approval_configurable": true,
|
||||
"duties": {}
|
||||
},
|
||||
{
|
||||
"task_key": "bd_research",
|
||||
"task_title": "调研报告",
|
||||
"enabled": false,
|
||||
"approval_configurable": true,
|
||||
"duties": {}
|
||||
},
|
||||
{
|
||||
"task_key": "bd_user_requirements",
|
||||
"task_title": "用户需求表",
|
||||
"enabled": false,
|
||||
"approval_configurable": true,
|
||||
"duties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"stage_key": "V1.0_quoting",
|
||||
"stage_name": "V1.0 商务报价",
|
||||
"enabled": true,
|
||||
"doc_tasks": [
|
||||
{
|
||||
"task_key": "bd_cdo_quote",
|
||||
"task_title": "CDO 报价单",
|
||||
"enabled": false,
|
||||
"approval_configurable": true,
|
||||
"duties": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"activated_by": 4,
|
||||
"activated_at": "2026-09-24T04:57:54.434372Z",
|
||||
"created_by": 4,
|
||||
"created_at": "2026-09-24T04:57:54.426343Z",
|
||||
"updated_at": "2026-09-24T04:57:54.434372Z"
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"run_id": "run_1790225874439969653",
|
||||
"status": "running"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
run_id=run_1790225874439969653 status=completed elapsed=300.3s
|
||||
+1
@@ -0,0 +1 @@
|
||||
path=/home/weichen/projects/Agent-System/data/test/workspaces/bizdev/intake_1790225875990871262/output.md chars=6802 status=completed
|
||||
+1
@@ -0,0 +1 @@
|
||||
http=202 same_run=True run_id=run_1790225874439969653
|
||||
@@ -0,0 +1,5 @@
|
||||
health={"status":"ok","env":"test","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":66,"schema_ready":true,"task_store_ready":true,"vault_identity_ok":false,"vault_connected":false,"vault_lag_ok":true}
|
||||
goose=66
|
||||
projects=9
|
||||
task_runs=9
|
||||
users=9
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
kinds=['delta', 'done', 'phase', 'status', 'turn_ack']
|
||||
lines=24
|
||||
@@ -0,0 +1,24 @@
|
||||
event: phase
|
||||
data: {"id":"evt_1790225227040420430_1","type":"phase","timestamp":"2026-09-24T04:47:07.04042131Z","phase":{"phase_id":"intent_recognizing","message":"正在研判您的意图与项目上下文...","role":"coordinator"}}
|
||||
|
||||
event: turn_ack
|
||||
data: {"id":"evt_1790225227042183457_2","type":"turn_ack","timestamp":"2026-09-24T04:47:07.042184847Z","turn":{"client_turn_id":"beta3-stream-1790225213","user_message_id":64,"status":"processing"}}
|
||||
|
||||
event: status
|
||||
data: {"id":"evt_1790225227043422481_3","type":"status","timestamp":"2026-09-24T04:47:07.043423081Z","status":{"intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","current_stage":"V0.1_triage","message":"识别分支: AMBIGUOUS_FALLBACK (置信度: 20%)"}}
|
||||
|
||||
event: phase
|
||||
data: {"id":"evt_1790225227709969798_4","type":"phase","timestamp":"2026-09-24T04:47:07.709971408Z","phase":{"phase_id":"dialogue_generating","message":"正在整理回复...","role":"assistant","progress":40}}
|
||||
|
||||
event: status
|
||||
data: {"id":"evt_1790225227710044446_5","type":"status","timestamp":"2026-09-24T04:47:07.710044766Z","status":{"intent":"CASUAL_CHAT","branch":"AMBIGUOUS_FALLBACK","current_stage":"V0.1_triage","message":"意图判定: CASUAL_CHAT (置信度: 20%)"}}
|
||||
|
||||
event: phase
|
||||
data: {"id":"evt_1790225227710270889_6","type":"phase","timestamp":"2026-09-24T04:47:07.710271199Z","phase":{"phase_id":"dialogue_generating","message":"正在生成项目助手回复...","role":"assistant","progress":60}}
|
||||
|
||||
event: delta
|
||||
data: {"id":"evt_1790225232805789277_7","type":"delta","timestamp":"2026-09-24T04:47:12.805790577Z","delta":"您好,我在的。有什么需要我协助的吗?"}
|
||||
|
||||
event: done
|
||||
data: {"id":"evt_1790225232805836466_8","type":"done","timestamp":"2026-09-24T04:47:12.805836846Z","done":{"session_id":"sess_beta3_chat_1790225213-stream","client_turn_id":"beta3-stream-1790225213","duration_ms":5765,"total_events":7,"user_message_id":64,"message_id":65,"status":"completed","content_revision":1,"content_sha256":"fecdb9d7f552f5666e69077b7fe9b2ffc995c41aa562e53e77331b7e1ba5e382","export_capability":{"policy":"full","formats":["rich_clipboard","markdown","docx","pdf","private_document"],"body_selector":"content_only","content_revision":1,"content_sha256":"fecdb9d7f552f5666e69077b7fe9b2ffc995c41aa562e53e77331b7e1ba5e382"}}}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
turn=1 http=200 intent=CASUAL_CHAT reply_source=template reply_len=1322 duration=13.5s
|
||||
@@ -0,0 +1 @@
|
||||
turn=2 http=200 intent=SUPPLEMENT_INFO reply_source=None reply_len=45 duration=0.0s
|
||||
@@ -0,0 +1,2 @@
|
||||
ids=[10, 9, 8, 7, 5, 3, 2]
|
||||
overlap=[]
|
||||
@@ -0,0 +1 @@
|
||||
{"status":"ok","env":"test","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":66,"schema_ready":true,"task_store_ready":true,"vault_identity_ok":false,"vault_connected":false,"vault_lag_ok":true}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"service":"shuimu-pi-agent-runner","endpoint_url":"https://api.minimaxi.com/v1","model_id":"MiniMax-M2.7-highspeed","api":"openai-compatible","has_token":true,"tavily":{"active":true,"keys_count":2},"data_dir":"/app/data/workspaces","uptime":55.936716389}
|
||||
@@ -0,0 +1,2 @@
|
||||
[PASS] test app SPA /
|
||||
[PASS] test app deep link /projects
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"commit": "3de5bc5c396fc21887f0f19e6e3baa8784347f99",
|
||||
"source_tree": "5d407ffa231db42fbffdfb21cd0b9a65b9792f9f",
|
||||
"source_context": "git-archive",
|
||||
"recorded_at": "2026-09-24T04:34:11Z",
|
||||
"tags": {
|
||||
"app": "shuimu-agent-app:candidate-3de5bc5c396f",
|
||||
"events": "shuimu-agent-events:candidate-3de5bc5c396f",
|
||||
"pi-agent": "shuimu-pi-agent:candidate-3de5bc5c396f"
|
||||
},
|
||||
"image_ids": {
|
||||
"app": "sha256:3bbf11c62a7e62cf5853b26b94bb9c0b88004fbb76c9b04d4729b9b42397ee90",
|
||||
"events": "sha256:3bbf11c62a7e62cf5853b26b94bb9c0b88004fbb76c9b04d4729b9b42397ee90",
|
||||
"pi-agent": "sha256:b0a173a7b17ec7997237b51a23486982bd1e9b9ba91cd4c184e6f2045bbfaf7b"
|
||||
},
|
||||
"image_digests": {
|
||||
"app": "shuimu-agent-app@sha256:3bbf11c62a7e62cf5853b26b94bb9c0b88004fbb76c9b04d4729b9b42397ee90",
|
||||
"events": "shuimu-agent-app@sha256:3bbf11c62a7e62cf5853b26b94bb9c0b88004fbb76c9b04d4729b9b42397ee90",
|
||||
"pi-agent": "shuimu-pi-agent@sha256:b0a173a7b17ec7997237b51a23486982bd1e9b9ba91cd4c184e6f2045bbfaf7b"
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"verdict": "pass",
|
||||
"commit": "3de5bc5c396fc21887f0f19e6e3baa8784347f99",
|
||||
"recorded_at": "2026-09-24T04:34:11Z",
|
||||
"runner_url": "http://127.0.0.1:18865",
|
||||
"model_id": "MiniMax-M2.7-highspeed",
|
||||
"protocol": "typed-result-v1",
|
||||
"image_digests": {
|
||||
"app": "shuimu-agent-app@sha256:3bbf11c62a7e62cf5853b26b94bb9c0b88004fbb76c9b04d4729b9b42397ee90",
|
||||
"events": "shuimu-agent-app@sha256:3bbf11c62a7e62cf5853b26b94bb9c0b88004fbb76c9b04d4729b9b42397ee90",
|
||||
"pi-agent": "shuimu-pi-agent@sha256:b0a173a7b17ec7997237b51a23486982bd1e9b9ba91cd4c184e6f2045bbfaf7b"
|
||||
},
|
||||
"suite": "nightly-real-pi-u8-four-workflows",
|
||||
"failure_class": "none",
|
||||
"evidence_refs": [
|
||||
"/tmp/dev-full-gate-20260924/nightly-r9-033243/evidence/go-test.txt",
|
||||
"/tmp/dev-full-gate-20260924/nightly-r9-033243/evidence/runner-health.json"
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,8 @@ Thumbs.db
|
||||
|
||||
# Runtime Data, Logs, and Databases
|
||||
/data/
|
||||
# go test cwd=internal/server + relative DATA_DIR writes here by mistake
|
||||
internal/server/data/
|
||||
logs/
|
||||
*.log
|
||||
*.sqlite
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- Admin password reset audit trail (no password or hash stored).
|
||||
CREATE TABLE IF NOT EXISTS user_password_reset_audits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
target_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
actor_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
request_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_password_reset_audits_target_created
|
||||
ON user_password_reset_audits (target_user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_password_reset_audits_actor_created
|
||||
ON user_password_reset_audits (actor_user_id, created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP INDEX IF EXISTS idx_user_password_reset_audits_actor_created;
|
||||
DROP INDEX IF EXISTS idx_user_password_reset_audits_target_created;
|
||||
DROP TABLE IF EXISTS user_password_reset_audits;
|
||||
@@ -0,0 +1,103 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/config"
|
||||
)
|
||||
|
||||
func TestGenerateTempPassword(t *testing.T) {
|
||||
pw, err := auth.GenerateTempPassword(16)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pw) != 16 {
|
||||
t.Fatalf("len=%d", len(pw))
|
||||
}
|
||||
for _, r := range pw {
|
||||
if !strings.ContainsRune("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789", r) {
|
||||
t.Fatalf("unexpected rune %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateUserRejectsEmptyPasswordHash(t *testing.T) {
|
||||
store := auth.NewMemoryStore(&config.Config{Env: "dev", AllowInMemory: true})
|
||||
_, err := store.CreateUser(t.Context(), &auth.UserAccount{
|
||||
Username: "nohash",
|
||||
Name: "无哈希",
|
||||
Status: auth.StatusActive,
|
||||
RoleID: "role-guest",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "password hash required") {
|
||||
t.Fatalf("expected password hash required, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetPassword_Service(t *testing.T) {
|
||||
cfg := &config.Config{Env: "dev", AllowInMemory: true, AdminUsername: "admin", AdminPassword: "admin123"}
|
||||
store := auth.NewMemoryStore(cfg)
|
||||
svc := auth.NewService(store, nil, cfg)
|
||||
|
||||
created, err := svc.CreateUserByAdmin(t.Context(), auth.CreateUserRequest{
|
||||
Username: "svc_reset",
|
||||
Name: "服务重置",
|
||||
InitialPassword: "InitPass123",
|
||||
RoleID: "role-engineer",
|
||||
Position: "方案工程师",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := svc.AdminResetPassword(t.Context(), created.ID, 1, "req_test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.TempPassword == "" || !result.MustChangePassword {
|
||||
t.Fatalf("bad result %+v", result)
|
||||
}
|
||||
|
||||
_, err = svc.Login(t.Context(), "svc_reset", "InitPass123")
|
||||
if err == nil {
|
||||
t.Fatal("old password should fail")
|
||||
}
|
||||
session, err := svc.Login(t.Context(), "svc_reset", result.TempPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !session.MustChangePassword {
|
||||
t.Fatal("expected must_change_password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginMissingPasswordHash(t *testing.T) {
|
||||
cfg := &config.Config{Env: "dev", AllowInMemory: true, AdminUsername: "admin", AdminPassword: "admin123"}
|
||||
store := auth.NewMemoryStore(cfg)
|
||||
// Bypass CreateUser guard by mutating seeded user hash after create via UpdateUserPassword empty is not allowed;
|
||||
// plant via direct store map is not exported — use CreateUser with hash then clear via UpdateUserPassword with invalid.
|
||||
// Instead: create with hash then overwrite stored hash through UpdateUserPassword with a sentinel that CheckPassword rejects,
|
||||
// and separately verify empty-hash path by creating through internal seed is hard.
|
||||
// Use Login against unknown user for 401 uniformity, and empty-hash via GetUserByID mutation on MemoryStore UpdateUserPassword
|
||||
// with hash "" is allowed by UpdateUserPassword — plant empty hash that way.
|
||||
created, err := store.CreateUser(t.Context(), &auth.UserAccount{
|
||||
Username: "emptyhash",
|
||||
Name: "空哈希",
|
||||
PasswordHash: "placeholder",
|
||||
Status: auth.StatusActive,
|
||||
RoleID: "role-guest",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.UpdateUserPassword(t.Context(), created.ID, "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := auth.NewService(store, nil, cfg)
|
||||
_, err = svc.Login(t.Context(), "emptyhash", "anything")
|
||||
if err == nil || err.Error() != "用户名或密码错误" {
|
||||
t.Fatalf("expected unified credential error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// tempPasswordAlphabet excludes ambiguous characters (0/O, 1/l/I).
|
||||
const tempPasswordAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
|
||||
// HashPassword 使用 bcrypt 对明文密码进行安全加盐哈希
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
@@ -18,3 +25,20 @@ func CheckPassword(hashed, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hashed), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GenerateTempPassword returns a cryptographically random temporary password.
|
||||
func GenerateTempPassword(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = 16
|
||||
}
|
||||
out := make([]byte, length)
|
||||
max := big.NewInt(int64(len(tempPasswordAlphabet)))
|
||||
for i := range out {
|
||||
n, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate temp password: %w", err)
|
||||
}
|
||||
out[i] = tempPasswordAlphabet[n.Int64()]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou
|
||||
if user == nil || strings.TrimSpace(user.Username) == "" {
|
||||
return nil, errors.New("username required")
|
||||
}
|
||||
if strings.TrimSpace(user.PasswordHash) == "" {
|
||||
return nil, errors.New("password hash required")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -161,14 +164,12 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou
|
||||
return nil, fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
|
||||
if user.PasswordHash != "" {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_credentials (user_id, password_hash)
|
||||
VALUES ($1, $2)
|
||||
`, id, user.PasswordHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert credentials: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_credentials (user_id, password_hash)
|
||||
VALUES ($1, $2)
|
||||
`, id, user.PasswordHash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert credentials: %w", err)
|
||||
}
|
||||
|
||||
if user.RoleID != "" {
|
||||
@@ -315,6 +316,14 @@ func (s *PgStore) UpdateUserPassword(ctx context.Context, id int64, passwordHash
|
||||
return s.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *PgStore) RecordPasswordResetAudit(ctx context.Context, targetUserID, actorUserID int64, requestID string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_password_reset_audits (target_user_id, actor_user_id, request_id)
|
||||
VALUES ($1, $2, NULLIF($3, ''))
|
||||
`, targetUserID, actorUserID, strings.TrimSpace(requestID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PgStore) ListUsers(ctx context.Context) ([]*UserAccount, error) {
|
||||
rows, err := s.pool.Query(ctx, userSelectSQL+` ORDER BY u.id ASC`)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -83,6 +84,11 @@ func (s *Service) Login(ctx context.Context, username, password string) (*Sessio
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(user.PasswordHash) == "" {
|
||||
slog.Warn("auth_login_missing_password_hash", "user_id", user.ID)
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
|
||||
if !CheckPassword(user.PasswordHash, password) {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
@@ -193,6 +199,48 @@ func (s *Service) ChangePassword(ctx context.Context, userID int64, oldPass, new
|
||||
return err
|
||||
}
|
||||
|
||||
// AdminResetPasswordResult is returned once to the calling admin; plaintext must not be logged.
|
||||
type AdminResetPasswordResult struct {
|
||||
TempPassword string `json:"temp_password"`
|
||||
MustChangePassword bool `json:"must_change_password"`
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
|
||||
// AdminResetPassword generates a temporary password, persists its hash with
|
||||
// must_change_password=true, revokes existing sessions, and records an audit row.
|
||||
func (s *Service) AdminResetPassword(ctx context.Context, targetID, actorID int64, requestID string) (*AdminResetPasswordResult, error) {
|
||||
if targetID <= 0 {
|
||||
return nil, errors.New("无效的用户ID")
|
||||
}
|
||||
if actorID <= 0 {
|
||||
return nil, errors.New("无效的操作者")
|
||||
}
|
||||
if _, err := s.store.GetUserByID(ctx, targetID); err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
|
||||
temp, err := GenerateTempPassword(16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成临时口令失败: %w", err)
|
||||
}
|
||||
hash, err := HashPassword(temp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("临时口令加密失败: %w", err)
|
||||
}
|
||||
if _, err := s.store.UpdateUserPassword(ctx, targetID, hash, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.sessions.RevokeAllForUser(ctx, targetID)
|
||||
if err := s.store.RecordPasswordResetAudit(ctx, targetID, actorID, requestID); err != nil {
|
||||
return nil, fmt.Errorf("记录重置审计失败: %w", err)
|
||||
}
|
||||
return &AdminResetPasswordResult{
|
||||
TempPassword: temp,
|
||||
MustChangePassword: true,
|
||||
UserID: targetID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserByToken 根据会话 Token 查找登录用户
|
||||
func (s *Service) GetUserByToken(ctx context.Context, token string) (*UserAccount, error) {
|
||||
userID, err := s.sessions.ResolveUserID(ctx, HashToken(token))
|
||||
|
||||
@@ -19,6 +19,7 @@ type AuthStore interface {
|
||||
UpdateUserRole(ctx context.Context, id int64, roleID, position string) (*UserAccount, error)
|
||||
UpdateUserStatus(ctx context.Context, id int64, status AccountStatus) (*UserAccount, error)
|
||||
UpdateUserPassword(ctx context.Context, id int64, passwordHash string, mustChangePassword bool) (*UserAccount, error)
|
||||
RecordPasswordResetAudit(ctx context.Context, targetUserID, actorUserID int64, requestID string) error
|
||||
ListUsers(ctx context.Context) ([]*UserAccount, error)
|
||||
ListActiveUserDirectory(ctx context.Context) ([]UserDirectoryEntry, error)
|
||||
GetConfig(ctx context.Context) (SystemAuthConfig, error)
|
||||
@@ -155,6 +156,10 @@ func (s *MemoryStore) CreateUser(ctx context.Context, user *UserAccount) (*UserA
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if user == nil || strings.TrimSpace(user.PasswordHash) == "" {
|
||||
return nil, fmt.Errorf("password hash required")
|
||||
}
|
||||
|
||||
for _, existing := range s.users {
|
||||
if strings.EqualFold(existing.Username, user.Username) {
|
||||
return nil, fmt.Errorf("username already exists: %s", user.Username)
|
||||
@@ -176,6 +181,11 @@ func (s *MemoryStore) CreateUser(ctx context.Context, user *UserAccount) (*UserA
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RecordPasswordResetAudit records an admin password reset in memory (no persistence).
|
||||
func (s *MemoryStore) RecordPasswordResetAudit(ctx context.Context, targetUserID, actorUserID int64, requestID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户基础资料;actorID==user.ID 时清除管理员代改标记。
|
||||
func (s *MemoryStore) UpdateUser(ctx context.Context, user *UserAccount, actorID int64) (*UserAccount, error) {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/authz"
|
||||
"chat-agent-system/internal/httpx"
|
||||
)
|
||||
|
||||
// authIsInfrastructureError classifies DB/driver/tx failures that must not be
|
||||
@@ -383,6 +384,41 @@ func (s *Server) handleAdminUpdateStatus(w http.ResponseWriter, r *http.Request)
|
||||
_ = json.NewEncoder(w).Encode(updated)
|
||||
}
|
||||
|
||||
// handleAdminResetPassword POST /api/v1/admin/users/{id}/reset-password
|
||||
// Returns a one-time temporary password; must not be logged.
|
||||
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := s.requireSystemAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeSafeError(w, r, http.StatusMethodNotAllowed, "不支持的请求方法")
|
||||
return
|
||||
}
|
||||
|
||||
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || targetID <= 0 {
|
||||
writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.authSvc.AdminResetPassword(r.Context(), targetID, actor.ID, httpx.RequestID(r))
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
status := http.StatusBadRequest
|
||||
if strings.Contains(msg, "不存在") {
|
||||
status = http.StatusNotFound
|
||||
} else if authIsInfrastructureError(err) {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
writeSafeError(w, r, status, msg)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
// handleAuthPublicConfig 返回登录/注册页所需的公开准入配置(无需鉴权)
|
||||
func (s *Server) handleAuthPublicConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/auth"
|
||||
@@ -285,3 +286,114 @@ func TestAuthHandlers_AdminCreateUser_MustChangePassword(t *testing.T) {
|
||||
t.Errorf("Expected MustChangePassword to be false after changing password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandlers_AdminResetPassword(t *testing.T) {
|
||||
ts, authSvc := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
adminLoginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "admin123"})
|
||||
adminResp, err := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(adminLoginBody))
|
||||
if err != nil {
|
||||
t.Fatalf("admin login: %v", err)
|
||||
}
|
||||
var adminSession auth.SessionInfo
|
||||
_ = json.NewDecoder(adminResp.Body).Decode(&adminSession)
|
||||
adminResp.Body.Close()
|
||||
if adminSession.Token == "" {
|
||||
t.Fatal("admin token empty")
|
||||
}
|
||||
|
||||
created, err := authSvc.CreateUserByAdmin(t.Context(), auth.CreateUserRequest{
|
||||
Username: "reset_target",
|
||||
Name: "重置目标",
|
||||
InitialPassword: "OldPassword111",
|
||||
RoleID: "role-engineer",
|
||||
Position: "方案工程师",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
|
||||
oldLoginBody, _ := json.Marshal(map[string]string{"username": "reset_target", "password": "OldPassword111"})
|
||||
oldLoginResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(oldLoginBody))
|
||||
var oldSession auth.SessionInfo
|
||||
_ = json.NewDecoder(oldLoginResp.Body).Decode(&oldSession)
|
||||
oldLoginResp.Body.Close()
|
||||
if oldSession.Token == "" {
|
||||
t.Fatal("target old session empty")
|
||||
}
|
||||
|
||||
resetReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/"+itoa64(created.ID)+"/reset-password", bytes.NewReader([]byte("{}")))
|
||||
resetReq.Header.Set("Authorization", "Bearer "+adminSession.Token)
|
||||
resetReq.Header.Set("Content-Type", "application/json")
|
||||
resetResp, err := client.Do(resetReq)
|
||||
if err != nil {
|
||||
t.Fatalf("reset request: %v", err)
|
||||
}
|
||||
defer resetResp.Body.Close()
|
||||
if resetResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("reset status=%d", resetResp.StatusCode)
|
||||
}
|
||||
var resetBody auth.AdminResetPasswordResult
|
||||
if err := json.NewDecoder(resetResp.Body).Decode(&resetBody); err != nil {
|
||||
t.Fatalf("decode reset: %v", err)
|
||||
}
|
||||
if resetBody.TempPassword == "" || !resetBody.MustChangePassword || resetBody.UserID != created.ID {
|
||||
t.Fatalf("unexpected reset body: %+v", resetBody)
|
||||
}
|
||||
|
||||
// Old password no longer works.
|
||||
badResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(oldLoginBody))
|
||||
if badResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("old password expected 401, got %d", badResp.StatusCode)
|
||||
}
|
||||
badResp.Body.Close()
|
||||
|
||||
// Old session revoked.
|
||||
meReq, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/auth/me", nil)
|
||||
meReq.Header.Set("Authorization", "Bearer "+oldSession.Token)
|
||||
meResp, _ := client.Do(meReq)
|
||||
if meResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("old session expected 401, got %d", meResp.StatusCode)
|
||||
}
|
||||
meResp.Body.Close()
|
||||
|
||||
// Temp password works and forces change.
|
||||
tempLoginBody, _ := json.Marshal(map[string]string{"username": "reset_target", "password": resetBody.TempPassword})
|
||||
tempResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(tempLoginBody))
|
||||
var tempSession auth.SessionInfo
|
||||
_ = json.NewDecoder(tempResp.Body).Decode(&tempSession)
|
||||
tempResp.Body.Close()
|
||||
if tempResp.StatusCode != http.StatusOK || !tempSession.MustChangePassword {
|
||||
t.Fatalf("temp login status=%d must_change=%v", tempResp.StatusCode, tempSession.MustChangePassword)
|
||||
}
|
||||
|
||||
// Non-admin forbidden.
|
||||
memberLoginBody, _ := json.Marshal(map[string]string{"username": "zhanggong", "password": "123456"})
|
||||
memberResp, _ := http.Post(ts.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(memberLoginBody))
|
||||
var memberSession auth.SessionInfo
|
||||
_ = json.NewDecoder(memberResp.Body).Decode(&memberSession)
|
||||
memberResp.Body.Close()
|
||||
denyReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/"+itoa64(created.ID)+"/reset-password", bytes.NewReader([]byte("{}")))
|
||||
denyReq.Header.Set("Authorization", "Bearer "+memberSession.Token)
|
||||
denyReq.Header.Set("Content-Type", "application/json")
|
||||
denyResp, _ := client.Do(denyReq)
|
||||
if denyResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("non-admin expected 403, got %d", denyResp.StatusCode)
|
||||
}
|
||||
denyResp.Body.Close()
|
||||
|
||||
// Missing user.
|
||||
missingReq, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/admin/users/999999/reset-password", bytes.NewReader([]byte("{}")))
|
||||
missingReq.Header.Set("Authorization", "Bearer "+adminSession.Token)
|
||||
missingReq.Header.Set("Content-Type", "application/json")
|
||||
missingResp, _ := client.Do(missingReq)
|
||||
if missingResp.StatusCode != http.StatusNotFound && missingResp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("missing user expected 404/400, got %d", missingResp.StatusCode)
|
||||
}
|
||||
missingResp.Body.Close()
|
||||
}
|
||||
|
||||
func itoa64(v int64) string {
|
||||
return strconv.FormatInt(v, 10)
|
||||
}
|
||||
|
||||
@@ -348,6 +348,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole)
|
||||
mux.HandleFunc("PUT /api/v1/admin/users/{id}/status", s.handleAdminUpdateStatus)
|
||||
mux.HandleFunc("PATCH /api/v1/admin/users/{id}/profile", s.handleAdminUpdateProfile)
|
||||
mux.HandleFunc("POST /api/v1/admin/users/{id}/reset-password", s.handleAdminResetPassword)
|
||||
mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig)
|
||||
mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig)
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ func TestWorkflowGovernance_PiAgent_PG_FullChain(t *testing.T) {
|
||||
WfKey: workflowdef.OfficialKeyMain,
|
||||
TaskKey: "intake",
|
||||
StageKey: "V0.1_triage",
|
||||
ProjectName: "PG PiAgent 治理链中文项目",
|
||||
ProjectName: fmt.Sprintf("PG PiAgent 治理链 %d", time.Now().UnixNano()%1_000_000),
|
||||
Materials: []bizdev.MaterialInput{{
|
||||
Filename: "project_brief.md",
|
||||
Content: []byte("# 项目简报\n\n- 产品:超声软组织手术系统\n- 目标:NMPA III 类注册路径摸底\n- 关键需求:双频刀头、灌注与负压吸引\n"),
|
||||
@@ -280,7 +280,7 @@ func TestWorkflowGovernance_PiAgent_PG_ProductManager_FullChain(t *testing.T) {
|
||||
WfKey: workflowdef.OfficialKeyProductManager,
|
||||
TaskKey: "system_reqs",
|
||||
StageKey: "requirements_baseline",
|
||||
ProjectName: "PG PiAgent 产品经理治理链",
|
||||
ProjectName: fmt.Sprintf("PG PiAgent 产品经理 %d", time.Now().UnixNano()%1_000_000),
|
||||
ExpectPlanningDone: true,
|
||||
Materials: []bizdev.MaterialInput{{
|
||||
Filename: "intake_summary.md",
|
||||
@@ -295,7 +295,7 @@ func TestWorkflowGovernance_PiAgent_PG_TestManager_FullChain(t *testing.T) {
|
||||
WfKey: workflowdef.OfficialKeyTestManager,
|
||||
TaskKey: "test_manager",
|
||||
StageKey: "system_verification_planning",
|
||||
ProjectName: "PG PiAgent 测试经理治理链",
|
||||
ProjectName: fmt.Sprintf("PG PiAgent 测试经理 %d", time.Now().UnixNano()%1_000_000),
|
||||
ExpectPlanningDone: true,
|
||||
Materials: []bizdev.MaterialInput{{
|
||||
Filename: "verification_scope.md",
|
||||
|
||||
@@ -702,23 +702,56 @@ func extractHeadings(md string) []string {
|
||||
return headings
|
||||
}
|
||||
|
||||
func cleanHeadingTitle(title string) string {
|
||||
// 去除前缀序号如 "一、", "二、", "1.", "1.1", "(一)" 等
|
||||
t := strings.TrimSpace(title)
|
||||
runes := []rune(t)
|
||||
// 去掉中文序号如 "一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、", "十、"
|
||||
chineseNums := []string{"一、", "二、", "三、", "四、", "五、", "六、", "七、", "八、", "九、", "十、"}
|
||||
for _, cn := range chineseNums {
|
||||
if strings.HasPrefix(t, cn) {
|
||||
t = strings.TrimPrefix(t, cn)
|
||||
break
|
||||
}
|
||||
func isChineseNumeralRune(r rune) bool {
|
||||
switch r {
|
||||
case '零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '百', '千', '两':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// 去掉数字序号如 "1. ", "1、"
|
||||
}
|
||||
|
||||
// stripChineseOrdinalPrefix 去掉「一、」「十一、」「二十三、」等中文序号前缀。
|
||||
// 旧实现只枚举到「十、」,长文档 LLM 常用「十一、签署」会导致与模板「签署」对不上。
|
||||
func stripChineseOrdinalPrefix(t string) string {
|
||||
runes := []rune(t)
|
||||
if len(runes) == 0 || !isChineseNumeralRune(runes[0]) {
|
||||
return t
|
||||
}
|
||||
i := 0
|
||||
for i < len(runes) && isChineseNumeralRune(runes[i]) {
|
||||
i++
|
||||
}
|
||||
if i >= len(runes) {
|
||||
return t
|
||||
}
|
||||
switch runes[i] {
|
||||
case '、', '.', '.':
|
||||
i++
|
||||
default:
|
||||
return t
|
||||
}
|
||||
for i < len(runes) && (runes[i] == ' ' || runes[i] == '\t') {
|
||||
i++
|
||||
}
|
||||
return string(runes[i:])
|
||||
}
|
||||
|
||||
func stripArabicOrdinalPrefix(t string) string {
|
||||
runes := []rune(t)
|
||||
// 去掉数字序号如 "1. ", "1、", "1.1 "
|
||||
for len(runes) > 0 && ((runes[0] >= '0' && runes[0] <= '9') || runes[0] == '.' || runes[0] == '、' || runes[0] == ' ') {
|
||||
t = strings.TrimSpace(string(runes[1:]))
|
||||
runes = []rune(t)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func cleanHeadingTitle(title string) string {
|
||||
// 去除前缀序号如 "一、", "十一、", "1.", "1.1" 等
|
||||
t := strings.TrimSpace(title)
|
||||
t = stripChineseOrdinalPrefix(t)
|
||||
t = stripArabicOrdinalPrefix(t)
|
||||
// 去除可能括号后缀
|
||||
if idx := strings.Index(t, "("); idx > 0 {
|
||||
t = strings.TrimSpace(t[:idx])
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package squad
|
||||
|
||||
// CleanHeadingTitleForTest 暴露 cleanHeadingTitle,供同模块白盒用例验证序号清洗。
|
||||
var CleanHeadingTitleForTest = cleanHeadingTitle
|
||||
@@ -480,3 +480,55 @@ func TestQualityGate_ActionPlanStatus_DoesNotTriggerRepair(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 现场 nightly TestManager:LLM 写「## 十一、签署 / ## 十二、说明」,旧清洗只剥到「十、」会误报缺章。
|
||||
func TestCleanHeadingTitle_MultiDigitChineseOrdinal(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"一、客户与项目画像", "客户与项目画像"},
|
||||
{"十、附录", "附录"},
|
||||
{"十一、签署", "签署"},
|
||||
{"十二、说明", "说明"},
|
||||
{"二十三、风险评估", "风险评估"},
|
||||
{"1. 结论", "结论"},
|
||||
{"签署", "签署"},
|
||||
{"十一、签署(终稿)", "签署"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := squad.CleanHeadingTitleForTest(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("cleanHeadingTitle(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 现场 nightly TestManager:LLM 写「## 十一、签署 / ## 十二、说明」,旧清洗只剥到「十、」会误报缺章。
|
||||
func TestQualityGate_Validate_ChineseOrdinalElevenTwelve(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
engine := squad.NewWorkspaceEngine()
|
||||
ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{
|
||||
WorkDir: filepath.Join(tmpDir, "ws_tm"),
|
||||
TaskKey: "test_manager",
|
||||
StageTitle: "系统验证方案及计划",
|
||||
ProjectName: "医疗监护仪",
|
||||
TemplateContent: "# 系统验证方案及计划\n\n" +
|
||||
"## 结论\n\n## 用例与验证规程\n\n## 签署\n\n## 说明\n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareWorkspace: %v", err)
|
||||
}
|
||||
gate := squad.NewQualityGate()
|
||||
body := "# 系统验证方案及计划\n\n" +
|
||||
"## 一、结论\n本方案覆盖核心验证路径,结论可交付。\n\n" +
|
||||
"## 二、用例与验证规程\n| TC-01 | 验证心率测量精度 | 追溯 REQ-01 | 判定准则误差 <= ±2bpm | 通过 |\n\n" +
|
||||
"## 十一、签署\n产品负责人签字确认。\n\n" +
|
||||
"## 十二、说明\n本说明补充验证边界与假设。\n"
|
||||
rep, err := gate.Validate(ws.WorkDir, "test_manager", body)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if !rep.Passed {
|
||||
t.Fatalf("expected eleven/twelve ordinal headings to match template, violations=%v", rep.Violations)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,20 @@ export RUN_PI_E2E=1
|
||||
export KB_REQUIRE_ONLINE="${KB_REQUIRE_ONLINE:-1}"
|
||||
# Host Go tests must write workspaces into the same tree the runner mounts
|
||||
# (docker-compose.dev.yml: ./data/dev/workspaces → /app/data/workspaces).
|
||||
#
|
||||
# CRITICAL: `go test ./internal/server/...` runs with cwd=internal/server.
|
||||
# A relative DATA_DIR (e.g. ./data/dev from configs/.env.dev) resolves to
|
||||
# internal/server/data/dev and diverges from the pi-agent bind mount, so the
|
||||
# agent sees an empty workspace (missing template.md / blackboard.json) while
|
||||
# PrepareWorkspace wrote a complete tree under the package-local path.
|
||||
if [[ -n "${DATA_DIR:-}" && "${DATA_DIR}" != /* ]]; then
|
||||
echo "warning: ignoring relative DATA_DIR=${DATA_DIR}; forcing ${ROOT}/data/dev" >&2
|
||||
DATA_DIR="${ROOT}/data/dev"
|
||||
fi
|
||||
export DATA_DIR="${DATA_DIR:-${ROOT}/data/dev}"
|
||||
# Absolutize even if caller exported a relative path via other means.
|
||||
DATA_DIR="$(cd "${DATA_DIR}" 2>/dev/null && pwd -P || echo "${ROOT}/data/dev")"
|
||||
export DATA_DIR
|
||||
echo "==> DATA_DIR=${DATA_DIR}"
|
||||
|
||||
# Shared KB preflight (host :5432). Fail closed unless KB_ALLOW_OFFLINE=1.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createInMemoryAuthClient } from './auth.local';
|
||||
import { authErrorMessage } from './auth';
|
||||
|
||||
test('AuthClient: 基础登录与 .env 超级管理员鉴权', async () => {
|
||||
const client = createInMemoryAuthClient({
|
||||
@@ -112,3 +113,12 @@ test('AuthClient: 管理员创建账号必须在首次登录修改密码', async
|
||||
const updatedSession = await client.login({ username: 'lead_zhao', password: 'NewPermanentPassword888' });
|
||||
assert.equal(updatedSession.mustChangePassword, false);
|
||||
});
|
||||
|
||||
test('authErrorMessage: 优先信封 error 领域原因,避免裸 JSON / 无权访问误导', () => {
|
||||
const body =
|
||||
'{"code":"AUTHZ","message":"无权访问","request_id":"req_x","error":"用户名或密码错误"}';
|
||||
const msg = authErrorMessage({ code: 'AUTHZ', message: '无权访问' }, body, '用户名或密码错误');
|
||||
assert.equal(msg, '用户名或密码错误');
|
||||
assert.ok(!msg.includes('{'));
|
||||
assert.notEqual(msg, '无权访问');
|
||||
});
|
||||
|
||||
+51
-4
@@ -11,10 +11,31 @@ import {
|
||||
} from '../types/auth';
|
||||
import { RoleDefinition } from '../types/rbac';
|
||||
import { mapLoginResult, mapMeResult, mapUserAccount, mapUserAccountList } from './authUserMap';
|
||||
import { apiFetch } from './client';
|
||||
import { apiErrorFromBody, apiFetch } from './client';
|
||||
|
||||
const AUTH_STORAGE_KEY_SESSION = 'smdf_auth_session';
|
||||
|
||||
/** Prefer domain error text over envelope SafeMessage ("无权访问"). */
|
||||
export function authErrorMessage(
|
||||
err: { code: string; message: string },
|
||||
bodyText: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as { error?: string; code?: string; message?: string };
|
||||
const domain = typeof parsed.error === 'string' ? parsed.error.trim() : '';
|
||||
if (domain && domain[0] !== '{' && domain !== parsed.code && domain !== err.code) {
|
||||
return domain;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
if (err.message && err.message !== '无权访问' && err.message[0] !== '{') {
|
||||
return err.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface AuthClientOptions {
|
||||
adminUsername?: string;
|
||||
adminPassword?: string;
|
||||
@@ -163,7 +184,8 @@ export class AuthClient {
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
throw new Error(errText.trim() || '用户名或密码错误');
|
||||
const apiErr = apiErrorFromBody(resp.status, errText, '用户名或密码错误');
|
||||
throw new Error(authErrorMessage(apiErr, errText, '用户名或密码错误'));
|
||||
}
|
||||
const data = mapLoginResult(await resp.json());
|
||||
this.saveSession(data);
|
||||
@@ -225,8 +247,33 @@ export class AuthClient {
|
||||
return this.getAdminUsers();
|
||||
}
|
||||
|
||||
async adminResetPassword(_userId: number, _tempPass: string): Promise<boolean> {
|
||||
throw new Error('请通过后端管理员接口重置密码');
|
||||
async adminResetPassword(userId: number): Promise<{ tempPassword: string; mustChangePassword: boolean }> {
|
||||
const token = this.getSessionToken();
|
||||
const resp = await authRequest(`/api/v1/admin/users/${userId}/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: '{}',
|
||||
}, true);
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text();
|
||||
const apiErr = apiErrorFromBody(resp.status, errText, '重置密码失败');
|
||||
throw new Error(authErrorMessage(apiErr, errText, '重置密码失败'));
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
temp_password?: string;
|
||||
must_change_password?: boolean;
|
||||
};
|
||||
const tempPassword = String(data.temp_password ?? '').trim();
|
||||
if (!tempPassword) {
|
||||
throw new Error('重置成功但未返回临时口令,请联系运维');
|
||||
}
|
||||
return {
|
||||
tempPassword,
|
||||
mustChangePassword: data.must_change_password !== false,
|
||||
};
|
||||
}
|
||||
|
||||
async createAdminUser(payload: CreateAdminUserPayload): Promise<UserAccount> {
|
||||
|
||||
@@ -180,21 +180,33 @@ export const UsersSection: React.FC<UsersSectionProps> = ({ currentUser, onRefre
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 重置用户密码为初始并强制改密
|
||||
// 4. 重置用户密码:服务端生成一次性临时口令并强制改密
|
||||
const handleResetUserPassword = async (user: UserAccount) => {
|
||||
const tempPass = 'ResetPass123';
|
||||
if (!window.confirm(`确定要重置【${user.name}】的密码吗?重置后初始密码为 ${tempPass},该用户下次登录将被强制改密。`)) return;
|
||||
try {
|
||||
await authClient.adminResetPassword(user.id, tempPass);
|
||||
} catch (err: any) {
|
||||
showNotification('error', err.message || '重置密码失败');
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定要重置【${user.name}】的密码吗?系统将生成一次性临时口令,该用户下次登录将被强制改密。临时口令仅展示一次。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await refreshUsers();
|
||||
showNotification('success', `已重置【${user.name}】密码为 ${tempPass},已标记首次登录强制改密`);
|
||||
} catch {
|
||||
showNotification('error', '密码已重置,但全局用户目录同步失败,请刷新后重试');
|
||||
const result = await authClient.adminResetPassword(user.id);
|
||||
try {
|
||||
await refreshUsers();
|
||||
} catch {
|
||||
// password already reset; continue to show temp password
|
||||
}
|
||||
const copied = window.prompt(
|
||||
`已重置【${user.name}】密码。请立即复制临时口令(关闭后不可再查看):`,
|
||||
result.tempPassword,
|
||||
);
|
||||
if (copied === null) {
|
||||
showNotification('success', '密码已重置;请手动记下上方弹出的临时口令');
|
||||
} else {
|
||||
showNotification('success', '密码已重置,已标记首次登录强制改密');
|
||||
}
|
||||
} catch (err: any) {
|
||||
showNotification('error', err.message || '重置密码失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user