From 3a27e7ec2baacb23faba395df37f9817a6602797 Mon Sep 17 00:00:00 2001 From: zizi Date: Sun, 14 Jun 2026 01:25:30 +0800 Subject: [PATCH] =?UTF-8?q?test(p1r):=20=E6=94=B6=E5=8F=A3=20Account=20Sec?= =?UTF-8?q?urity=20Events=20completed=20approval=20=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approve only appListSecurityEvents and appGetSecurityEvent at operation level - keep appAcknowledgeSecurityEvent needs_verification - add HTTP+DB completed approval IT and update coverage gates/docs --- docs/agent-specs/.agent | 12 + ...ntSecurityEventsCompletedApproval审阅版.md | 258 ++++ ...ntSecurityEventsCompletedApproval执行版.md | 700 +++++++++++ ...-06-13-P1RAccountSecurityEvents状态推进.md | 274 +++++ .../superpowers/reports/p1r-api-coverage.json | 14 +- docs/superpowers/reports/p1r-api-coverage.md | 10 +- .../api/P1rAccountRealApiGateTest.java | 10 +- ...ountSecurityEventsCompletedApprovalIT.java | 1067 +++++++++++++++++ .../framework/api/P1rAiRealApiGateTest.java | 10 +- .../api/P1rApiCoverageReportTest.java | 15 +- .../api/P1rEventsRealApiGateTest.java | 16 +- .../api/P1rKnowledgeRealApiGateTest.java | 10 +- .../api/P1rMarketRealApiGateTest.java | 10 +- muse-cloud/scripts/p1r-audit-api-coverage.py | 3 + 14 files changed, 2366 insertions(+), 43 deletions(-) create mode 100644 docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md create mode 100644 docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md create mode 100644 docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java diff --git a/docs/agent-specs/.agent b/docs/agent-specs/.agent index 68c078cb..9b2e0e52 100644 --- a/docs/agent-specs/.agent +++ b/docs/agent-specs/.agent @@ -231,3 +231,15 @@ P1R Content Admin RiskAction Completed Approval 执行版 fresh review 进展: P1R Content Admin RiskAction Completed Approval 执行版 fresh re-review 已双 PASS:Peirce execution spec/scope re-review PASS,确认最新执行版仍只覆盖 `content:adminRiskAction` 一个 operation-level completed approval,不推进 Content domain-level completed,不夹带 admin import/export task、Content CRUD 写命令、OpenAPI、Content main/java 或 SQL migration,目标 `233/145/88` 与 Content `14/37` 可由当前 `233/144/89` 与 Content `13/38` 加 1 个 operation 机械推导;补充的 harness bean set、command/governance 行级断言和 standalone/combined `_test` 建库命令都仍落在测试与门禁范围内。Arendt execution quality/feasibility/testing re-review PASS,确认上一轮 3 项 FAIL 已关闭:执行版已补 exact admin harness bean set、command/audit row + snapshot gate、governance action row fact、standalone / combined `_test` DB 准备、Flyway system property restore、XML 防空跑和 V1-V21 schema gate;未发现会导致 fake pass 或启动失败的 P0/P1/P2/P3 blocker。该双 PASS 只代表执行版可进入用户批准点,不代表 `content:adminRiskAction` completed;下一步必须由用户明确批准四项执行边界后才允许 implementation,并且 implementation 后仍需 fresh implementation 双 review。 P1R Content Admin RiskAction Completed Approval implementation 当前状态:用户已批准执行版四项前置条件,本轮只把 `content:adminRiskAction` 一个治理写命令加入 operation-level completed approval,未把 `content` 加入 domain-level allowlist,未修改 OpenAPI、Content 业务实现或 SQL migration。scanner 仍保持 `APPROVED_COMPLETED_DOMAINS={"ai","knowledge"}`,只追加 `content:adminRiskAction`;coverage summary 已推进为 `233/145/88/0/0/0`,Content 为 `14 completed / 37 needs_verification`。admin import/export task、Content CRUD 写命令、AI planning candidate、style check、Meta projection、FileService、import/export/parse operation 仍保持 `needs_verification`。TDD RED 已验证旧 report 下 focused P1R gates 因目标 summary 仍为 144、`adminRiskAction` 仍为 `needs_verification` 而失败;GREEN 后 `python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` 通过并重生成 report。新增 `P1rContentAdminRiskActionCompletedApprovalIT` 使用 MockMvc HTTP 入口、真实 `AdminContentController` / `ContentAdminServiceImpl` / `ContentCommandServiceImpl` / `ContentAuditServiceImpl` / mapper、method security + test-local `ss` bean 与 PostgreSQL `_test` 库,单类验证 `8/8 pass`,覆盖安全配置拒绝、V1-V21 schema、`target_ids` JSONB、duplicate command SQLState 23505、`FOR UPDATE` 行锁合同、API version、RBAC、Bean Validation no-write、work/chapter/block happy path、status、replay、command conflict、revision conflict、unsupported action/scope、invalid targetIds、cross-work/cross-tenant target、missing/cross tenant work、command/governance 行级事实、command/audit snapshot 和全表 no-write / rollback。Content focused `ContentAdminServiceTest,AdminContentControllerTest` fresh 运行 `21/21 pass`;Content combined DB gate `P1rContentAdminRiskActionCompletedApprovalIT,P1rContentAdminReadCompletedApprovalIT,P1rContentPlanningCompletedApprovalIT,P1rContentCoreCompletedApprovalIT,P1rContentEventsPublishFlywayMigrationIT` 使用 `_test` 库 `muse_p1r_content_admin_risk_action_combined_test` fresh 运行 `40/40 pass`;P1R mixed gates fresh 运行 `49/49 pass`;XML 防空跑覆盖 15 个目标类全部 tests>0 且 failures/errors/skipped=0;`git diff --check` 通过;protected OpenAPI diff、Content main/java diff、SQL migration diff 均为空。fresh implementation review 已双 PASS:Gauss scope review PASS、Lagrange data-integrity/testing review PASS,均无 findings。新增 memory:`docs/memorys/2026-06-13-P1RContentAdminRiskAction状态推进.md`。当前具备提交候选条件,但仍不代表 Content 51/51、Account remaining、Market remaining 或总 P1R completed。 + +P1R Account Security Events Completed Approval 已启动并按 testing review 修订审阅版与执行版:`docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md`、`docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md`。首轮 scope review(Laplace)PASS,但 testing review(Dalton)FAIL;有效阻塞项为 `appAcknowledgeSecurityEvent` 的 `session_revoked` OpenAPI 合同仍承诺真实 session revoke,而当前实现仅返回“未接入 session 管理服务”的 fail-closed 文案,且 HTTP+DB `_test` 需要明确 `MemberUserMapper.selectById` 依赖的 `member_user` 表 fixture。已修订为本轮候选只覆盖 `account:appListSecurityEvents`、`account:appGetSecurityEvent` 两个 read operation-level completed approval;`account:appAcknowledgeSecurityEvent` 继续 `dedicated/needs_verification`。不把 `account` 加入 domain-level completed allowlist,不推进 Account 33/33,不修改 OpenAPI、scanner、coverage report、Account 业务实现或 SQL migration。当前 coverage 仍为 `233/145/88/0/0/0`,Account 仍为 `10 completed / 23 needs_verification`;执行版目标仅作为获批后目标:`233/147/86/0/0/0`,Account `12 completed / 21 needs_verification`。执行版明确允许在隔离 `_test` 库创建 test-local `member_user` auth fixture 满足 `requireUser()`,但不计入 P1R schema evidence、不修改 production SQL、不允许 mock 目标 mapper。下一步必须重新派发 fresh execution spec/scope review + fresh execution quality/feasibility/testing review;双 PASS 前不得实现、提交、push 或推进 Account completed。 + +P1R Account Security Events Completed Approval 执行版 review 收到新的 scope P1 并已修订:Kepler fresh scope review FAIL,唯一有效问题是执行版 allowed diff 把既有 `AccountSecurityServiceTest` 与 `AppAccountSecurityControllerTest` 列成可修改路径,超出本轮最小 scope。已按建议收紧执行版 allowed diff:既有 focused tests、mapper/convert tests 只允许作为验证命令和 XML 防空跑证据被运行、读取,不列入本轮可修改路径;如 implementation 必须改既有 focused tests、Account 业务实现、OpenAPI、SQL migration、非批准 gate 或其它文件,必须停下说明原因并重新取得用户批准。Kepler FAIL 之前的 PASS 结论和任何基于旧 allowed diff 的 PASS 均不能作为最终 gate;下一步必须基于收紧后的执行版重新派发 fresh execution spec/scope review 与 fresh quality/feasibility/testing review。当前仍只改文档,未修改 scanner、coverage report、OpenAPI、Account 业务实现、SQL migration 或 gate test,coverage 仍为 `233/145/88/0/0/0`。 + +P1R Account Security Events Completed Approval 执行版 fresh testing review(Planck)FAIL,两个 P1 已验证有效并修订:第一,新 `P1rAccountSecurityEventsCompletedApprovalIT` 原固定 `TARGET_VERSION="20"` / V1-V20 clean migrate,但当前 `sql/muse` live baseline 已到 `V21__extend_content_events_publish_outbox.sql`,因此执行版与审阅版已改为新 HTTP+DB IT 必须 `TARGET_VERSION="21"`、V1-V21 clean migrate、`migrationsExecuted=21`;Account 专属 `P1rAccountEventsPublishFlywayMigrationIT` 可继续作为 V20 Account outbox 迁移证据复跑,但不能替代新 IT 的 V21 baseline。第二,test-local `member_user` fixture 只有原则说明,缺可执行 DDL 约束;已补要求 fixture 在隔离 `_test` 库 Flyway migrate 后创建,DDL 至少覆盖 `MemberUserDO`、`TenantBaseDO`、`BaseDO` 当前映射列,包括 `id/mobile/password/status/register_ip/register_terminal/login_ip/login_date/nickname/avatar/name/sex/birthday/area_id/mark/point/tag_ids/level_id/experience/group_id/tenant_id/creator/create_time/updater/update_time/deleted`,不得只建最小列、不得 mock `MemberUserMapper`、不得修改 production SQL。Planck FAIL 和此前任何基于旧 V20/fixture 口径的 PASS 均不能作为最终 gate;下一步必须基于最新版重新派发 fresh execution spec/scope review 与 fresh quality/feasibility/testing review。当前仍只改文档,未修改 scanner、coverage report、OpenAPI、Account 业务实现、SQL migration 或 gate test。 + +P1R Account Security Events Completed Approval 执行版 fresh testing review(Anscombe)FAIL,1 个 P1 与 2 个 P2 已验证并修订:P1 为 Task 4 focused verification 会运行新增 `P1rAccountSecurityEventsCompletedApprovalIT`,但命令未传 `_test` JDBC、`p1r.flyway.url/user/locations`,会导致新 IT 缺真实 PostgreSQL `_test` 与 V1-V21 migrate 前置;已补 Task 4 命令,复用 `muse_p1r_account_security_events_completed_approval_test`,导出 `P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_JDBC_URL`,创建/复用 `_test` 库,并向 Maven 传 `-Dp1r.flyway.locations=filesystem:sql/muse`、`-Dp1r.flyway.url`、`-Dp1r.flyway.user`、新 IT 专属 JDBC user/url 属性,密码仍只来自环境变量。P2 已补具体 schema 对象名:`idx_muse_member_security_user`、`trg_muse_member_security_updated_at`、`uk_muse_account_security_event_ack_command`、`idx_muse_account_security_event_ack_user`、`idx_muse_account_security_event_ack_event`、`trg_muse_account_security_event_ack_updated_at`。P2 已补 no-write gate 必须对 `muse_member_security_event`、`muse_account_security_event_ack`、`muse_account_command`、`muse_account_audit`、`muse_account_event_publish_outbox` 使用 `row_to_json(t)::text` 全表快照并按稳定键排序,不能只比较 row count。Anscombe FAIL 和此前任何基于旧 Task 4 命令的 PASS 均不能作为最终 gate;下一步必须基于最新版重新派发 fresh execution spec/scope review 与 fresh quality/feasibility/testing review。当前仍只改文档,未修改 scanner、coverage report、OpenAPI、Account 业务实现、SQL migration 或 gate test。 + +P1R Account Security Events Completed Approval 执行版最新版 fresh review 已双 PASS:Mencius scope review PASS,无 P0/P1/P2 findings;Bohr testing/feasibility review PASS,无 P0/P1 blocker。Mencius 确认执行版可进入用户批准点:只审批 `account:appListSecurityEvents`、`account:appGetSecurityEvent` 两个 operation;继续 operation-level approval,不把 `account` 加入 domain allowlist,不推进 Account 33/33;`account:appAcknowledgeSecurityEvent` 继续 `dedicated/needs_verification`;allowed diff 只覆盖 scanner、coverage report、P1R gate tests、新增 `P1rAccountSecurityEventsCompletedApprovalIT`、memory、`.agent`、两份 spec,既有 focused tests 只允许运行/读取;mixed gate 目标为 summary `233/147/86`、Account `12/21`。Bohr 确认 HTTP+DB `_test`、V1-V21 baseline、member_user fixture、no-write、schema/XML/protected diff/rollback 主证据链可进入批准点;其 P2 建议已作为低风险文档补强处理:执行版已明确既有 ack/session_revoked focused tests 只作为现状回归和 XML 防空跑、不计入本轮 completed evidence,并补可复制的 XML 防空跑 Python 校验脚本。该双 PASS 只代表执行版可进入用户批准点,不代表 2 个 Account Security Events read operation completed;未获用户明确批准四项前不得实施 scanner/report/gate/IT 修改。 + +P1R Account Security Events Completed Approval implementation 当前状态:用户已批准执行版四项前置条件,本轮只把 `account:appListSecurityEvents`、`account:appGetSecurityEvent` 两个只读 operation 加入 operation-level completed approval,未把 `account` 加入 domain-level allowlist,未修改 OpenAPI、Account 业务实现或 SQL migration;`account:appAcknowledgeSecurityEvent` 仍为 `dedicated/needs_verification/requiresCommandId=true`。scanner 仍保持 `APPROVED_COMPLETED_DOMAINS={"ai","knowledge"}`,只追加两个 `account:*` operation key。TDD RED 已验证旧 report 下 P1R focused gates 失败,失败点为 expected completed=147 but was 145、Account expected 12 completed but was 10、`appListSecurityEvents` 仍为 needs_verification;GREEN 后 `python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check` 输出 summary `233/147/86/0/0/0`,Account 为 `33/12/21/0/0/0`。新增 `P1rAccountSecurityEventsCompletedApprovalIT` 使用 MockMvc HTTP 入口、真实 `AppAccountSecurityController` / `AccountSecurityServiceImpl` / mapper、PostgreSQL `_test` 库 `muse_p1r_account_security_events_completed_approval_test`,clean migrate V1-V21 后创建 test-local `member_user` fixture,单类验证 `8/8 pass`,覆盖 list/detail、missing/invalid API version、invalid severity、missing user、非数字 eventId、missing/cross owner/cross tenant 404、owner 不能由请求参数覆盖、latest ack 合并、IP/userAgent/token 脱敏、V2/V11/V20/V21 schema、no-write 全表快照和 `_test`/credential safety。Account focused command fresh 运行 muse-server 19/19 pass;member-server focused XML 显示 `AccountSecurityServiceTest 8/8`、`AppAccountSecurityControllerTest 6/6`、`AccountSecurityEventAckMapperTest 4/4`、`MemberSecurityEventMapperTest 2/2`、`AccountConvertTest 9/9`。Account V20 Flyway gate `P1rAccountEventsPublishFlywayMigrationIT` 使用 `_test` 库 `muse_p1r_account_security_events_flyway_test` fresh 运行 `4/4 pass`,输出 `flyway_success=true`、`migrations_executed=20`、`successful_migration_count=20`、`target_schema_version=20`、`flyway_latest=20:extend account events publish outbox`。P1R mixed gates fresh 运行 `49/49 pass`;XML 防空跑覆盖 10 个目标类,全部 tests>0 且 failures/errors/skipped=0;`git diff --check` 通过;protected OpenAPI diff、Account main/java diff、SQL migration diff 均为空。首轮 fresh implementation review 结果:Darwin correctness PASS;Banach testing PASS 且提出 P2 residual(新增 IT 未直接覆盖 `X-API-Version: 2`)已按 `superpowers:receiving-code-review` 验证为低风险有效项并补测试关闭,补充后已 fresh 重跑新增 IT `8/8 pass`、Account focused muse-server `19/19 pass`、P1R mixed gates `49/49 pass`、scanner/report `233/147/86`、XML 防空跑与 protected diff。新增 memory:`docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md`。当前必须重新派发 fresh implementation spec/correctness re-review + fresh implementation quality/data-integrity/testing re-review,双 PASS 前不得提交、push 或宣称 Account 33/33、Market remaining、Content remaining 或总 P1R completed。 diff --git a/docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md b/docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md new file mode 100644 index 00000000..7ee173e0 --- /dev/null +++ b/docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md @@ -0,0 +1,258 @@ +# P1R Account Security Events Completed Approval 审阅版 + +日期:2026-06-13 + +## 结论 + +推荐 Account 下一批只推进 2 个用户安全事件只读 operation-level completed approval: + +```text +account:appListSecurityEvents +account:appGetSecurityEvent +``` + +暂不推进 `account:appAcknowledgeSecurityEvent`。原因是 OpenAPI 当前仍承诺 `session_revoked` 会联动 session 管理并使相关会话失效,但当前实现返回“未接入 session 管理服务”的 fail-closed 文案。operation-level completed 不能排除单个 action 子路径;在 OpenAPI 或真实 session revoke 未闭合前,把整个 acknowledge operation 标为 completed 会制造 fake completed。 + +本切片 completed 口径只覆盖:对既有 `muse_member_security_event` 安全事件事实的当前登录用户可见读取、详情脱敏、latest ack 合并、owner/tenant 隔离、API version 和 read no-write 闭环。 + +本审阅版只冻结候选范围、证据标准、风险边界和后续执行版要求。不修改 OpenAPI,不修改 scanner,不修改 coverage report,不修改业务实现,不推进任何 operation completed。 + +```mermaid +flowchart TB + Current["当前 Account
10 completed / 23 needs_verification"] --> Candidate["下一批候选
Security Events read 2 ops"] + Candidate --> Read["list / detail
owner 可见 + 脱敏 + latest ack"] + Candidate --> Exclude["暂缓 acknowledge
session_revoked 合同不闭合"] + Read --> Review["fresh spec/scope review
fresh quality/testing review"] + Exclude --> Keep["继续 needs_verification"] + Review --> Exec["双 PASS 后进入审批点"] + Exec --> Approval{"用户明确批准后才实现"} + Approval -->|"否"| Stay["保持 233/145/88"] + Approval -->|"是"| Target["目标 233/147/86
Account 12 completed / 21 needs_verification"] +``` + +## 当前事实状态 + +工作区: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +当前 HEAD: + +```text +304f665 test(p1r): 收口 Content Admin RiskAction completed approval 门禁 +``` + +当前 coverage summary: + +```text +total=233 +completed=145 +needsVerification=88 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +当前 domain 状态: + +```text +account completed=10 needsVerification=23 +content completed=14 needsVerification=37 +market completed=4 needsVerification=28 +meta completed=16 needsVerification=0 +events completed=1 needsVerification=0 +``` + +目标 operation 当前状态: + +```text +appListSecurityEvents dedicated needs_verification requiresCommandId=false GET /app-api/muse/account/security-events +appGetSecurityEvent dedicated needs_verification requiresCommandId=false GET /app-api/muse/account/security-events/{eventId} +``` + +获批后的目标值: + +```text +total=233 +completed=147 +needsVerification=86 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 + +account total=33 +completed=12 +needsVerification=21 +``` + +## 推荐候选 + +| operation key | Method | Path | 推荐原因 | 必补 completed-grade evidence | +|---|---|---|---|---| +| `account:appListSecurityEvents` | `GET` | `/app-api/muse/account/security-events` | 读当前登录用户安全事件摘要,支持 severity 筛选和 latest ack 合并,无写入副作用 | MockMvc HTTP + 真实 PostgreSQL `_test`,覆盖 owner/tenant 隔离、severity filter、分页、latest ack、敏感字段不泄露、missing API version 拒绝、read no-write | +| `account:appGetSecurityEvent` | `GET` | `/app-api/muse/account/security-events/{eventId}` | 读当前登录用户安全事件详情,服务端按 eventId + accountUserId 查询并脱敏 IP / User-Agent | MockMvc HTTP + 真实 PostgreSQL `_test`,覆盖 detail 脱敏、跨 owner/跨 tenant/missing 不可见、ack detail 合并、非数字 eventId 404、read no-write | + +推荐理由: + +- 2 个 operation 都属于 Account 本域 `AccountSecurityServiceImpl`,不需要调用 New-API、FileService、Market、Content、AI 或 Knowledge 外部 owner。 +- App 端 Controller 使用 `getLoginUserId()`,不接受请求参数覆盖 owner,适合用 HTTP + DB 证明 owner 隔离。 +- OpenAPI 的 list/detail 合同与当前实现方向一致:当前账户可见、安全事件详情、敏感值脱敏。 +- 读侧需要合并 `muse_account_security_event_ack` latest ack,但不需要证明 ack 写命令 completed。 + +## 不纳入本批的 Account operation + +以下 21 个 Account operation 必须继续 `dedicated / needs_verification`: + +```text +account:adminCreateCallAttributionJob +account:adminGetCallAttributionJob +account:adminGetIntegrationCallByCorrelation +account:adminListNewApiBindings +account:adminListPurchaseRecords +account:adminListUsageRecords +account:adminCreateNewApiBinding +account:adminCreateQuotaRequest +account:appDownloadExport +account:appCreateExportTask +account:appGetExportTask +account:appGetIntegrationCallByCorrelation +account:appListLicenses +account:getAppNewApiBinding +account:appRecheckNewApiBinding +account:appListPublishRecords +account:appListPurchases +account:appCreateQuotaRequest +account:appGetQuotaRequest +account:appAcknowledgeSecurityEvent +account:getAppUsage +``` + +排除原因: + +- `appAcknowledgeSecurityEvent` 的 `session_revoked` OpenAPI 合同承诺与当前实现 fail-closed 文案不闭合;必须单独处理合同或实现后再审批。 +- New-API binding、recheck、quota request 和 integration correlation 仍依赖真实 New-API runtime 或外部调用归因闭环。 +- FileService / export download 仍缺真实文件交付、下载字节、对象存储和凭证消费端到端 evidence。 +- call attribution job 当前 create 路径只创建 queued job 事实,真实归因执行仍由后续 owner/facade 接入;可作为后续独立切片,但不能夹入本批。 +- Market purchase / license / publish 仍缺 Market 主流程到 Account projection 的跨 owner 一致性 evidence。 +- usage summary / admin usage 仍受 `muse_member_usage_record` 和 attribution source 质量影响,本批不绕过上游事实缺口。 + +## 已有实现证据 + +### OpenAPI 合同 + +OpenAPI 当前包含 2 个本批候选读 operation: + +```text +GET /app-api/muse/account/security-events +GET /app-api/muse/account/security-events/{eventId} +``` + +本切片不需要修改 `docs/api-contracts/account/openapi.yaml`。但 OpenAPI 中 acknowledge 的 `session_revoked` 文案当前是阻塞项,因此本批明确不推进 `appAcknowledgeSecurityEvent`。 + +### Controller 入口 + +`AppAccountSecurityController` 当前链路: + +```text +GET /muse/account/security-events +GET /muse/account/security-events/{eventId} +AccountApiVersionGuard.requireVersion(apiVersion) +getLoginUserId() +AccountSecurityService +``` + +说明: + +- Controller-local mapping 是 `/muse/account/...`;对外 OpenAPI 路径仍以 `/app-api/muse/...` 为准。 +- 执行版必须用当前项目既有 app MockMvc / Web 测试上下文证明最终 app API 路径可访问。 +- `list` / `detail` 都必须证明缺少 `X-API-Version` 时不调用 service。 + +### Service 读链路 + +`AccountSecurityServiceImpl.appListSecurityEvents`: + +1. `requireUser(accountUserId)`。 +2. normalize severity,只允许 `info / warning / critical`。 +3. `MemberSecurityEventMapper.selectPageByAccountUserIdAndSeverity(...)`。 +4. 批量读取当前 owner 最新 ack。 +5. 转换摘要 VO,敏感字段不直接外泄。 + +`AccountSecurityServiceImpl.appGetSecurityEvent`: + +1. `requireUser(accountUserId)`。 +2. 解析 eventId,非数字或空值按 not found 处理。 +3. `MemberSecurityEventMapper.selectByIdAndAccountUserId(...)`。 +4. 读取当前 owner 最新 ack。 +5. 转换详情 VO,脱敏 IP / userAgent。 + +### Schema 与测试前置 + +P1R SQL 当前定义目标事实表: + +```text +V2: muse_member_security_event +V11: muse_account_security_event_ack +``` + +但 `AccountSecurityServiceImpl.requireUser()` 会通过 `MemberUserMapper.selectById` 读取 `member_user`,而 `sql/muse` 当前 V1-V21 不创建 `member_user`。执行版必须明确:HTTP+DB `_test` 可以在隔离库完成 V1-V21 clean migrate 后创建 test-local `member_user` auth fixture 表并 seed 登录用户,用于满足认证前置;该 fixture 不计入 P1R Account schema evidence,不能修改 production SQL,也不能替代目标安全事件表/ack 表证据。 + +执行版必须 fresh 证明: + +- V1-V21 clean migrate 成功,且新 HTTP+DB IT 使用当前 live migration baseline。 +- 目标表 `muse_member_security_event`、`muse_account_security_event_ack` 的列、索引、trigger 和 JSONB 字段真实存在。 +- test-local `member_user` fixture 只在 `_test` 库、Flyway migrate 后创建,并在测试内记录为 auth fixture;DDL 至少覆盖 `MemberUserDO`、`TenantBaseDO`、`BaseDO` 当前映射列:`id`、`mobile`、`password`、`status`、`register_ip`、`register_terminal`、`login_ip`、`login_date`、`nickname`、`avatar`、`name`、`sex`、`birthday`、`area_id`、`mark`、`point`、`tag_ids`、`level_id`、`experience`、`group_id`、`tenant_id`、`creator`、`create_time`、`updater`、`update_time`、`deleted`。 + +## 风险与取舍 + +1. `appAcknowledgeSecurityEvent` 暂缓是必须的。 + - 当前 OpenAPI 对 `session_revoked` 承诺真实 session revoke。 + - 当前实现只返回 fail-closed 文案。 + - 在合同或实现未闭合前,不能把整个 operation 标为 completed。 + +2. `member_user` 是测试 harness 前置,不是目标业务证据。 + - 不能 mock `MemberUserMapper` 来掩盖真实 service 依赖。 + - 也不能修改 `sql/muse` 迁移补这个表。 + - 允许在隔离 `_test` 库、V1-V21 migrate 后创建 test-local fixture,并明确不计入 P1R schema evidence。 + - fixture DDL 必须覆盖真实 `MemberUserMapper.selectById` 会读取的当前实体映射列,不能只建 `id/nickname` 等最小列。 + +3. 安全事件生产路径不是本批完成条件。 + - 已知 `AccountExportServiceImpl.insertSensitiveExportEvent` 会写安全事件 source fact。 + - 但本批不证明高敏导出文件交付或所有安全事件来源 completed。 + +4. App HTTP + real DB gate 必须避免假绿。 + - 不能只靠 Mockito service/controller tests。 + - 必须读取新鲜 Surefire XML,检查 tests>0、failures=0、errors=0、skipped=0。 + +## 后续执行版必须包含 + +1. 2 个目标 operation 的审批清单、当前状态和目标状态。 +2. 保持 `needs_verification` 的 21 个 Account operation 及原因。 +3. TDD RED:先改 P1R gate 期望,旧 report 必须失败。 +4. TDD GREEN:只把 2 个 `account:*` operation 加入 operation-level allowlist,不加 `account` domain allowlist。 +5. 新增 `P1rAccountSecurityEventsCompletedApprovalIT`,使用 MockMvc HTTP + 真实 PostgreSQL `_test`。 +6. App 端登录用户上下文、tenant interceptor、API version、真实 mapper、test-local `member_user` auth fixture 的组合方式。 +7. schema gate:新 HTTP+DB IT 必须执行 V1-V21 clean migrate;V2/V11 安全事件表、ack 表、唯一约束、索引、trigger、JSONB 字段断言;Account 专属 `P1rAccountEventsPublishFlywayMigrationIT` 可继续作为 V20 outbox 证据,但不能替代新 IT 的 V21 baseline。 +8. runtime matrix:happy、empty page、severity filter、latest ack、detail 脱敏、cross owner、cross tenant、missing、invalid eventId、read no-write。 +9. P1R mixed gates:至少覆盖 Account、Coverage、Events、AI、Knowledge、Market、Content、Meta。 +10. XML 防空跑、protected diff、allowed diff 和 rollback 策略。 +11. `.agent` 与 `docs/memorys` 留痕要求。 + +## 验收标准 + +本审阅版可以视为完成的条件: + +1. 文件写入 `docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md`。 +2. `.agent` 记录 Account Security Events completed approval 审阅版状态。 +3. `git diff --check` 通过。 +4. OpenAPI、scanner、coverage report、业务实现、SQL migration 当前无新增 diff。 +5. fresh spec/scope review PASS。 +6. fresh quality/feasibility/testing review PASS。 + +## 待确认项 + +1. 是否确认 Account 下一批只做这 2 个 security events read operation-level completed approval。 +2. 是否确认 `appAcknowledgeSecurityEvent` 因 `session_revoked` 合同不闭合继续保持 `needs_verification`。 +3. 是否确认本批不把 `account` 加入 domain-level completed allowlist。 +4. 是否确认允许 HTTP+DB `_test` 在隔离 `_test` 库内创建 test-local `member_user` auth fixture,但不修改 production SQL、不把该 fixture 计入 P1R schema evidence。 diff --git a/docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md b/docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md new file mode 100644 index 00000000..9876f835 --- /dev/null +++ b/docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md @@ -0,0 +1,700 @@ +# P1R Account Security Events Completed Approval 执行版 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to implement this plan task-by-task only after fresh execution review double PASS and explicit user approval. Steps use checkbox syntax for tracking. + +日期:2026-06-13 + +## 结论 + +本执行版只锁定 Account 用户安全事件只读 2 个 operation-level completed approval: + +```text +account:appListSecurityEvents +account:appGetSecurityEvent +``` + +本轮不把 `account` 加入 domain-level completed allowlist,不推进 Account 33/33 completed,不修改 OpenAPI,不修改 Account 业务实现,不修改 SQL migration。`account:appAcknowledgeSecurityEvent` 因 `session_revoked` 公开合同与当前实现不闭合,继续保持 `needs_verification`。 + +获批后目标 coverage summary 为: + +```text +total=233 +completed=147 +needsVerification=86 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +Account 域目标状态为: + +```text +account total=33 +completed=12 +needsVerification=21 +``` + +当前阶段只允许写执行方案并进入 fresh execution review。执行版 review 双 PASS 后,仍必须由用户再次明确批准审批清单、operation-level 边界、allowed diff 和 mixed gate 同步范围,才允许实施。 + +```mermaid +flowchart TB + Current["当前 Account
10 completed / 23 needs_verification"] --> Exec["执行版
锁定 Security Events read 2 ops"] + Exec --> Review["fresh execution spec/scope review
fresh execution quality/testing review"] + Review --> Approval{"用户明确批准
2 ops / operation-level / allowed diff / mixed gates"} + Approval -->|否| Stay["保持当前 coverage
233/145/88"] + Approval -->|是| Red["TDD RED
先改 gate 期望
旧 report 必须失败"] + Red --> Runtime["HTTP + real DB _test
owner + tenant + latest ack + no-write"] + Runtime --> Green["scanner operation allowlist
regenerate report"] + Green --> Verify["focused tests + P1R mixed gates
XML 防空跑 + protected diff"] + Verify --> FreshReview["fresh implementation review"] +``` + +## 当前事实 + +正确 worktree: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +当前 HEAD: + +```text +304f665 test(p1r): 收口 Content Admin RiskAction completed approval 门禁 +``` + +当前 coverage summary: + +```text +233 145 88 0 0 0 +``` + +当前 domain 状态: + +```text +account completed=10 needsVerification=23 +content completed=14 needsVerification=37 +market completed=4 needsVerification=28 +meta completed=16 needsVerification=0 +events completed=1 needsVerification=0 +``` + +当前目标 operation 状态: + +```text +appListSecurityEvents dedicated needs_verification false GET /app-api/muse/account/security-events +appGetSecurityEvent dedicated needs_verification false GET /app-api/muse/account/security-events/{eventId} +``` + +必须保持 `needs_verification` 的相关写命令: + +```text +appAcknowledgeSecurityEvent dedicated needs_verification true POST /app-api/muse/account/security-events/{eventId}/acknowledge +``` + +已验证的只读实现事实: + +- `AppAccountSecurityController` 使用 `AccountApiVersionGuard.requireVersion(...)` 和 `getLoginUserId()`。 +- `AccountSecurityServiceImpl.appListSecurityEvents` 按当前 owner + severity 查询安全事件,并合并最新 ack。 +- `AccountSecurityServiceImpl.appGetSecurityEvent` 按 eventId + owner 查询详情,并通过 `AccountConvert` 脱敏 IP / userAgent。 +- `AccountSecurityServiceImpl.requireUser()` 依赖 `MemberUserMapper.selectById`;P1R `sql/muse` 当前 V1-V21 不创建 `member_user`。 +- `SecurityEventAcknowledgeReqVO` 与 OpenAPI 当前包含 `session_revoked`,但 OpenAPI 文案承诺真实 session revoke,当前实现只返回 fail-closed 文案;本执行版不推进 acknowledge。 + +## 执行边界 + +### 必须先获用户明确批准 + +实现前必须同时获得以下 4 项批准: + +1. 批准本轮只审批 `account:appListSecurityEvents`、`account:appGetSecurityEvent` 两个 operation。 +2. 批准继续使用 operation-level approval,不把 `account` 加入 domain-level completed allowlist。 +3. 批准按本执行版修改 scanner、coverage report、P1R gates、新增 Security Events HTTP+DB `_test`、memory 和 `.agent`。 +4. 批准同步 mixed gate 的 summary / Account / 非目标域防回退断言,目标为 `233/147/86/0/0/0` 与 Account `12 completed / 21 needs_verification`。 + +未获上述批准前,不得实施本执行版。 + +### 本轮允许变更 + +获批后只允许修改以下路径: + +- `muse-cloud/scripts/p1r-audit-api-coverage.py` +- `docs/superpowers/reports/p1r-api-coverage.json` +- `docs/superpowers/reports/p1r-api-coverage.md` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rContentRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMetaRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java` +- `docs/agent-specs/.agent` +- `docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md` +- `docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md` +- `docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md` + +`AccountSecurityServiceTest`、`AppAccountSecurityControllerTest` 以及既有 mapper/convert focused tests 只允许作为验证命令和 XML 防空跑证据被运行、读取,不列入本轮可修改路径。如 implementation 发现必须修改 Account 业务实现、OpenAPI、SQL migration、既有 focused tests、非批准 gate 或其它文件,必须停下说明原因并重新取得用户批准。 + +### 本轮禁止变更 + +- 不修改 7 个 OpenAPI。 +- 不修改 Account main/java 业务实现来掩盖 coverage 缺口。 +- 不新增或修改 SQL migration。 +- 不把 `account` 加入 domain-level completed allowlist。 +- 不推进 Account 33/33 completed。 +- 不推进 `account:appAcknowledgeSecurityEvent`。 +- 不推进 Account remaining 21、Market remaining 28、Content remaining 37 或总 P1R completed。 +- 不推进 New-API binding / recheck / quota request、FileService/export/download、call attribution、usage attribution、Market purchase/license/publish projection、integration-call 查询。 +- 不把安全事件读证据写成安全事件所有生产来源 completed。 +- 不通过 mock `MemberUserMapper` 或 production SQL 修改绕过 `member_user` 前置依赖。 +- 不把 dedicated gate PASS、review PASS、文档结论或已有 Mockito 单测直接等同 completed。 + +## 审批清单 + +| operation key | Method | Path | 目标状态 | 完成证据边界 | +|---|---|---|---|---| +| `account:appListSecurityEvents` | `GET` | `/app-api/muse/account/security-events` | `dedicated / completed` | MockMvc HTTP + 真实 PostgreSQL `_test`,覆盖 owner/tenant、severity、分页、latest ack、敏感字段不泄露、API version、read no-write | +| `account:appGetSecurityEvent` | `GET` | `/app-api/muse/account/security-events/{eventId}` | `dedicated / completed` | MockMvc HTTP + 真实 PostgreSQL `_test`,覆盖 detail 脱敏、跨 owner/跨 tenant/missing 不可见、非数字 eventId、ack 合并、read no-write | + +## 保持 needs_verification 的 Account operation + +以下 21 个 operation 必须保持 `dedicated / needs_verification`: + +```text +account:adminCreateCallAttributionJob +account:adminGetCallAttributionJob +account:adminGetIntegrationCallByCorrelation +account:adminListNewApiBindings +account:adminListPurchaseRecords +account:adminListUsageRecords +account:adminCreateNewApiBinding +account:adminCreateQuotaRequest +account:appDownloadExport +account:appCreateExportTask +account:appGetExportTask +account:appGetIntegrationCallByCorrelation +account:appListLicenses +account:getAppNewApiBinding +account:appRecheckNewApiBinding +account:appListPublishRecords +account:appListPurchases +account:appCreateQuotaRequest +account:appGetQuotaRequest +account:appAcknowledgeSecurityEvent +account:getAppUsage +``` + +## 证据分层 + +| Evidence | 文件 / 测试 | 目的 | +|---|---|---| +| Operation inventory / coverage gate | `P1rApiCoverageReportTest`、`P1rAccountRealApiGateTest` 和 mixed gates | 证明只推进获批 2 个 operation,非目标 domain 不回退 | +| Security Events HTTP+DB `_test` | 新增 `P1rAccountSecurityEventsCompletedApprovalIT` | 证明 2 个 read operation 经 MockMvc HTTP 入口访问真实 service / mapper / PostgreSQL `_test` 数据 | +| Tenant / schema gate | `P1rAccountSecurityEventsCompletedApprovalIT` 内验证 V1-V21 clean migrate 与 V2/V11 安全事件依赖 schema | 证明安全事件表、ack 表、索引、trigger、JSONB 和 tenant 字段在当前 live migration baseline 下真实可用 | +| Auth fixture gate | `P1rAccountSecurityEventsCompletedApprovalIT` 内创建 test-local `member_user` fixture | 满足 `requireUser()` 的真实 mapper 前置,但不计入 P1R schema evidence | +| App security context gate | `P1rAccountSecurityEventsCompletedApprovalIT` 内设置 app LoginUser 与 tenant context | 证明 owner 不能由请求参数覆盖,跨 owner / 跨 tenant 不可见 | +| No-write gate | `P1rAccountSecurityEventsCompletedApprovalIT` 对 read happy/error 前后做表快照 | 证明 list/detail 不写 security event、ack、command、audit 或 outbox | +| Focused unit/controller tests | `AccountSecurityServiceTest`、`AppAccountSecurityControllerTest`、mapper/convert tests | 支撑 service/controller contract,不替代 HTTP+DB `_test` | + +## TDD / 实施步骤 + +### Task 1:Preflight 与 RED gate + +- [ ] 确认当前 worktree: + +```bash +git -c core.quotePath=false status --short --branch +``` + +期望: + +```text +## dev/1.0.0...origin/dev/1.0.0 +``` + +允许存在本执行版和审阅版文档 diff;不得存在 OpenAPI、业务实现、SQL、scanner、coverage report 或 gate test 的未授权 diff。 + +- [ ] 确认当前 coverage: + +```bash +jq -r '.summary | [.totalOperations,.completedOperations,.needsVerificationOperations,.incompleteOperations,.genericPersistenceOperations,.ssePlaceholderOperations] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +jq -r '.operations[] | select(.domain=="account" and (.operationId=="appListSecurityEvents" or .operationId=="appGetSecurityEvent" or .operationId=="appAcknowledgeSecurityEvent")) | [.operationId,.implementationStatus,.completionStatus,(.requiresCommandId|tostring),.method,.path] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +期望: + +```text +233 145 88 0 0 0 +appListSecurityEvents dedicated needs_verification false GET /app-api/muse/account/security-events +appGetSecurityEvent dedicated needs_verification false GET /app-api/muse/account/security-events/{eventId} +appAcknowledgeSecurityEvent dedicated needs_verification true POST /app-api/muse/account/security-events/{eventId}/acknowledge +``` + +- [ ] TDD RED:先修改 P1R gate 期望值和 2 个 read operation completed 断言,但不修改 scanner/report。 + +RED 目标: + +- `P1rApiCoverageReportTest` 期望 summary `completed=147 / needsVerification=86`。 +- `P1rAccountRealApiGateTest` 期望 Account `12 completed / 21 needs_verification`,2 个 read operation completed,`appAcknowledgeSecurityEvent` 继续 `needs_verification`。 +- 7 个 mixed gate 中所有硬编码 Account `10/23` 或 summary `145/88` 的断言同步为 `12/21` 或 `147/86`。 + +RED 命令: + +```bash +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rApiCoverageReportTest,P1rAccountRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest,P1rMarketRealApiGateTest,P1rContentRealApiGateTest,P1rMetaRealApiGateTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +期望:BUILD FAILURE。失败点必须来自旧 report 仍是 `233/145/88` 或 2 个 read operation 仍为 `needs_verification`;不得来自编译错误或无关测试失败。 + +### Task 2:新增 Security Events HTTP+DB `_test` + +- [ ] 新增: + +```text +muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java +``` + +测试上下文要求: + +- 使用 `@SpringBootTest(webEnvironment = MOCK)` + `WebApplicationContext` + `MockMvcBuilders.webAppContextSetup(...)`。 +- 真实 `/app-api/muse/**` 前缀进入 `AppAccountSecurityController`。 +- 导入 `MuseWebAutoConfiguration`,并提供 test-local `ApiErrorLogCommonApi` stub,返回 `CommonResult.success(true)`,满足 `GlobalExceptionHandler` 依赖。 +- 设置 app `LoginUser` 与 tenant context;请求参数中的 `userId` 或其它伪 owner 参数不得改变 owner。 +- 使用真实 `AccountSecurityServiceImpl`、真实 mapper 和真实 PostgreSQL `_test` 数据库;不得 mock `MemberSecurityEventMapper` 或 `AccountSecurityEventAckMapper`。 +- `MemberUserMapper` 必须使用真实 mapper。由于 `sql/muse` 不包含 `member_user`,测试可以在隔离 `_test` 库完成 Flyway migrate 后创建 test-local `member_user` fixture 表并 seed 登录用户;该 fixture 只能作为 `requireUser()` auth 前置,不计入 P1R schema evidence,不允许修改 production SQL。 +- test-local `member_user` DDL 必须覆盖 `MemberUserDO`、`TenantBaseDO`、`BaseDO` 当前映射列,至少包含:`id`、`mobile`、`password`、`status`、`register_ip`、`register_terminal`、`login_ip`、`login_date`、`nickname`、`avatar`、`name`、`sex`、`birthday`、`area_id`、`mark`、`point`、`tag_ids`、`level_id`、`experience`、`group_id`、`tenant_id`、`creator`、`create_time`、`updater`、`update_time`、`deleted`。不得只建 `id/nickname` 等最小列,也不得通过修改 mapper 或 production SQL 回避真实 `MemberUserMapper.selectById`。 +- 注册真实 MyBatis tenant interceptor:`TenantLineInnerInterceptor` + `TenantDatabaseInterceptor(new TenantProperties())`。 +- `TARGET_VERSION="21"`,执行 V1-V21 clean migrate 后再创建 test-local auth fixture;Account 专属 `P1rAccountEventsPublishFlywayMigrationIT` 可以继续作为 V20 Account outbox 迁移证据复跑,但不能替代本 IT 的当前 live V21 baseline。 +- 数据库密码只能来自环境变量,禁止 JVM system property 或 JDBC query 参数传入。 +- 保存并在 `@AfterAll` 恢复 `p1r.flyway.url` / `p1r.flyway.user` system property,避免污染同一 Surefire JVM 后续 Flyway 测试。 + +建议测试库: + +```text +muse_p1r_account_security_events_completed_approval_test +``` + +建议环境变量优先级: + +```text +P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_PASSWORD +P1R_ACCOUNT_COMPLETED_PASSWORD +P1R_FLYWAY_PASSWORD +MUSE_POSTGRES_PASSWORD +``` + +standalone 建库与运行前置: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_ACCOUNT_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_TEST_DB=muse_p1r_account_security_events_completed_approval_test + +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -tc "SELECT 1 FROM pg_database WHERE datname = '$P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_TEST_DB'" | grep -q 1 || \ +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -c "CREATE DATABASE $P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_TEST_DB" +``` + +必测矩阵: + +1. schema gate: + - V1-V21 clean migrate 成功,`migrationsExecuted=21`。 + - `muse_member_security_event`、`muse_account_security_event_ack` 存在。 + - `muse_member_security_event` 有 tenant_id、account_user_id、device_info JSONB、ip_address、acknowledged、acknowledged_at。 + - `muse_account_security_event_ack` 有 tenant_id、event_id、account_user_id、action、command_id、risk_summary JSONB。 + - `idx_muse_member_security_user`、`trg_muse_member_security_updated_at` 存在。 + - `uk_muse_account_security_event_ack_command`、`idx_muse_account_security_event_ack_user`、`idx_muse_account_security_event_ack_event`、`trg_muse_account_security_event_ack_updated_at` 存在。 + - test-local `member_user` fixture 表存在,覆盖真实实体映射列,并只用于 seed 登录用户。 +2. `appListSecurityEvents`: + - 同 tenant / same owner 返回 list。 + - severity filter 只返回目标严重度。 + - 只合并当前 owner 最新 ack。 + - 返回摘要不泄露原始 IP / device_info。 + - 跨 owner、跨 tenant 不可见。 + - 缺少 `X-API-Version` 不调用 service、不写库。 + - read no-write:请求前后 `muse_member_security_event`、`muse_account_security_event_ack`、`muse_account_command`、`muse_account_audit`、`muse_account_event_publish_outbox` 全表快照一致。 + - no-write 快照必须使用 `row_to_json(t)::text` 级别内容比对并按稳定键排序,至少按 `tenant_id, id` 排序;不能退化成只比较 row count。 +3. `appGetSecurityEvent`: + - detail 返回脱敏 sourceIp、deviceInfo.userAgent。 + - 合并最新 ack 的 acknowledgedAt / acknowledgedAction。 + - missing、非数字 eventId、跨 owner、跨 tenant 都返回 not found 语义且不泄露事件存在性。 + - read no-write,使用与 list 相同的 `row_to_json(t)::text` 全表快照集合。 + +最低 XML tests 数:建议 `P1rAccountSecurityEventsCompletedApprovalIT` >= 8。 + +### Task 3:TDD GREEN,最小 scanner/report 状态推进 + +只修改 `muse-cloud/scripts/p1r-audit-api-coverage.py`: + +- 不修改 `APPROVED_COMPLETED_DOMAINS`。 +- 只向 `APPROVED_COMPLETED_OPERATIONS` 追加: + +```text +account:appListSecurityEvents +account:appGetSecurityEvent +``` + +- 保持 completed 必须仍是 `dedicated` 的校验。 + +生成 report: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +预期 summary: + +```text +total=233 +completed=147 +needsVerification=86 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +预期 Account: + +```text +completed=12 +needsVerification=21 +``` + +### Task 4:Account focused verification + +运行 Account security focused tests: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_ACCOUNT_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_TEST_DB=muse_p1r_account_security_events_completed_approval_test +export P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_JDBC_URL="jdbc:postgresql://${MUSE_POSTGRES_HOST}:${MUSE_POSTGRES_PORT}/${P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_TEST_DB}" + +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -tc "SELECT 1 FROM pg_database WHERE datname = '$P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_TEST_DB'" | grep -q 1 || \ +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -c "CREATE DATABASE $P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_TEST_DB" + +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-module-member/muse-module-member-server,muse-server -am \ + -Dtest=AccountSecurityServiceTest,AppAccountSecurityControllerTest,AccountSecurityEventAckMapperTest,MemberSecurityEventMapperTest,AccountConvertTest,P1rAccountSecurityEventsCompletedApprovalIT,P1rAccountRealApiGateTest,P1rApiCoverageReportTest \ + -Dflyway.postgresql.transactional.lock=false \ + -Dp1r.flyway.locations=filesystem:sql/muse \ + -Dp1r.flyway.url="$P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_JDBC_URL" \ + -Dp1r.flyway.user="$MUSE_POSTGRES_USERNAME" \ + -Dp1r.account.security-events.completed-approval.jdbc-url="$P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_APPROVAL_JDBC_URL" \ + -Dp1r.account.security-events.completed-approval.jdbc-user="$MUSE_POSTGRES_USERNAME" \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +必须读取以下 XML 并检查 `tests>0`、`failures=0`、`errors=0`、`skipped=0`: + +| Test class | 最低 tests | +|---|---:| +| `AccountSecurityServiceTest` | 8 | +| `AppAccountSecurityControllerTest` | 6 | +| `AccountSecurityEventAckMapperTest` | 4 | +| `MemberSecurityEventMapperTest` | 2 | +| `AccountConvertTest` | 8 | +| `P1rAccountSecurityEventsCompletedApprovalIT` | 8 | +| `P1rAccountRealApiGateTest` | 5 | +| `P1rApiCoverageReportTest` | 6 | + +`AccountSecurityServiceTest`、`AccountSecurityEventAckMapperTest` 中涉及 ack 或 `session_revoked` 的既有断言,只作为现状回归和 XML 防空跑证据,不计入本轮 completed evidence;`appAcknowledgeSecurityEvent` 仍必须保持 `needs_verification`。 + +如实现修改了 Account gate 测试数量,XML 检查的最低 tests 可高于上表,但不能低于上表。 + +建议使用以下 XML 防空跑脚本检查 Task 4、Task 5 和 Task 6 的目标 XML: + +```bash +python3 - <<'PY' +from pathlib import Path +import sys +import xml.etree.ElementTree as ET + +expected = { + "TEST-cn.iocoder.muse.module.member.application.account.AccountSecurityServiceTest.xml": 8, + "TEST-cn.iocoder.muse.module.member.controller.app.account.AppAccountSecurityControllerTest.xml": 6, + "TEST-cn.iocoder.muse.module.member.dal.mysql.account.AccountSecurityEventAckMapperTest.xml": 4, + "TEST-cn.iocoder.muse.module.member.dal.mysql.account.MemberSecurityEventMapperTest.xml": 2, + "TEST-cn.iocoder.muse.module.member.convert.account.AccountConvertTest.xml": 8, + "TEST-cn.iocoder.muse.server.framework.api.P1rAccountSecurityEventsCompletedApprovalIT.xml": 8, + "TEST-cn.iocoder.muse.server.framework.api.P1rAccountEventsPublishFlywayMigrationIT.xml": 4, + "TEST-cn.iocoder.muse.server.framework.api.P1rApiCoverageReportTest.xml": 6, + "TEST-cn.iocoder.muse.server.framework.api.P1rAccountRealApiGateTest.xml": 5, + "TEST-cn.iocoder.muse.server.framework.api.P1rEventsRealApiGateTest.xml": 5, + "TEST-cn.iocoder.muse.server.framework.api.P1rAiRealApiGateTest.xml": 7, + "TEST-cn.iocoder.muse.server.framework.api.P1rKnowledgeRealApiGateTest.xml": 8, + "TEST-cn.iocoder.muse.server.framework.api.P1rMarketRealApiGateTest.xml": 6, + "TEST-cn.iocoder.muse.server.framework.api.P1rContentRealApiGateTest.xml": 5, + "TEST-cn.iocoder.muse.server.framework.api.P1rMetaRealApiGateTest.xml": 4, +} + +roots = [ + Path("muse-module-member/muse-module-member-server/target/surefire-reports"), + Path("muse-server/target/surefire-reports"), +] +failures = [] +for name, minimum in expected.items(): + xml_path = next((root / name for root in roots if (root / name).exists()), None) + if xml_path is None: + failures.append(f"missing {name}") + continue + suite = ET.parse(xml_path).getroot() + tests = int(suite.attrib.get("tests", "0")) + failed = int(suite.attrib.get("failures", "0")) + errors = int(suite.attrib.get("errors", "0")) + skipped = int(suite.attrib.get("skipped", "0")) + if tests < minimum or failed or errors or skipped: + failures.append(f"{name}: tests={tests}, failures={failed}, errors={errors}, skipped={skipped}, minimum={minimum}") + +if failures: + print("\n".join(failures)) + sys.exit(1) +print("P1R Account Security Events XML gate passed") +PY +``` + +### Task 5:Account Flyway `_test` gate + +fresh rerun `P1rAccountEventsPublishFlywayMigrationIT`。使用独立 `_test` 库,不能使用生产库或非 `_test` 库: + +```bash +source ~/.config/muse-repo/infra.env +export P1R_FLYWAY_PASSWORD="$MUSE_POSTGRES_PASSWORD" +export P1R_ACCOUNT_TEST_DB=muse_p1r_account_security_events_flyway_test + +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -tc "SELECT 1 FROM pg_database WHERE datname = '$P1R_ACCOUNT_TEST_DB'" | grep -q 1 || \ +PGPASSWORD="$MUSE_POSTGRES_PASSWORD" psql \ + -h "$MUSE_POSTGRES_HOST" \ + -p "$MUSE_POSTGRES_PORT" \ + -U "$MUSE_POSTGRES_USERNAME" \ + -d postgres \ + -c "CREATE DATABASE $P1R_ACCOUNT_TEST_DB" + +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rAccountEventsPublishFlywayMigrationIT \ + -Dflyway.postgresql.transactional.lock=false \ + -Dp1r.flyway.locations=filesystem:sql/muse \ + -Dp1r.flyway.url="jdbc:postgresql://$MUSE_POSTGRES_HOST:$MUSE_POSTGRES_PORT/$P1R_ACCOUNT_TEST_DB" \ + -Dp1r.flyway.user="$MUSE_POSTGRES_USERNAME" \ + -Djava.net.useSystemProxies=false \ + -DsocksProxyHost= -DsocksProxyPort= \ + -Dhttp.proxyHost= -Dhttp.proxyPort= \ + -Dhttps.proxyHost= -Dhttps.proxyPort= \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +必须检查 XML: + +| Test class | 最低 tests | +|---|---:| +| `P1rAccountEventsPublishFlywayMigrationIT` | 4 | + +### Task 6:P1R mixed gate + +运行 mixed gates: + +```bash +cd muse-cloud +JAVA_HOME=$(/usr/libexec/java_home -v 21) PATH="$JAVA_HOME/bin:$PATH" \ +mvn -o test -pl muse-server -am \ + -Dtest=P1rApiCoverageReportTest,P1rAccountRealApiGateTest,P1rEventsRealApiGateTest,P1rAiRealApiGateTest,P1rKnowledgeRealApiGateTest,P1rMarketRealApiGateTest,P1rContentRealApiGateTest,P1rMetaRealApiGateTest \ + -Dsurefire.failIfNoSpecifiedTests=false +``` + +必须读取以下 XML: + +| Test class | 最低 tests | +|---|---:| +| `P1rApiCoverageReportTest` | 6 | +| `P1rAccountRealApiGateTest` | 5 | +| `P1rEventsRealApiGateTest` | 5 | +| `P1rAiRealApiGateTest` | 7 | +| `P1rKnowledgeRealApiGateTest` | 8 | +| `P1rMarketRealApiGateTest` | 6 | +| `P1rContentRealApiGateTest` | 5 | +| `P1rMetaRealApiGateTest` | 4 | + +每个 XML 均必须 `failures=0`、`errors=0`、`skipped=0`。 + +### Task 7:报告与 diff gate + +检查 summary: + +```bash +jq -r '.summary | [.totalOperations,.completedOperations,.needsVerificationOperations,.incompleteOperations,.genericPersistenceOperations,.ssePlaceholderOperations] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +预期: + +```text +233 147 86 0 0 0 +``` + +检查 Account 12/21: + +```bash +python3 - <<'PY' +import json +from collections import Counter +from pathlib import Path +data = json.loads(Path("docs/superpowers/reports/p1r-api-coverage.json").read_text()) +counter = Counter(op["completionStatus"] for op in data["operations"] if op["domain"] == "account") +print(counter) +PY +``` + +预期: + +```text +Counter({'needs_verification': 21, 'completed': 12}) +``` + +检查目标 operation: + +```bash +jq -r '.operations[] | select(.domain=="account" and (.operationId=="appListSecurityEvents" or .operationId=="appGetSecurityEvent" or .operationId=="appAcknowledgeSecurityEvent")) | [.operationId,.implementationStatus,.completionStatus,(.requiresCommandId|tostring)] | @tsv' docs/superpowers/reports/p1r-api-coverage.json +``` + +预期: + +```text +appListSecurityEvents dedicated completed false +appGetSecurityEvent dedicated completed false +appAcknowledgeSecurityEvent dedicated needs_verification true +``` + +检查 OpenAPI protected diff: + +```bash +git diff --quiet -- docs/api-contracts +git diff --cached --quiet -- docs/api-contracts +``` + +检查 business implementation 不被修改: + +```bash +git diff --quiet -- muse-cloud/muse-module-member/muse-module-member-server/src/main/java +git diff --cached --quiet -- muse-cloud/muse-module-member/muse-module-member-server/src/main/java +``` + +检查 SQL migration 不被修改: + +```bash +git diff --quiet -- muse-cloud/sql/muse +git diff --cached --quiet -- muse-cloud/sql/muse +``` + +检查 whitespace: + +```bash +git diff --check +``` + +检查 allowed diff: + +```bash +git -c core.quotePath=false status --short +``` + +输出只能包含本执行版允许路径。若出现 OpenAPI、业务实现、迁移或非批准 gate 文件,必须停止。 + +### Task 8:memory 与 `.agent` + +实现通过后新增: + +```text +docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md +``` + +内容必须记录: + +- 批准的 2 个 Account operation。 +- 保持 `needs_verification` 的 21 个 Account operation 及原因。 +- `appAcknowledgeSecurityEvent` 暂缓原因:`session_revoked` 合同与实现不闭合。 +- scanner/report/gate 修改范围。 +- 所有验证命令、结果、XML 计数、Flyway `_test` 库名。 +- protected diff 与 allowed diff 结果。 +- 明确说明不代表 Account 33/33 completed,不代表 Market/Content/总 P1R completed。 + +同时更新 `docs/agent-specs/.agent`,但只能记录事实,不写未验证结论。 + +### Task 9:fresh implementation review + +实现、验证和文档留痕后,必须派发两类 fresh reviewer: + +- spec/correctness reviewer:检查 2 个 operation 是否严格匹配本执行版,21 个 operation 是否仍 needs_verification,Account domain allowlist 是否未新增。 +- quality/data-integrity/testing reviewer:检查 HTTP+DB `_test`、test-local auth fixture、scanner/report/gate、XML 防空跑、Flyway `_test`、protected diff、allowed diff、rollback 是否可靠。 + +双 PASS 前不得提交、push 或宣称 Account Security Events completed approval 已收口。 + +## 回滚策略 + +如实现后需要撤回: + +1. 从 `APPROVED_COMPLETED_OPERATIONS` 移除 2 个 `account:*` key。 +2. 重新运行: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +3. 恢复 gate 期望: + +```text +summary completed=145 +summary needsVerification=88 +account completed=10 +account needsVerification=23 +``` + +4. 删除或修订本轮 memory,保留失败原因。 +5. 重新运行 Account/P1R focused gates 和 `git diff --check`。 + +## 验收标准 + +执行版本身可以进入实现前批准点的条件: + +1. 本文件已写入 `docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md`。 +2. `.agent` 记录执行版当前状态。 +3. `git diff --check` 通过。 +4. OpenAPI、scanner、coverage report、业务实现、SQL migration 当前无新增 diff。 +5. fresh execution spec/scope review PASS。 +6. fresh execution quality/feasibility/testing review PASS。 + +实现完成条件必须等用户批准后另行满足,不由本执行版写入自动成立。 diff --git a/docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md b/docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md new file mode 100644 index 00000000..5b4f3fad --- /dev/null +++ b/docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md @@ -0,0 +1,274 @@ +# P1R Account Security Events 状态推进 + +日期:2026-06-13 + +## 结论 + +本轮只推进 Account 用户安全事件只读 2 个 operation-level completed approval: + +- `account:appListSecurityEvents` +- `account:appGetSecurityEvent` + +Account 仍不是整域 completed。`account:appAcknowledgeSecurityEvent` 继续保持 `dedicated/needs_verification/requiresCommandId=true`。 + +本轮未修改 OpenAPI,未修改 Account 业务实现,未修改 SQL migration,未把 `account` 加入 domain-level completed allowlist。 + +## 工作区与基线 + +工作区: + +```text +/Users/qingse/.config/superpowers/worktrees/oh-my-muse/dev-1.0.0 +``` + +基线 HEAD: + +```text +304f665 test(p1r): 收口 Content Admin RiskAction completed approval 门禁 +``` + +实施前 coverage summary: + +```text +233 145 88 0 0 0 +``` + +实施后 coverage summary: + +```text +233 147 86 0 0 0 +``` + +实施后 Account 状态: + +```text +total=33 +completed=12 +needs_verification=21 +incomplete=0 +genericPersistence=0 +ssePlaceholder=0 +``` + +目标 operation 状态: + +```text +appListSecurityEvents dedicated completed false GET /app-api/muse/account/security-events +appGetSecurityEvent dedicated completed false GET /app-api/muse/account/security-events/{eventId} +``` + +必须保持 needs_verification: + +```text +appAcknowledgeSecurityEvent dedicated needs_verification true POST /app-api/muse/account/security-events/{eventId}/acknowledge +``` + +## 修改范围 + +本轮修改: + +- `muse-cloud/scripts/p1r-audit-api-coverage.py` +- `docs/superpowers/reports/p1r-api-coverage.json` +- `docs/superpowers/reports/p1r-api-coverage.md` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java` +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java` +- `docs/agent-specs/.agent` +- `docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval审阅版.md` +- `docs/agent-specs/2026-06-13-P1RAccountSecurityEventsCompletedApproval执行版.md` +- `docs/memorys/2026-06-13-P1RAccountSecurityEvents状态推进.md` + +未修改: + +- 7 个 OpenAPI。 +- Account 业务实现。 +- SQL migration。 +- Account domain-level completed allowlist。 +- 既有 Account focused tests。 + +## TDD evidence + +RED 阶段先修改 P1R gate 期望值和 2 个 Security Events read operation completed 断言,在旧 scanner/report 下运行 focused P1R gates,预期失败已出现: + +```text +Tests run: 49, Failures: 10, Errors: 0, Skipped: 0 +BUILD FAILURE +``` + +失败点来自旧 report 仍为 `completed=145 / needsVerification=88`,Account 仍为 `10 completed / 23 needs_verification`,以及 `appListSecurityEvents` / `appGetSecurityEvent` 仍为 `needs_verification`;没有编译错误或无关测试失败。 + +GREEN 阶段只把两个 `account:*` key 加入 scanner operation-level allowlist,重新生成 report,并完成 HTTP+DB `_test` evidence 和 focused gates。 + +## MockMvc HTTP 入口 + DB 证据 + +新增: + +- `muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java` + +验证方式: + +- 使用 Spring Mock web context + `MockMvc`。 +- 通过 `/app-api/muse/account/security-events` 与 `/app-api/muse/account/security-events/{eventId}` HTTP 入口进入 `AppAccountSecurityController`。 +- 使用真实 `AccountSecurityServiceImpl`、mapper、tenant SQL interceptor 和 PostgreSQL `_test` 数据库。 +- Flyway clean/migrate V1-V21。 +- 在隔离 `_test` 库创建 test-local `member_user` fixture,满足 `requireUser()` 的真实 `MemberUserMapper.selectById` 前置;该 fixture 不计入 P1R production schema evidence。 +- 密码只从环境变量读取,拒绝 JVM password system property 和 JDBC credential query。 +- `@AfterAll` 恢复 `p1r.flyway.url` / `p1r.flyway.user`,避免污染同一 Surefire JVM 后续 Flyway 测试。 + +单类验证数据库: + +```text +muse_p1r_account_security_events_completed_approval_test +``` + +通过结果: + +```text +P1rAccountSecurityEventsCompletedApprovalIT: tests=8, failures=0, errors=0, skipped=0 +``` + +覆盖内容: + +- 拒绝非 `_test` 数据库、JVM password system property 和 JDBC credential query。 +- V1-V21 schema gate,覆盖 `muse_member_security_event`、`muse_account_security_event_ack`、`muse_account_event_publish_outbox` 和 test-local `member_user`。 +- `idx_muse_member_security_user`、`trg_muse_member_security_updated_at`、`uk_muse_account_security_event_ack_command`、ack user/event 索引和 updated_at trigger。 +- list 支持 severity、分页、owner/tenant 隔离,并合并当前用户最新 ack。 +- detail 支持 eventId + owner 查询,返回脱敏 IP、脱敏 userAgent、脱敏 description / suggestedActions。 +- 请求参数不能覆盖当前登录 owner。 +- missing / invalid API version、invalid severity、missing user、missing event、非数字 eventId、跨 owner、跨 tenant 都不泄露目标事实。 +- read happy/error 都使用 `row_to_json(t)::text` 对 `member_user`、`muse_member_security_event`、`muse_account_security_event_ack`、`muse_account_command`、`muse_account_audit`、`muse_account_event_publish_outbox` 做全表快照 no-write。 + +## 组合验证 + +Account focused Maven 命令通过,muse-server 部分: + +```text +P1rAccountSecurityEventsCompletedApprovalIT: tests=8, failures=0, errors=0, skipped=0 +P1rApiCoverageReportTest: tests=6, failures=0, errors=0, skipped=0 +focused muse-server total: tests=19, failures=0, errors=0, skipped=0 +``` + +member-server focused XML: + +```text +AccountSecurityServiceTest: tests=8, failures=0, errors=0, skipped=0 +AppAccountSecurityControllerTest: tests=6, failures=0, errors=0, skipped=0 +AccountSecurityEventAckMapperTest: tests=4, failures=0, errors=0, skipped=0 +MemberSecurityEventMapperTest: tests=2, failures=0, errors=0, skipped=0 +AccountConvertTest: tests=9, failures=0, errors=0, skipped=0 +focused member-server total: tests=29, failures=0, errors=0, skipped=0 +``` + +Account V20 Flyway 迁移门禁使用真实 PostgreSQL `_test` 库: + +```text +muse_p1r_account_security_events_flyway_test +``` + +通过结果: + +```text +P1rAccountEventsPublishFlywayMigrationIT: tests=4, failures=0, errors=0, skipped=0 +flyway_success=true +migrations_executed=20 +successful_migration_count=20 +target_schema_version=20 +flyway_latest=20:extend account events publish outbox +``` + +P1R mixed gates: + +```text +P1rApiCoverageReportTest: tests=6, failures=0, errors=0, skipped=0 +P1rAccountRealApiGateTest: tests=5, failures=0, errors=0, skipped=0 +P1rEventsRealApiGateTest: tests=7, failures=0, errors=0, skipped=0 +P1rAiRealApiGateTest: tests=7, failures=0, errors=0, skipped=0 +P1rKnowledgeRealApiGateTest: tests=8, failures=0, errors=0, skipped=0 +P1rMarketRealApiGateTest: tests=6, failures=0, errors=0, skipped=0 +P1rContentRealApiGateTest: tests=5, failures=0, errors=0, skipped=0 +P1rMetaRealApiGateTest: tests=5, failures=0, errors=0, skipped=0 +mixed total: tests=49, failures=0, errors=0, skipped=0 +``` + +fresh implementation testing review 后处理: + +```text +Banach testing/data-integrity review: PASS +P2 residual: 新增 IT 未直接覆盖 X-API-Version: 2 +处理结果: 已补 invalid API version HTTP+DB no-write 断言 +复验: P1rAccountSecurityEventsCompletedApprovalIT 8/8 pass +复验: Account focused muse-server 19/19 pass +复验: P1R mixed gates 49/49 pass +复验: scanner/report 233/147/86 +复验: XML 防空跑与 protected diff 通过 +``` + +## scanner / report 验证 + +命令: + +```bash +python3 muse-cloud/scripts/p1r-audit-api-coverage.py --check +``` + +结果: + +```text +Generated docs/superpowers/reports/p1r-api-coverage.json +Generated docs/superpowers/reports/p1r-api-coverage.md +233 147 86 0 0 0 +``` + +Account 聚合: + +```text +33 12 21 0 0 0 +``` + +目标 operation 明细: + +```text +account appListSecurityEvents dedicated completed false GET /app-api/muse/account/security-events +account appGetSecurityEvent dedicated completed false GET /app-api/muse/account/security-events/{eventId} +account appAcknowledgeSecurityEvent dedicated needs_verification true POST /app-api/muse/account/security-events/{eventId}/acknowledge +``` + +## protected diff + +通过: + +```bash +git diff --check +``` + +通过: + +```bash +git diff --quiet -- docs/api-contracts muse-cloud/muse-module-member/muse-module-member-server/src/main/java muse-cloud/sql/muse +git diff --cached --quiet -- docs/api-contracts muse-cloud/muse-module-member/muse-module-member-server/src/main/java muse-cloud/sql/muse +``` + +结论: + +```text +protected_diff_empty=true +``` + +## 后续边界 + +本轮完成候选只代表 2 个 Account Security Events read operation-level completed approval。 + +仍不代表: + +- Account 33/33 completed。 +- `account:appAcknowledgeSecurityEvent` completed。 +- Account remaining 21 completed。 +- Market remaining completed。 +- Content remaining completed。 +- 总 P1R completed。 + +提交或 push 前仍需要 fresh implementation spec/correctness review + fresh implementation quality/data-integrity/testing review 双 PASS。 diff --git a/docs/superpowers/reports/p1r-api-coverage.json b/docs/superpowers/reports/p1r-api-coverage.json index f6165b02..56722367 100644 --- a/docs/superpowers/reports/p1r-api-coverage.json +++ b/docs/superpowers/reports/p1r-api-coverage.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-25T09:52:35+00:00", + "generatedAt": "2026-05-25T14:04:44+00:00", "sourceContracts": [ "docs/api-contracts/account/openapi.yaml", "docs/api-contracts/ai/openapi.yaml", @@ -11,9 +11,9 @@ ], "summary": { "totalOperations": 233, - "completedOperations": 145, + "completedOperations": 147, "incompleteOperations": 0, - "needsVerificationOperations": 88, + "needsVerificationOperations": 86, "catchAllOperations": 0, "genericPersistenceOperations": 0, "ssePlaceholderOperations": 0, @@ -2130,7 +2130,7 @@ ], "targetStage": "P1R-3 Account Real API", "implementationStatus": "dedicated", - "completionStatus": "needs_verification", + "completionStatus": "completed", "controllerFiles": [ "muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountSecurityController.java" ], @@ -2194,7 +2194,7 @@ "muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/service/user/MemberUserService.java", "muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/service/user/MemberUserServiceImpl.java" ], - "notes": "Dedicated entry exists; later P1R stage must verify DTO, state machine, persistence, and real external closure." + "notes": "Dedicated entry exists; user-approved P1R acceptance evidence allows this operation to advance to completed coverage." }, { "domain": "account", @@ -2210,7 +2210,7 @@ ], "targetStage": "P1R-3 Account Real API", "implementationStatus": "dedicated", - "completionStatus": "needs_verification", + "completionStatus": "completed", "controllerFiles": [ "muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/controller/app/account/AppAccountSecurityController.java" ], @@ -2274,7 +2274,7 @@ "muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/service/user/MemberUserService.java", "muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/service/user/MemberUserServiceImpl.java" ], - "notes": "Dedicated entry exists; later P1R stage must verify DTO, state machine, persistence, and real external closure." + "notes": "Dedicated entry exists; user-approved P1R acceptance evidence allows this operation to advance to completed coverage." }, { "domain": "account", diff --git a/docs/superpowers/reports/p1r-api-coverage.md b/docs/superpowers/reports/p1r-api-coverage.md index efda4b4a..84d881d0 100644 --- a/docs/superpowers/reports/p1r-api-coverage.md +++ b/docs/superpowers/reports/p1r-api-coverage.md @@ -1,9 +1,9 @@ # P1R API Coverage Report -- Generated at: `2026-05-25T09:52:35+00:00` +- Generated at: `2026-05-25T14:04:44+00:00` - Total operations: `233` -- Completed: `145` -- Needs verification: `88` +- Completed: `147` +- Needs verification: `86` - Incomplete: `0` - Catch-all: `0` - Generic persistence: `0` @@ -40,8 +40,8 @@ | account | app | GET | `/app-api/muse/account/purchases` | `appListPurchases` | dedicated | needs_verification | P1R-3 Account Real API | | account | app | POST | `/app-api/muse/account/quota-requests` | `appCreateQuotaRequest` | dedicated | needs_verification | P1R-3 Account Real API | | account | app | GET | `/app-api/muse/account/quota-requests/{requestId}` | `appGetQuotaRequest` | dedicated | needs_verification | P1R-3 Account Real API | -| account | app | GET | `/app-api/muse/account/security-events` | `appListSecurityEvents` | dedicated | needs_verification | P1R-3 Account Real API | -| account | app | GET | `/app-api/muse/account/security-events/{eventId}` | `appGetSecurityEvent` | dedicated | needs_verification | P1R-3 Account Real API | +| account | app | GET | `/app-api/muse/account/security-events` | `appListSecurityEvents` | dedicated | completed | P1R-3 Account Real API | +| account | app | GET | `/app-api/muse/account/security-events/{eventId}` | `appGetSecurityEvent` | dedicated | completed | P1R-3 Account Real API | | account | app | POST | `/app-api/muse/account/security-events/{eventId}/acknowledge` | `appAcknowledgeSecurityEvent` | dedicated | needs_verification | P1R-3 Account Real API | | account | app | GET | `/app-api/muse/account/usage` | `getAppUsage` | dedicated | needs_verification | P1R-3 Account Real API | | account | app | GET | `/app-api/muse/me` | `getCurrentUser` | dedicated | completed | P1R-3 Account Real API | diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java index f70ec23e..19049a16 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountRealApiGateTest.java @@ -40,7 +40,7 @@ class P1rAccountRealApiGateTest { "blocked" ); - /** 已批准的 Account 第一批本域闭合接口,仍保持 operation-level approval。 */ + /** 已批准的 Account 本域闭合接口,仍保持 operation-level approval。 */ private static final Set APPROVED_ACCOUNT_COMPLETED_OPERATIONS = Set.of( "getCurrentUser", "getProfile", @@ -51,7 +51,9 @@ class P1rAccountRealApiGateTest { "adminGetBalanceSnapshots", "getAppBalanceSnapshots", "adminCreateQuotaAdjustment", - "adminListQuotaAdjustments" + "adminListQuotaAdjustments", + "appListSecurityEvents", + "appGetSecurityEvent" ); /** Content completed approval 当前只允许推进 14 个 operation。 */ @@ -189,8 +191,8 @@ class P1rAccountRealApiGateTest { } } - assertEquals(10, completed, "Account 第一批只能有 10 个 operation completed"); - assertEquals(23, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); + assertEquals(12, completed, "Account Security Events read completed approval 后只能有 12 个 operation completed"); + assertEquals(21, needsVerification, "Account 剩余 21 个 operation 必须继续 needs_verification"); } /** diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java new file mode 100644 index 00000000..2e4ef8e5 --- /dev/null +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountSecurityEventsCompletedApprovalIT.java @@ -0,0 +1,1067 @@ +package cn.iocoder.muse.server.framework.api; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.muse.framework.common.biz.infra.logger.ApiErrorLogCommonApi; +import cn.iocoder.muse.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO; +import cn.iocoder.muse.framework.common.enums.UserTypeEnum; +import cn.iocoder.muse.framework.common.pojo.CommonResult; +import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration; +import cn.iocoder.muse.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.muse.framework.security.core.LoginUser; +import cn.iocoder.muse.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.muse.framework.tenant.config.TenantProperties; +import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.muse.framework.tenant.core.db.TenantDatabaseInterceptor; +import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration; +import cn.iocoder.muse.module.member.application.account.AccountAuditService; +import cn.iocoder.muse.module.member.application.account.AccountCommandService; +import cn.iocoder.muse.module.member.application.account.AccountSecurityServiceImpl; +import cn.iocoder.muse.module.member.controller.app.account.AppAccountSecurityController; +import cn.iocoder.muse.module.member.dal.dataobject.account.AccountAuditDO; +import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO; +import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor; +import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import javax.sql.DataSource; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_FOUND; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED; +import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P1R Account Security Events completed approval:2 个用户端安全事件只读 operation 的 HTTP + 真实 PostgreSQL 证据。 + * + *

