diff --git a/docs/Phase-V12-Permission-Scope-Completion-Report.md b/docs/Phase-V12-Permission-Scope-Completion-Report.md new file mode 100644 index 0000000..e70ca5d --- /dev/null +++ b/docs/Phase-V12-Permission-Scope-Completion-Report.md @@ -0,0 +1,42 @@ +# Phase V1.2 Task 6 权限范围看板收口报告 + +## 结论 + +**PARTIAL / NO-GO(当前阶段)**。 + +本次仅完成当前检出代码的 contract 盘点和可重复验证。真实 PostgreSQL、Redis、Flyway 最新迁移、角色登录、跨组织拒绝及六项卡片与详情数量一致性没有在本次运行中得到活栈证据,因此不能宣称 V1.2 阶段通过。 + +## 当前证据 + +| 项目 | 状态 | 证据 | +|---|---|---| +| Task 1 权限矩阵 | PARTIAL | `docs/v12/V1.2-角色权限与前端入口矩阵.md` 已存在;未在本次连接干净 PostgreSQL 验证 | +| Task 2 工作台聚合/范围 | PARTIAL | 静态 contract 通过;未验证真实角色登录、SELF/TEAM/ALL 数据隔离 | +| Task 3 六项详情范围 | **NOT_IMPLEMENTED** | `Phase-V12-Task3-Detail-Scope-Completion-Report.md` 明确没有统一详情 mapper/路由 | +| Task 4 管理端卡片/入口 | PASS(静态) | `tests/e2e/v12_permission_scope_dashboard_test.py` contract 盘点通过;浏览器验收未执行 | +| Task 5 运营角色最小权限 | PARTIAL | V073 合同测试历史结果存在;本次未运行干净数据库迁移和真实登录 | +| Task 6 活栈 E2E | NOT_VERIFIED | 当前未提供可达 PostgreSQL/Redis/后端活栈;未伪造通过结果 | + +## 已执行 + +```text +E2E_CONTRACT_ONLY=1 python -m pytest tests/e2e/v12_permission_scope_dashboard_test.py -q +``` + +该模式只运行源码/API contract 盘点,并明确绕过活栈探活;它不是业务 E2E 证据。 + +## Blockers + +1. **Task 3 详情范围缺口(P0)**:审核、咨询、预约、活动、统计等详情接口尚未统一到同一 scope builder,无法证明卡片数字与详情列表一致,也无法完成请求参数越权拒绝验收。 +2. **缺少当前活栈证据(P0)**:未验证 V001 至最新迁移、角色登录、真实 403/404、跨组织拒绝、空数据与查询失败状态。 +3. **前端浏览器验收未执行(P1)**:静态契约和构建不能替代菜单、卡片点击和错误状态的实际交互。 + +## 发布判断 + +在 Task 3 详情范围统一、干净 PostgreSQL/Redis 活栈和真实角色矩阵 E2E 通过前,阶段结论保持 **NO-GO**。完成上述证据后再重新评估是否可转为 `CONDITIONAL GO` 或 `GO`。 + +## 未做与风险 + +- 未修改业务代码、数据库迁移、权限矩阵和前端业务逻辑。 +- 未执行 Maven 全量、admin type-check/build、miniapp type-check 或活栈 E2E;Task 1–5 报告中的历史结果不作为本次当前证据。 +- contract-only 模式只验证源码结构和已记录缺口;接口运行时行为、数据库查询范围和权限配置仍需活栈验证。 diff --git a/tests/e2e/v12_permission_scope_dashboard_test.py b/tests/e2e/v12_permission_scope_dashboard_test.py new file mode 100644 index 0000000..a2d7f70 --- /dev/null +++ b/tests/e2e/v12_permission_scope_dashboard_test.py @@ -0,0 +1,118 @@ +"""V1.2 Task 6 权限/范围看板收口盘点。 + +默认在无活栈时运行静态/API contract 盘点,避免把不可达的真实 E2E 伪装成通过。 +设置 ``E2E_CONTRACT_ONLY=1`` 后运行本文件;设置 ``E2E_BASE_URL`` 和 +``E2E_OPERATOR_PASSWORD`` 并移除该变量,再运行同一文件才是活栈验收。 +""" +from __future__ import annotations + +import re +import os +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BACKEND = ROOT / "backend" / "src" / "main" / "java" +ADMIN = ROOT / "admin-web" / "src" +WORKBENCH = BACKEND / "com/danyang/match/platform/matchmaker/dashboard" + +pytestmark = pytest.mark.e2e + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_workbench_exposes_fixed_six_metrics_and_server_scope() -> None: + service = _read(WORKBENCH / "MatchmakerWorkbenchService.java") + controller = _read(WORKBENCH / "MatchmakerWorkbenchController.java") + for metric in ( + "pendingReviews", + "matchmakerTodos", + "counselingTodos", + "serviceStationAppointments", + "effectiveMatches", + "activityRegistrations", + ): + assert f'metrics.put("{metric}"' in service + assert '@RequestMapping("/operator/workbench")' in controller + assert '@GetMapping("/dashboard")' in controller + assert "operatorId" in service and "organizationId" in service + assert "@RequestParam" not in controller + + +def test_dashboard_does_not_accept_client_scope_overrides() -> None: + controller = _read(WORKBENCH / "MatchmakerWorkbenchController.java") + assert not re.search(r"(userId|matchmakerId|teamId|scope)\s*=", controller) + scope = _read(WORKBENCH / "MatchmakerDashboardScope.java") + assert '"ALL".equals(type)' in scope + assert '"TEAM".equals(type)' in scope + + +def test_admin_cards_have_stable_contract_keys_and_capability_status() -> None: + dashboard = _read(ADMIN / "services/dashboard.ts") + for key in ( + "pending_reviews", + "matchmaker_todos", + "counseling_todos", + "station_appointments", + "effective_matches", + "activity_registrations", + ): + assert f"key: '{key}'" in dashboard + assert "metricStatus(" in dashboard + assert "metricRoute(" in dashboard + assert "available" in dashboard + + +def test_task3_detail_scope_gap_is_detected_and_reported() -> None: + """盘点当前缺口;详情统一 scope 不能被静态检查误报为已完成。""" + java_sources = "\n".join( + p.read_text(encoding="utf-8") + for p in BACKEND.rglob("*.java") + ) + assert "class MatchmakerDashboardMapper" not in java_sources + assert "@RequestMapping(\"/operator/workbench/details\")" not in java_sources + + +def test_task3_gap_is_a_release_blocker() -> None: + report = ROOT / "docs" / "Phase-V12-Task3-Detail-Scope-Completion-Report.md" + text = _read(report) + assert "NOT_IMPLEMENTED" in text + assert "六类详情接口尚未统一" in text + + +@pytest.mark.skipif(os.environ.get("E2E_CONTRACT_ONLY") == "1", reason="contract-only: no live HTTP") +def test_user_cannot_access_operator_workbench(api, user_token) -> None: + response = api.call("GET", "/operator/workbench/dashboard", token=user_token) + assert response.status_code == 403, response.text[:400] + + +@pytest.mark.skipif(os.environ.get("E2E_CONTRACT_ONLY") == "1", reason="contract-only: no live HTTP") +def test_seeded_admin_dashboard_returns_six_metrics(api, admin_token) -> None: + data = api.ok("GET", "/operator/workbench/dashboard", token=admin_token) + assert data["scope"] == "ALL" + metrics = data["metrics"] + assert set(metrics) >= { + "pendingReviews", + "matchmakerTodos", + "counselingTodos", + "serviceStationAppointments", + "effectiveMatches", + "activityRegistrations", + } + for item in metrics.values(): + assert "available" in item + assert "detailPermission" in item + + +@pytest.mark.skipif(os.environ.get("E2E_CONTRACT_ONLY") == "1", reason="contract-only: no live HTTP") +def test_matchmaker_dashboard_scope_is_server_derived(api, operator_token) -> None: + data = api.ok( + "GET", + "/operator/workbench/dashboard", + token=operator_token("e2e_matchmaker"), + params={"operatorId": "not-the-token-owner", "teamId": "other-team"}, + ) + assert data["scope"] in {"SELF", "TEAM"}