本测试只连接显式传入的 PostgreSQL {@code _test} 库,通过真实 {@code /app-api/muse/**} + * 前缀进入 Controller,并使用真实 {@link AccountSecurityServiceImpl} 与安全事件 mapper。 + * 它只证明 {@code appListSecurityEvents} 与 {@code appGetSecurityEvent} 的 owner/tenant 隔离、 + * API version、敏感字段脱敏、latest ack 合并和 read no-write;不把 acknowledge 或 + * {@code session_revoked} 写路径计入 completed evidence。

+ */ +@SpringBootTest( + classes = P1rAccountSecurityEventsCompletedApprovalIT.AccountSecurityEventsCompletedApprovalConfiguration.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK +) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class P1rAccountSecurityEventsCompletedApprovalIT { + + private static final String TARGET_VERSION = "21"; + private static final Long TENANT_ID = 100L; + private static final Long OTHER_TENANT_ID = 200L; + private static final Long ACCOUNT_USER_ID = 1001L; + private static final Long OTHER_USER_ID = 2002L; + private static final Long MISSING_USER_ID = 9099L; + private static final String API_VERSION = "1"; + private static final String RAW_IP = "203.0.113.44"; + private static final String RAW_TOKEN = "sk-security-token-raw-value"; + private static final String HASH_64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + private static final Set CREDENTIAL_QUERY_KEYS = Set.of( + "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", + "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"); + + private static volatile AccountSecurityEventsCompletedApprovalSettings cachedSettings; + private static volatile boolean originalFlywayPropertiesCaptured; + private static volatile String originalFlywayUrlSystemProperty; + private static volatile String originalFlywayUserSystemProperty; + + @Autowired + private DataSource dataSource; + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + private SeedFacts seedFacts; + + @DynamicPropertySource + static void registerAccountSecurityEventsCompletedApprovalProperties(DynamicPropertyRegistry registry) { + AccountSecurityEventsCompletedApprovalSettings settings = settings(); + redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser()); + registry.add("spring.application.name", () -> "p1r-account-security-events-completed-approval-it"); + registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.member"); + registry.add("muse.web.admin-ui.url", () -> "http://localhost"); + registry.add("spring.datasource.url", settings::jdbcUrl); + registry.add("spring.datasource.username", settings::jdbcUser); + registry.add("spring.datasource.password", settings::jdbcPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.main.banner-mode", () -> "off"); + registry.add("spring.main.lazy-initialization", () -> "true"); + registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO"); + } + + @BeforeAll + void migrateAccountSchemaAndCreateMemberUserFixture() { + AccountSecurityEventsCompletedApprovalSettings settings = settings(); + silenceFlywayInfoLogs(); + Flyway flyway = Flyway.configure() + .dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword()) + .locations(resolveMuseSqlLocation(settings.flywayLocations())) + .schemas("public") + .defaultSchema("public") + .target(TARGET_VERSION) + .cleanDisabled(false) + .load(); + cleanSchema(flyway, settings); + MigrateResult result = migrateSchema(flyway, settings); + assertEquals(21, result.migrationsExecuted, + "Account Security Events completed approval 必须在隔离库执行 V1-V21 全量迁移,实际: " + + result.migrationsExecuted); + createMemberUserFixtureTable(); + } + + @BeforeEach + void setUp() throws Exception { + this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + resetAccountTables(); + this.seedFacts = seedAccountSecurityFacts(); + setRuntimeContext(ACCOUNT_USER_ID); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + TenantContextHolder.clear(); + } + + @AfterAll + void restoreFlywaySystemProperties() { + restoreOriginalFlywaySystemProperties(); + } + + @Test + void should_rejectUnsafeDatabaseConfigurationInputs() { + System.setProperty("p1r.account.security-events.completed-approval.password", "must-not-be-used"); + try { + AssertionError error = assertThrows(AssertionError.class, + P1rAccountSecurityEventsCompletedApprovalIT::assertNoPasswordSystemProperties); + assertTrue(error.getMessage().contains("p1r.account.security-events.completed-approval.password"), + "拒绝 JVM password system property 时必须指出属性名"); + } finally { + System.clearProperty("p1r.account.security-events.completed-approval.password"); + } + + AssertionError credentialQueryError = assertThrows(AssertionError.class, + () -> assertNoCredentialQuery("jdbc:postgresql://localhost:5432/muse_test?password=secret")); + assertTrue(credentialQueryError.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"), + "拒绝 JDBC credential query 时必须说明连接串只能通过环境变量传密码"); + + AssertionError databaseNameError = assertThrows(AssertionError.class, + () -> assertTestDatabaseUrl("jdbc:postgresql://localhost:5432/muse_prod")); + assertTrue(databaseNameError.getMessage().contains("_test"), + "拒绝非 _test 数据库时必须指出隔离库后缀要求"); + } + + @Test + void should_verifyV21SecurityEventsSchemaAndMemberUserFixture() throws Exception { + assertEquals(21, successfulMigrationCount(), "隔离库必须执行 V1-V21 共 21 个成功 SQL migration"); + assertTrue(tableExists("muse_member_security_event"), "V2 安全事件事实表必须存在"); + assertTrue(tableExists("muse_account_security_event_ack"), "V11 安全事件确认轨迹表必须存在"); + assertTrue(tableExists("muse_account_event_publish_outbox"), "V20 Account outbox 必须存在,证明 live baseline 已到 V21"); + assertTrue(tableExists("member_user"), "test-local member_user auth fixture 表必须存在"); + + assertColumn("muse_member_security_event", "tenant_id", "bigint"); + assertColumn("muse_member_security_event", "account_user_id", "bigint"); + assertColumn("muse_member_security_event", "device_info", "jsonb"); + assertColumn("muse_member_security_event", "ip_address", "character varying"); + assertColumn("muse_member_security_event", "acknowledged", "boolean"); + assertColumn("muse_member_security_event", "acknowledged_at", "timestamp without time zone"); + assertTrue(indexExists("idx_muse_member_security_user"), "安全事件必须保留 tenant/user/time 索引"); + assertTrue(triggerExists("muse_member_security_event", "trg_muse_member_security_updated_at"), + "安全事件必须保留 update_time trigger"); + + assertColumn("muse_account_security_event_ack", "tenant_id", "bigint"); + assertColumn("muse_account_security_event_ack", "event_id", "bigint"); + assertColumn("muse_account_security_event_ack", "account_user_id", "bigint"); + assertColumn("muse_account_security_event_ack", "action", "character varying"); + assertColumn("muse_account_security_event_ack", "command_id", "character varying"); + assertColumn("muse_account_security_event_ack", "risk_summary", "jsonb"); + assertTrue(constraintExists("uk_muse_account_security_event_ack_command"), + "ack 轨迹必须保留 tenant + command_id 唯一约束"); + assertTrue(indexExists("idx_muse_account_security_event_ack_user"), "ack 轨迹必须保留 user 查询索引"); + assertTrue(indexExists("idx_muse_account_security_event_ack_event"), "ack 轨迹必须保留 event 查询索引"); + assertTrue(triggerExists("muse_account_security_event_ack", "trg_muse_account_security_event_ack_updated_at"), + "ack 轨迹必须保留 update_time trigger"); + + for (String column : List.of("id", "mobile", "password", "status", "register_ip", "register_terminal", + "login_ip", "login_date", "nickname", "avatar", "name", "sex", "birthday", "area_id", "mark", + "point", "tag_ids", "level_id", "experience", "group_id", "tenant_id", "creator", "create_time", + "updater", "update_time", "deleted")) { + assertTrue(columnExists("member_user", column), "member_user fixture 必须覆盖实体映射列: " + column); + } + } + + @Test + void should_listSecurityEventsWithSeverityAckMergeTenantIsolationAndNoSensitiveLeak() throws Exception { + assertNoAccountMutationDuring("appListSecurityEvents severity 过滤是纯读 operation", + () -> mockMvc.perform(get("/app-api/muse/account/security-events") + .header("X-API-Version", API_VERSION) + .param("pageNo", "1") + .param("pageSize", "10") + .param("severity", "critical") + .param("userId", String.valueOf(OTHER_USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.pageNo").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(10)) + .andExpect(jsonPath("$.data.list[0].eventId").value(String.valueOf(seedFacts.criticalEventId()))) + .andExpect(jsonPath("$.data.list[0].eventType").value("login_anomaly")) + .andExpect(jsonPath("$.data.list[0].severity").value("critical")) + .andExpect(jsonPath("$.data.list[0].acknowledged").value(true)) + .andExpect(jsonPath("$.data.list[0].deviceInfo").doesNotExist()) + .andExpect(content().string(not(containsString(RAW_IP)))) + .andExpect(content().string(not(containsString(RAW_TOKEN)))) + .andExpect(content().string(not(containsString("Other Tenant Security"))))); + } + + @Test + void should_listSecurityEventsWithDefaultPageAndIgnoreRequestOwnerSpoof() throws Exception { + assertNoAccountMutationDuring("appListSecurityEvents 默认分页不能被 owner 参数绕过", + () -> mockMvc.perform(get("/app-api/muse/account/security-events") + .header("X-API-Version", API_VERSION) + .param("userId", String.valueOf(OTHER_USER_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(3)) + .andExpect(jsonPath("$.data.pageNo").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(20)) + .andExpect(jsonPath("$.data.list[*].eventId", hasItem(String.valueOf(seedFacts.criticalEventId())))) + .andExpect(jsonPath("$.data.list[*].eventId", hasItem(String.valueOf(seedFacts.warningEventId())))) + .andExpect(jsonPath("$.data.list[*].eventId", hasItem(String.valueOf(seedFacts.acknowledgedEventId())))) + .andExpect(jsonPath("$.data.list[*].eventId", not(hasItem(String.valueOf(seedFacts.otherOwnerEventId()))))) + .andExpect(jsonPath("$.data.list[*].eventId", not(hasItem(String.valueOf(seedFacts.otherTenantEventId()))))) + .andExpect(content().string(not(containsString("Other Owner Security")))) + .andExpect(content().string(not(containsString("Other Tenant Security"))))); + } + + @Test + void should_rejectMissingApiVersionInvalidSeverityAndMissingUserWithoutWrite() throws Exception { + setRuntimeContext(MISSING_USER_ID); + assertNoAccountMutationDuring("缺少 API version 必须在 requireUser 前拒绝且不能写库", + () -> mockMvc.perform(get("/app-api/muse/account/security-events")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(content().string(not(containsString("P1R Security"))))); + + assertNoAccountMutationDuring("非法 API version 必须在 requireUser 前拒绝且不能写库", + () -> mockMvc.perform(get("/app-api/muse/account/security-events") + .header("X-API-Version", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(content().string(not(containsString("P1R Security"))))); + + setRuntimeContext(ACCOUNT_USER_ID); + assertNoAccountMutationDuring("非法 severity 必须拒绝且不能写库", + () -> mockMvc.perform(get("/app-api/muse/account/security-events") + .header("X-API-Version", API_VERSION) + .param("severity", "high")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + + setRuntimeContext(MISSING_USER_ID); + assertNoAccountMutationDuring("缺失登录用户事实必须拒绝且不能写库", + () -> mockMvc.perform(get("/app-api/muse/account/security-events") + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode())) + .andExpect(jsonPath("$.data").doesNotExist())); + setRuntimeContext(ACCOUNT_USER_ID); + } + + @Test + void should_getSecurityEventDetailWithMaskedSensitiveFieldsLatestAckAndNoWrite() throws Exception { + assertNoAccountMutationDuring("appGetSecurityEvent detail happy path 是纯读 operation", + () -> mockMvc.perform(get("/app-api/muse/account/security-events/{eventId}", seedFacts.criticalEventId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.eventId").value(String.valueOf(seedFacts.criticalEventId()))) + .andExpect(jsonPath("$.data.eventType").value("login_anomaly")) + .andExpect(jsonPath("$.data.severity").value("critical")) + .andExpect(jsonPath("$.data.sourceIp").value("203.0.113.***")) + .andExpect(jsonPath("$.data.deviceInfo.userAgent").value("Mozilla/5.0 ***")) + .andExpect(jsonPath("$.data.deviceInfo.platform").value("macOS")) + .andExpect(jsonPath("$.data.deviceInfo.location").value("CN Shanghai")) + .andExpect(jsonPath("$.data.affectedScope").value("账户登录会话")) + .andExpect(jsonPath("$.data.acknowledgedAction").value("password_changed")) + .andExpect(jsonPath("$.data.acknowledgedAt").isNotEmpty()) + .andExpect(content().string(not(containsString(RAW_IP)))) + .andExpect(content().string(not(containsString(RAW_TOKEN)))) + .andExpect(content().string(not(containsString("session_revoked")))) + .andExpect(content().string(not(containsString("ipAddress"))))); + } + + @Test + void should_getSecurityEventErrorPathsWithoutLeakingOrWriting() throws Exception { + assertNoAccountMutationDuring("missing detail path 不能泄露也不能写库", + () -> assertAccountError(get("/app-api/muse/account/security-events/{eventId}", 999999L) + .header("X-API-Version", API_VERSION), + NOT_FOUND.getCode(), "missing event")); + assertNoAccountMutationDuring("非数字 eventId 必须按 not found 收敛且不能写库", + () -> assertAccountError(get("/app-api/muse/account/security-events/not-a-number") + .header("X-API-Version", API_VERSION), + NOT_FOUND.getCode(), "non numeric event")); + assertNoAccountMutationDuring("cross owner detail path 不能泄露也不能写库", + () -> assertAccountError(get("/app-api/muse/account/security-events/{eventId}", seedFacts.otherOwnerEventId()) + .header("X-API-Version", API_VERSION), + NOT_FOUND.getCode(), "cross owner event")); + assertNoAccountMutationDuring("cross tenant detail path 不能泄露也不能写库", + () -> assertAccountError(get("/app-api/muse/account/security-events/{eventId}", seedFacts.otherTenantEventId()) + .header("X-API-Version", API_VERSION), + NOT_FOUND.getCode(), "cross tenant event")); + + setRuntimeContext(MISSING_USER_ID); + assertNoAccountMutationDuring("缺少 API version 的 detail 必须在 requireUser 前拒绝且不能写库", + () -> mockMvc.perform(get("/app-api/muse/account/security-events/{eventId}", seedFacts.criticalEventId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode())) + .andExpect(content().string(not(containsString("P1R Security"))))); + setRuntimeContext(ACCOUNT_USER_ID); + } + + @Test + void should_getSecurityEventUseBaseAcknowledgedAtAndReturnUnprocessedEventWithoutAck() throws Exception { + assertNoAccountMutationDuring("基础事件 acknowledged=true 时 detail 只回传 acknowledgedAt", + () -> mockMvc.perform(get("/app-api/muse/account/security-events/{eventId}", seedFacts.acknowledgedEventId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.acknowledgedAt").isNotEmpty()) + .andExpect(jsonPath("$.data.acknowledgedAction").doesNotExist())); + + assertNoAccountMutationDuring("未处理且无 ack 的事件 detail 不应伪造确认信息", + () -> mockMvc.perform(get("/app-api/muse/account/security-events/{eventId}", seedFacts.warningEventId()) + .header("X-API-Version", API_VERSION)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.acknowledgedAt").doesNotExist()) + .andExpect(jsonPath("$.data.acknowledgedAction").doesNotExist())); + } + + private void setRuntimeContext(Long userId) { + TenantContextHolder.setTenantId(TENANT_ID); + LoginUser loginUser = new LoginUser(); + loginUser.setId(userId); + loginUser.setUserType(UserTypeEnum.MEMBER.getValue()); + loginUser.setTenantId(TENANT_ID); + loginUser.setVisitTenantId(TENANT_ID); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + } + + private void resetAccountTables() throws SQLException { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + // 每个测试重置 Account read 会触达或必须保持不变的 fact,保证 no-write 断言能识别任何误写。 + statement.execute(""" + TRUNCATE TABLE + muse_account_event_publish_outbox, + muse_account_audit, + muse_account_command, + muse_account_security_event_ack, + muse_member_security_event, + member_user + RESTART IDENTITY CASCADE + """); + } + } + + private SeedFacts seedAccountSecurityFacts() throws SQLException { + LocalDateTime base = LocalDateTime.of(2026, 5, 29, 10, 0); + try (Connection connection = dataSource.getConnection()) { + insertMemberUser(connection, TENANT_ID, ACCOUNT_USER_ID, "Muse Owner"); + insertMemberUser(connection, TENANT_ID, OTHER_USER_ID, "Other Owner"); + + Long warningEventId = insertSecurityEvent(connection, TENANT_ID, ACCOUNT_USER_ID, "device_change", + "warning", "P1R Security Warning", "198.51.100.77", false, null, base.plusMinutes(5)); + Long acknowledgedEventId = insertSecurityEvent(connection, TENANT_ID, ACCOUNT_USER_ID, "credential_expired", + "info", "P1R Security Acknowledged", "198.51.100.88", true, base.plusMinutes(18), + base.plusMinutes(8)); + Long criticalEventId = insertSecurityEvent(connection, TENANT_ID, ACCOUNT_USER_ID, "login_anomaly", + "critical", "P1R Security Critical", RAW_IP, false, null, base.plusMinutes(10)); + Long otherOwnerEventId = insertSecurityEvent(connection, TENANT_ID, OTHER_USER_ID, "login_anomaly", + "critical", "Other Owner Security", "203.0.113.55", false, null, base.plusMinutes(11)); + Long otherTenantEventId = insertSecurityEvent(connection, OTHER_TENANT_ID, ACCOUNT_USER_ID, "login_anomaly", + "critical", "Other Tenant Security", "203.0.113.66", false, null, base.plusMinutes(12)); + + insertSecurityAck(connection, TENANT_ID, criticalEventId, ACCOUNT_USER_ID, "acknowledged", + "older", "ack-critical-old", base.plusMinutes(20)); + insertSecurityAck(connection, TENANT_ID, criticalEventId, ACCOUNT_USER_ID, "password_changed", + "latest", "ack-critical-new", base.plusMinutes(30)); + insertSecurityAck(connection, TENANT_ID, criticalEventId, OTHER_USER_ID, "session_revoked", + "other owner latest", "ack-critical-other-owner", base.plusMinutes(40)); + + return new SeedFacts(criticalEventId, warningEventId, acknowledgedEventId, + otherOwnerEventId, otherTenantEventId); + } + } + + private void insertMemberUser(Connection connection, Long tenantId, Long userId, String nickname) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO member_user(id, mobile, password, status, register_ip, register_terminal, + login_ip, login_date, nickname, avatar, name, sex, birthday, area_id, + mark, point, tag_ids, level_id, experience, group_id, tenant_id) + VALUES (?, ?, 'encoded-password', 0, '127.0.0.1', 1, + '127.0.0.1', CURRENT_TIMESTAMP, ?, '', ?, 0, CURRENT_TIMESTAMP, 0, + '', 0, NULL, NULL, 0, NULL, ?) + """)) { + statement.setLong(1, userId); + statement.setString(2, "1380000" + userId); + statement.setString(3, nickname); + statement.setString(4, nickname); + statement.setLong(5, tenantId); + assertEquals(1, statement.executeUpdate(), "seed member_user fixture 必须插入一行"); + } + } + + private Long insertSecurityEvent(Connection connection, Long tenantId, Long accountUserId, String eventType, + String severity, String label, String ipAddress, boolean acknowledged, + LocalDateTime acknowledgedAt, LocalDateTime createTime) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_member_security_event(account_user_id, event_type, severity, device_info, + ip_address, acknowledged, acknowledged_at, tenant_id, create_time) + VALUES (?, ?, ?, ?::jsonb, ?, ?, ?, ?, ?) + RETURNING id + """)) { + statement.setLong(1, accountUserId); + statement.setString(2, eventType); + statement.setString(3, severity); + statement.setString(4, deviceInfoJson(label, ipAddress)); + statement.setString(5, ipAddress); + statement.setBoolean(6, acknowledged); + if (acknowledgedAt == null) { + statement.setTimestamp(7, null); + } else { + statement.setTimestamp(7, Timestamp.valueOf(acknowledgedAt)); + } + statement.setLong(8, tenantId); + statement.setTimestamp(9, Timestamp.valueOf(createTime)); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "seed security event 必须返回 id"); + return resultSet.getLong(1); + } + } + } + + private String deviceInfoJson(String label, String ipAddress) { + return """ + { + "userAgent": "Mozilla/5.0 AppleWebKit/537.36", + "platform": "macOS", + "location": "CN Shanghai", + "affectedScope": "账户登录会话", + "description": "%s from %s Authorization: Bearer %s", + "suggestedActions": ["修改密码", "重新登录并检查 token %s"] + } + """.formatted(label, ipAddress, RAW_TOKEN, RAW_TOKEN); + } + + private void insertSecurityAck(Connection connection, Long tenantId, Long eventId, Long accountUserId, + String action, String note, String commandId, LocalDateTime createTime) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(""" + INSERT INTO muse_account_security_event_ack(event_id, account_user_id, actor_user_id, + action, note, command_id, request_hash, + risk_summary, tenant_id, create_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?) + """)) { + statement.setLong(1, eventId); + statement.setLong(2, accountUserId); + statement.setLong(3, accountUserId); + statement.setString(4, action); + statement.setString(5, note); + statement.setString(6, commandId); + statement.setString(7, HASH_64); + statement.setString(8, "{\"summary\":\"" + action + "\"}"); + statement.setLong(9, tenantId); + statement.setTimestamp(10, Timestamp.valueOf(createTime)); + assertEquals(1, statement.executeUpdate(), "seed security ack 必须插入一行"); + } + } + + private void assertAccountError(MockHttpServletRequestBuilder request, int expectedCode, String scenario) + throws Exception { + MvcResult result = mockMvc.perform(request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(expectedCode)) + .andExpect(jsonPath("$.data").doesNotExist()) + .andReturn(); + String body = result.getResponse().getContentAsString(); + assertFalse(body.contains("Other Owner Security"), scenario + " 不能泄露跨 owner 事件"); + assertFalse(body.contains("Other Tenant Security"), scenario + " 不能泄露跨 tenant 事件"); + assertFalse(body.contains(RAW_IP), scenario + " 不能泄露原始 IP"); + assertFalse(body.contains(RAW_TOKEN), scenario + " 不能泄露 token"); + } + + private void assertNoAccountMutationDuring(String message, CheckedOperation operation) throws Exception { + AccountFactSnapshot before = accountFactSnapshot(); + operation.run(); + assertEquals(before, accountFactSnapshot(), message + ":不能新增、删除或更新任何 Account fact"); + } + + private AccountFactSnapshot accountFactSnapshot() throws SQLException { + return new AccountFactSnapshot( + tableSnapshot("member_user", "tenant_id, id"), + tableSnapshot("muse_member_security_event", "tenant_id, id"), + tableSnapshot("muse_account_security_event_ack", "tenant_id, id"), + tableSnapshot("muse_account_command", "tenant_id, id"), + tableSnapshot("muse_account_audit", "tenant_id, id"), + tableSnapshot("muse_account_event_publish_outbox", "tenant_id, id") + ); + } + + private List tableSnapshot(String tableName, String orderBy) throws SQLException { + String sql = """ + SELECT row_to_json(t)::text + FROM %s t + ORDER BY %s + """.formatted(tableName, orderBy); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql); + ResultSet resultSet = statement.executeQuery()) { + List rows = new java.util.ArrayList<>(); + while (resultSet.next()) { + rows.add(resultSet.getString(1)); + } + return rows; + } + } + + private void createMemberUserFixtureTable() { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS member_user ( + id BIGINT PRIMARY KEY, + mobile VARCHAR(32), + password VARCHAR(255), + status INTEGER, + register_ip VARCHAR(64), + register_terminal INTEGER, + login_ip VARCHAR(64), + login_date TIMESTAMP, + nickname VARCHAR(64), + avatar VARCHAR(512), + name VARCHAR(64), + sex INTEGER, + birthday TIMESTAMP, + area_id INTEGER, + mark VARCHAR(255), + point INTEGER, + tag_ids VARCHAR(255), + level_id BIGINT, + experience INTEGER, + group_id BIGINT, + tenant_id BIGINT NOT NULL DEFAULT 0, + creator VARCHAR(64) NOT NULL DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) NOT NULL DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE + ) + """); + } catch (SQLException exception) { + throw new AssertionError("创建 test-local member_user fixture 表失败", exception); + } + } + + private void assertColumn(String tableName, String columnName, String expectedDataType) throws SQLException { + assertTrue(columnExists(tableName, columnName), tableName + "." + columnName + " 必须存在"); + assertEquals(expectedDataType, columnDataType(tableName, columnName), + tableName + "." + columnName + " 类型不符合预期"); + } + + private boolean tableExists(String tableName) throws SQLException { + return exists("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = ?", + tableName); + } + + private boolean columnExists(String tableName, String columnName) throws SQLException { + return exists(""" + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? AND column_name = ? + """, tableName, columnName); + } + + private String columnDataType(String tableName, String columnName) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(""" + SELECT data_type FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = ? AND column_name = ? + """)) { + statement.setString(1, tableName); + statement.setString(2, columnName); + try (ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), tableName + "." + columnName + " 必须能读取 data_type"); + return resultSet.getString(1); + } + } + } + + private boolean indexExists(String indexName) throws SQLException { + return exists("SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = ?", indexName); + } + + private boolean constraintExists(String constraintName) throws SQLException { + return exists("SELECT 1 FROM pg_constraint WHERE conname = ?", constraintName); + } + + private boolean triggerExists(String tableName, String triggerName) throws SQLException { + return exists(""" + SELECT 1 FROM information_schema.triggers + WHERE event_object_schema = 'public' AND event_object_table = ? AND trigger_name = ? + """, tableName, triggerName); + } + + private int successfulMigrationCount() throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT COUNT(*) FROM flyway_schema_history WHERE success = TRUE AND type = 'SQL'"); + ResultSet resultSet = statement.executeQuery()) { + assertTrue(resultSet.next(), "必须能读取 flyway_schema_history"); + return resultSet.getInt(1); + } + } + + private boolean exists(String sql, String... parameters) throws SQLException { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + for (int i = 0; i < parameters.length; i++) { + statement.setString(i + 1, parameters[i]); + } + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } + } + } + + private static AccountSecurityEventsCompletedApprovalSettings settings() { + if (cachedSettings == null) { + cachedSettings = AccountSecurityEventsCompletedApprovalSettings.fromPropertiesAndEnvironment(); + } + return cachedSettings; + } + + private static void cleanSchema(Flyway flyway, AccountSecurityEventsCompletedApprovalSettings settings) { + try { + flyway.clean(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception); + } + } + + private static MigrateResult migrateSchema(Flyway flyway, AccountSecurityEventsCompletedApprovalSettings settings) { + try { + return flyway.migrate(); + } catch (RuntimeException exception) { + throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception); + } + } + + private static AssertionError sanitizedFlywayFailure(String action, + AccountSecurityEventsCompletedApprovalSettings settings, + RuntimeException exception) { + String sanitizedMessage = Objects.toString(exception.getMessage(), "") + .replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl())) + .replace("for user '" + settings.jdbcUser() + "'", "for user ''"); + return new AssertionError(action + ": " + sanitizedMessage); + } + + private static String requiredProperty(String name) { + String value = System.getProperty(name); + assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name); + return value; + } + + private static String optionalProperty(String name) { + String value = System.getProperty(name); + return value == null || value.isBlank() ? null : value; + } + + private static String firstNonBlankProperty(String primaryName, String fallbackName) { + String primary = optionalProperty(primaryName); + return primary == null ? requiredProperty(fallbackName) : primary; + } + + private static String requiredPasswordEnvironment() { + String password = firstNonBlankEnvironment("P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_PASSWORD", + "P1R_ACCOUNT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); + assertTrue(password != null, + "缺少必需环境变量: P1R_ACCOUNT_SECURITY_EVENTS_COMPLETED_PASSWORD、P1R_ACCOUNT_COMPLETED_PASSWORD、" + + "P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); + return password; + } + + private static String firstNonBlankEnvironment(String... names) { + // 数据库密码只能来自环境变量,避免 Surefire XML 或 JVM 参数泄露。 + for (String name : names) { + String value = System.getenv(name); + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private static void assertNoPasswordSystemProperties() { + Properties properties = System.getProperties(); + List passwordProperties = properties.stringPropertyNames().stream() + .filter(P1rAccountSecurityEventsCompletedApprovalIT::isForbiddenPasswordSystemProperty) + .sorted() + .toList(); + assertTrue(passwordProperties.isEmpty(), + "数据库密码不能通过 JVM system property 传入: " + passwordProperties); + } + + private static boolean isForbiddenPasswordSystemProperty(String name) { + String normalized = name.toLowerCase(Locale.ROOT); + return normalized.contains("password") + && (normalized.startsWith("p1r.") + || normalized.startsWith("p1r_") + || normalized.contains(".flyway.") + || normalized.contains(".account.") + || normalized.contains(".datasource.")); + } + + private static void assertNoCredentialQuery(String url) { + int queryStart = url.indexOf('?'); + if (queryStart < 0) { + return; + } + String query = url.substring(queryStart + 1); + for (String parameter : query.split("&")) { + String key = parameter; + int equalsStart = key.indexOf('='); + if (equalsStart >= 0) { + key = key.substring(0, equalsStart); + } + assertFalse(isCredentialQueryKey(key), + "p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入"); + } + } + + private static boolean isCredentialQueryKey(String rawKey) { + String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_'); + return CREDENTIAL_QUERY_KEYS.contains(key) + || key.endsWith("_token") + || key.endsWith("_secret") + || key.endsWith("_password"); + } + + private static void assertTestDatabaseUrl(String url) { + String databaseName = jdbcDatabaseName(url); + assertTrue(databaseName.endsWith("_test"), + "p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url)); + } + + private static String resolveMuseSqlLocation(String requestedLocations) { + assertEquals("filesystem:sql/muse", requestedLocations, + "P1R Account Security Events completed approval IT 要求显式使用 filesystem:sql/muse"); + Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + String relativeLocation = requestedLocations.substring("filesystem:".length()); + for (Path cursor = current; cursor != null; cursor = cursor.getParent()) { + Path candidate = cursor.resolve(relativeLocation); + if (Files.isDirectory(candidate)) { + return "filesystem:" + candidate; + } + } + throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current); + } + + private static String jdbcDatabaseName(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1, + "p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url)); + return urlWithoutQuery.substring(databaseStart + 1); + } + + private static String jdbcUrlWithoutQuery(String url) { + int queryStart = url.indexOf('?'); + return queryStart < 0 ? url : url.substring(0, queryStart); + } + + private static String maskedUrl(String url) { + String urlWithoutQuery = jdbcUrlWithoutQuery(url); + int databaseStart = urlWithoutQuery.lastIndexOf('/'); + if (databaseStart < 0) { + return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url); + } + String prefix = urlWithoutQuery.substring(0, databaseStart + 1); + String database = urlWithoutQuery.substring(databaseStart + 1); + return maskJdbcHost(prefix) + database + maskedQuerySuffix(url); + } + + private static String maskedQuerySuffix(String url) { + return url.indexOf('?') < 0 ? "" : "?"; + } + + private static String maskJdbcHost(String urlPart) { + return urlPart.replaceAll("//([^:/?#]+)", "//"); + } + + private static void redactFlywaySystemProperties(String url, String user) { + captureOriginalFlywaySystemProperties(); + System.setProperty("p1r.flyway.url", maskedUrl(url)); + System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "" : ""); + } + + private static void captureOriginalFlywaySystemProperties() { + if (originalFlywayPropertiesCaptured) { + return; + } + // 该 IT 会为日志脱敏 p1r.flyway.*;先保存原值,避免同一 Surefire JVM 中污染后续 Flyway IT。 + originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url"); + originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user"); + originalFlywayPropertiesCaptured = true; + } + + private static void restoreOriginalFlywaySystemProperties() { + if (!originalFlywayPropertiesCaptured) { + return; + } + // 恢复用户传入的原始连接属性,让组合运行的 Flyway 验收测试继续读取真实 _test 库地址。 + restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty); + restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty); + } + + private static void restoreSystemProperty(String name, String value) { + if (value == null) { + System.clearProperty(name); + return; + } + System.setProperty(name, value); + } + + private static void silenceFlywayInfoLogs() { + try { + Object flywayLogger = LoggerFactory.getLogger("org.flywaydb"); + Class levelClass = Class.forName("ch.qos.logback.classic.Level"); + Object warnLevel = levelClass.getField("WARN").get(null); + flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel); + } catch (ReflectiveOperationException | LinkageError ignored) { + // 日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。 + } + } + + private record SeedFacts(Long criticalEventId, + Long warningEventId, + Long acknowledgedEventId, + Long otherOwnerEventId, + Long otherTenantEventId) { + } + + private record AccountFactSnapshot(List memberUsers, + List securityEvents, + List securityEventAcks, + List commands, + List audits, + List outboxes) { + } + + @FunctionalInterface + private interface CheckedOperation { + + void run() throws Exception; + } + + private record AccountSecurityEventsCompletedApprovalSettings(String jdbcUrl, + String jdbcUser, + String jdbcPassword, + String flywayLocations) { + + static AccountSecurityEventsCompletedApprovalSettings fromPropertiesAndEnvironment() { + assertNoPasswordSystemProperties(); + String url = firstNonBlankProperty("p1r.account.security-events.completed-approval.jdbc-url", + "p1r.flyway.url"); + String user = firstNonBlankProperty("p1r.account.security-events.completed-approval.jdbc-user", + "p1r.flyway.user"); + String password = requiredPasswordEnvironment(); + String locations = requiredProperty("p1r.flyway.locations"); + assertNoCredentialQuery(url); + assertTestDatabaseUrl(url); + return new AccountSecurityEventsCompletedApprovalSettings(url, user, password, locations); + } + } + + static class TestSecurityFrameworkService implements SecurityFrameworkService { + + private final AtomicReference lastPermission = new AtomicReference<>(); + + @Override + public boolean hasPermission(String permission) { + lastPermission.set(permission); + return true; + } + + @Override + public boolean hasAnyPermissions(String... permissions) { + lastPermission.set(permissions == null || permissions.length == 0 ? null : permissions[0]); + return true; + } + + @Override + public boolean hasRole(String role) { + return false; + } + + @Override + public boolean hasAnyRoles(String... roles) { + return false; + } + + @Override + public boolean hasScope(String scope) { + return false; + } + + @Override + public boolean hasAnyScopes(String... scope) { + return false; + } + } + + @SpringBootConfiguration + @EnableMethodSecurity(securedEnabled = true) + @ImportAutoConfiguration({ + JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + DataSourceAutoConfiguration.class, + DataSourceTransactionManagerAutoConfiguration.class, + JdbcTemplateAutoConfiguration.class, + TransactionAutoConfiguration.class, + RestTemplateAutoConfiguration.class, + WebMvcAutoConfiguration.class, + MuseDataSourceAutoConfiguration.class, + MuseMybatisAutoConfiguration.class, + MybatisPlusAutoConfiguration.class, + MybatisPlusJoinAutoConfiguration.class, + MuseWebAutoConfiguration.class + }) + @Import({ + AppAccountSecurityController.class, + AccountSecurityServiceImpl.class, + SpringUtil.class + }) + static class AccountSecurityEventsCompletedApprovalConfiguration { + + @Bean("ss") + TestSecurityFrameworkService securityFrameworkService() { + return new TestSecurityFrameworkService(); + } + + @Bean + AccountCommandService accountCommandService() { + return new AccountCommandService() { + @Override + public String buildRequestHash(Object payload) { + throw new AssertionError("Account Security Events read 不允许调用 AccountCommandService.buildRequestHash"); + } + + @Override + public AccountCommandDO getReplayCommand(CommandEnvelope envelope) { + throw new AssertionError("Account Security Events read 不允许调用 AccountCommandService.getReplayCommand"); + } + + @Override + public AccountCommandDO reserveCommand(CommandEnvelope envelope) { + throw new AssertionError("Account Security Events read 不允许调用 AccountCommandService.reserveCommand"); + } + + @Override + public AccountCommandDO recordSucceeded(CommandEnvelope envelope, String resultSnapshot) { + throw new AssertionError("Account Security Events read 不允许调用 AccountCommandService.recordSucceeded"); + } + }; + } + + @Bean + AccountAuditService accountAuditService() { + return req -> { + throw new AssertionError("Account Security Events read 不允许调用 AccountAuditService.record"); + }; + } + + @Bean + TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) { + TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor( + new TenantDatabaseInterceptor(new TenantProperties())); + MyBatisUtils.addInterceptor(interceptor, inner, 0); + return inner; + } + + @Bean + ApiErrorLogCommonApi apiErrorLogCommonApi() { + return new ApiErrorLogCommonApi() { + @Override + public CommonResult createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) { + return CommonResult.success(true); + } + }; + } + } +} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java index 0258ae48..057407b8 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAiRealApiGateTest.java @@ -145,8 +145,8 @@ class P1rAiRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(145, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); + assertEquals(147, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 12 + Market 4 + Content 14"); } @Test @@ -174,7 +174,7 @@ class P1rAiRealApiGateTest { void should_keep_content_meta_account_dedicated_statuses() throws IOException { assertContentStatusCount(14, 37); assertMetaStatusCount(16, 0); - assertAccountStatusCount(10, 23); + assertAccountStatusCount(12, 21); assertMarketStatusCount(4, 28); } @@ -225,8 +225,8 @@ class P1rAiRealApiGateTest { } } assertEquals(33, total, "Account operation 数量必须保持 33"); - assertEquals(expectedCompleted, completed, "Account 第一批 completed approval 后必须有 10 个 operation completed"); - assertEquals(expectedNeedsVerification, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); + assertEquals(expectedCompleted, completed, "Account Security Events read completed approval 后必须有 12 个 operation completed"); + assertEquals(expectedNeedsVerification, needsVerification, "Account 剩余 21 个 operation 必须继续 needs_verification"); } private void assertMetaStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java index 9e5ddca9..42975820 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rApiCoverageReportTest.java @@ -81,6 +81,8 @@ class P1rApiCoverageReportTest { "account:getAppBalanceSnapshots", "account:adminCreateQuotaAdjustment", "account:adminListQuotaAdjustments", + "account:appListSecurityEvents", + "account:appGetSecurityEvent", "market:getMarketplaceAsset", "market:listMarketplaceCategories", "market:favoriteAsset", @@ -125,7 +127,7 @@ class P1rApiCoverageReportTest { "activateFunctionChainVersion" ); - /** 已批准的 Account 第一批本域闭合接口,仍保持 operation-level approval。 */ + /** 已批准的 Account 本域闭合接口,仍保持 operation-level approval。 */ private static final Set APPROVED_ACCOUNT_COMPLETED_OPERATIONS = Set.of( "getCurrentUser", "getProfile", @@ -136,7 +138,9 @@ class P1rApiCoverageReportTest { "adminGetBalanceSnapshots", "getAppBalanceSnapshots", "adminCreateQuotaAdjustment", - "adminListQuotaAdjustments" + "adminListQuotaAdjustments", + "appListSecurityEvents", + "appGetSecurityEvent" ); /** 已批准的 Market 第一批详情、分类和收藏接口,仍保持 operation-level approval。 */ @@ -280,7 +284,7 @@ class P1rApiCoverageReportTest { completed++; } } - assertEquals(145, completed, "Content Admin RiskAction operation-level approval 后 completed 总数只能从 144 增至 145"); + assertEquals(147, completed, "Account Security Events read completed approval 后 completed 总数只能从 145 增至 147"); assertOperationStatus("events", "streamEvents", "dedicated", "completed"); for (String operationId : APPROVED_META_SCHEMA_COMPLETED_OPERATIONS) { @@ -302,6 +306,9 @@ class P1rApiCoverageReportTest { assertOperationStatus("market", "listMarketplaceRecommendations", "dedicated", "needs_verification"); assertOperationStatus("market", "favoriteAsset", "dedicated", "completed", true); assertOperationStatus("market", "unfavoriteAsset", "dedicated", "completed", true); + assertOperationStatus("account", "appListSecurityEvents", "dedicated", "completed"); + assertOperationStatus("account", "appGetSecurityEvent", "dedicated", "completed"); + assertOperationStatus("account", "appAcknowledgeSecurityEvent", "dedicated", "needs_verification", true); assertOperationStatus("content", "createWork", "dedicated", "needs_verification"); assertOperationStatus("content", "updateWork", "dedicated", "needs_verification"); assertOperationStatus("content", "createChapter", "dedicated", "needs_verification"); @@ -311,7 +318,7 @@ class P1rApiCoverageReportTest { assertOperationStatus("content", "createStyleCheck", "dedicated", "needs_verification"); assertOperationStatus("content", "adminRiskAction", "dedicated", "completed", true); assertDomainStatusCount("market", "dedicated", "needs_verification", 28); - assertDomainStatusCount("account", "dedicated", "needs_verification", 23); + assertDomainStatusCount("account", "dedicated", "needs_verification", 21); assertDomainStatusCount("content", "dedicated", "needs_verification", 37); } diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java index 430d1625..941cc316 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rEventsRealApiGateTest.java @@ -27,7 +27,7 @@ class P1rEventsRealApiGateTest { /** P1R-6 Market 合同固定 operation 数量,P1R-7a 不能顺手改变 Market 覆盖状态。 */ private static final int EXPECTED_MARKET_OPERATION_COUNT = 32; - /** P1R-3 Account 合同固定 operation 数量,Account completed approval 只能推进第一批 10 个。 */ + /** P1R-3 Account 合同固定 operation 数量,Account completed approval 当前只允许推进 12 个。 */ private static final int EXPECTED_ACCOUNT_OPERATION_COUNT = 33; /** Events 真实入口门禁必须拒绝的占位或缺失状态。 */ @@ -106,10 +106,10 @@ class P1rEventsRealApiGateTest { void should_keep_completed_approval_summary_at_approved_operation_boundary() throws IOException { JsonNode summary = readReport().path("summary"); - assertEquals(145, summary.path("completedOperations").asInt(), - "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); - assertEquals(88, summary.path("needsVerificationOperations").asInt(), - "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 10、Market 4 和 Content 14"); + assertEquals(147, summary.path("completedOperations").asInt(), + "completedOperations 只能来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 12 + Market 4 + Content 14"); + assertEquals(86, summary.path("needsVerificationOperations").asInt(), + "needsVerificationOperations 必须扣除已批准的 Events streamEvents、Meta 16、Account 12、Market 4 和 Content 14"); assertEquals(0, summary.path("incompleteOperations").asInt(), "Events 退出 placeholder 后不应再留下 incomplete operation"); assertEquals(0, summary.path("genericPersistenceOperations").asInt(), @@ -150,7 +150,7 @@ class P1rEventsRealApiGateTest { } @Test - void should_keep_account_partial_approval_at_10_completed_operations() throws IOException { + void should_keep_account_partial_approval_at_12_completed_operations() throws IOException { JsonNode accountOperations = readAccountOperations(); int completed = 0; int needsVerification = 0; @@ -168,8 +168,8 @@ class P1rEventsRealApiGateTest { needsVerification++; } } - assertEquals(10, completed, "Account 第一批 completed approval 只能推进 10 个 operation"); - assertEquals(23, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); + assertEquals(12, completed, "Account Security Events read completed approval 后只能推进 12 个 operation"); + assertEquals(21, needsVerification, "Account 剩余 21 个 operation 必须继续 needs_verification"); } @Test diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java index cd9eaded..434bd1eb 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRealApiGateTest.java @@ -184,8 +184,8 @@ class P1rKnowledgeRealApiGateTest { void should_count_ai_and_knowledge_operations_as_completed() throws IOException { JsonNode report = readReport(); - assertEquals(145, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); + assertEquals(147, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 12 + Market 4 + Content 14"); } @Test @@ -193,7 +193,7 @@ class P1rKnowledgeRealApiGateTest { assertDomainStatusCount("ai", 41, "completed"); assertContentStatusCount(14, 37); assertMetaStatusCount(16, 0); - assertAccountStatusCount(10, 23); + assertAccountStatusCount(12, 21); assertMarketStatusCount(4, 28); } @@ -285,8 +285,8 @@ class P1rKnowledgeRealApiGateTest { } } assertEquals(33, total, "Account operation 数量必须保持 33"); - assertEquals(expectedCompleted, completed, "Account 第一批 completed approval 后必须有 10 个 operation completed"); - assertEquals(expectedNeedsVerification, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); + assertEquals(expectedCompleted, completed, "Account Security Events read completed approval 后必须有 12 个 operation completed"); + assertEquals(expectedNeedsVerification, needsVerification, "Account 剩余 21 个 operation 必须继续 needs_verification"); } private void assertMarketStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java index 4b5d5f0b..ba12120f 100644 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java +++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java @@ -151,8 +151,8 @@ class P1rMarketRealApiGateTest { void should_keep_completion_summary_owned_by_approved_scopes_only() throws IOException { JsonNode report = readReport(); - assertEquals(145, report.path("summary").path("completedOperations").asInt(), - "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 10 + Market 4 + Content 14"); + assertEquals(147, report.path("summary").path("completedOperations").asInt(), + "completedOperations 必须只来自 AI 41 + Knowledge 59 + Events streamEvents 1 + Meta 16 + Account 12 + Market 4 + Content 14"); } @Test @@ -161,7 +161,7 @@ class P1rMarketRealApiGateTest { assertDomainStatusCount("knowledge", 59, "completed"); assertContentStatusCount(14, 37); assertMetaStatusCount(16, 0); - assertAccountStatusCount(10, 23); + assertAccountStatusCount(12, 21); } private void assertContentStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { @@ -247,8 +247,8 @@ class P1rMarketRealApiGateTest { } } assertEquals(33, total, "Account operation 数量必须保持 33"); - assertEquals(expectedCompleted, completed, "Account 第一批 completed approval 后必须有 10 个 operation completed"); - assertEquals(expectedNeedsVerification, needsVerification, "Account 剩余 23 个 operation 必须继续 needs_verification"); + assertEquals(expectedCompleted, completed, "Account Security Events read completed approval 后必须有 12 个 operation completed"); + assertEquals(expectedNeedsVerification, needsVerification, "Account 剩余 21 个 operation 必须继续 needs_verification"); } private void assertMetaStatusCount(int expectedCompleted, int expectedNeedsVerification) throws IOException { diff --git a/muse-cloud/scripts/p1r-audit-api-coverage.py b/muse-cloud/scripts/p1r-audit-api-coverage.py index 0fc7aee9..fddb0f20 100755 --- a/muse-cloud/scripts/p1r-audit-api-coverage.py +++ b/muse-cloud/scripts/p1r-audit-api-coverage.py @@ -74,6 +74,9 @@ APPROVED_COMPLETED_OPERATIONS = { "account:getAppBalanceSnapshots", "account:adminCreateQuotaAdjustment", "account:adminListQuotaAdjustments", + # P1R Account Security Events read completed approval 只推进两个只读接口。 + "account:appListSecurityEvents", + "account:appGetSecurityEvent", # P1R Market completed approval 第一批仍使用 operation-level 清单,避免整域 Market 被误升 completed。 "market:getMarketplaceAsset", "market:listMarketplaceCategories",