From 730dddde3066f4b9eda4d77b0858297a215e5efb Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Fri, 18 Sep 2026 13:50:19 +0800 Subject: [PATCH] =?UTF-8?q?test(e2e):=20Phase=2023B=20=E2=80=94=20rebuild?= =?UTF-8?q?=20E2E=20infrastructure;=20fix=206=20real=20prod=20defects=20it?= =?UTF-8?q?=20exposed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 23 closed as PARTIAL: fake coverage was gone, but the E2E layer itself was still empty — no conftest.py anywhere, ~15 fixtures that never existed, 6 files crashing at import on os.environ["E2E_PASSWORD"], and CI never ran E2E. STATUS's "10 E2E files / ~80+ cases ✅" described files that had never executed. Infrastructure delivered: - tests/e2e/conftest.py: ApiClient (trust_env=False — a host http_proxy turns localhost calls into 502), operator_token(username) / new_user / admin_token fixtures, and a session probe that calls pytest.exit(4) when the stack is unreachable. NOT skip: skip would launder "did not run" into "passed". - pytest.ini, tests/e2e/requirements.txt, and tests/e2e/sql/ seed_e2e_operators.sql — migrations contain NO operator seed at all, so a fresh database has zero logins; the seed creates 6 e2e accounts with role bindings, idempotent, password injected via `psql -v pwd`. - CI job `e2e`: compose up -> poll /health/live (timeout = fail) -> seed -> pytest -> always teardown. Gate D-23-D is now closed. - 8 E2E files migrated off import-time env reads; tautological assertions (`x == t or not x`), a counselor token standing in for content_editor, and a bare `status_code == 200` claiming to prove INV-020 were replaced with real permission/behavior assertions. Six real production defects found only by running against real PostgreSQL (H2 has zero coverage for all of them): - D-23B-A (Critical): stale V042__add_appointment_operator_register_permission.sql left in target/classes; src had replaced it with V042__add_service_location_ announcements.sql. Packing without clean put two V042 in the jar -> Flyway "Found more than one migration with version 042" -> app would not start. Guard added: testBuildOutputHasNoDuplicateMigrationVersions scans build output (src is clean, so only this catches it). - D-23B-B (High): non-UUID path variable reached the DB -> 500 + leaked `invalid input syntax for type uuid`. Now rejected at the controller. - D-23B-C (High): 6 E2E files raised KeyError at import -> never collected. - D-23B-E (Critical): permissions = 48, role_permissions for super_admin = 18, matchmaker = 0. V015's "grant all to super_admin" was a point-in-time snapshot; the 30 permissions added later were never granted. On a fresh stack an admin cannot create a service station and matchmakers can do nothing. V044 restores super_admin to its documented definition (not an expansion); the matchmaker permission set needs a product decision and is left as a residual. - D-23B-I (Medium): IllegalArgumentException (e.g. invalid activity type) was unmapped -> 500. Now BAD_REQUEST. - D-23B-J (High): `operator does not exist: uuid = character varying` — Java ids are String, columns are uuid, PostgreSQL does not implicitly cast in comparisons. Fixed at the source with JDBC `stringtype=unspecified` (docker profile); production datasource comes from deploy env and needs the same. One judgement retracted: an initial read suggested service_appointment.register was missing. It is not — the endpoint uses service.assisted_registration, seeded in V029, and the code deliberately avoids a duplicate permission. Nothing was changed on that basis. Verification: 33 E2E passed against a live stack; unreachable stack and a missing operator password both exit 4 (never skip/pass); backend regression 656 / 0 failures / 0 errors / 3 skipped (baseline 655, +1 guard, negatively validated by injecting a duplicate V042 -> exactly one test turned red). --- .github/workflows/ci.yml | 64 +++++++ .../controller/CounselingController.java | 11 ++ .../exception/GlobalExceptionHandler.java | 12 ++ .../src/main/resources/application-docker.yml | 5 +- ...__grant_all_permissions_to_super_admin.sql | 20 ++ .../deployment/MigrationValidationTest.java | 34 +++- docs/JIRA_BACKLOG.md | 3 +- ...astructure-Completion-Report-2026-09-18.md | 126 +++++++++++++ docs/STATUS.md | 30 +++ pytest.ini | 14 ++ tests/e2e/conftest.py | 172 +++++++++++++++++ tests/e2e/content_e2e_test.py | 90 +++------ tests/e2e/counseling_permission_test.py | 110 +++++++---- tests/e2e/counseling_v11_test.py | 144 ++++----------- tests/e2e/home_v11_test.py | 87 ++++----- tests/e2e/local_eligibility_test.py | 87 +++++---- tests/e2e/multi_type_activity_test.py | 68 ++++--- tests/e2e/organization_isolation_test.py | 47 +++-- tests/e2e/referral_v11_test.py | 173 ++++-------------- tests/e2e/requirements.txt | 2 + tests/e2e/service_station_permission_test.py | 57 +++--- tests/e2e/sql/seed_e2e_operators.sql | 41 +++++ 22 files changed, 891 insertions(+), 506 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V044__grant_all_permissions_to_super_admin.sql create mode 100644 docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md create mode 100644 pytest.ini create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/requirements.txt create mode 100644 tests/e2e/sql/seed_e2e_operators.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dc86fe..5669d51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,3 +142,67 @@ jobs: # 机械阻断空壳回归 —— 详见 scripts/check-no-empty-tests.py。 - name: 禁止 skip/pass 空壳测试 run: python3 scripts/check-no-empty-tests.py tests + + e2e: + name: E2E (live stack) + runs-on: ubuntu-latest + # Phase 23 遗留 D-23-D:计划要求 CI 串联 E2E。此前 E2E 从未真正执行过—— + # 无 conftest、fixture 不存在、文件在 import 期就崩。本 job 起真实栈后执行。 + needs: [backend, migration] + env: + E2E_BASE_URL: http://localhost:8085/api/v1 + # CI 专用测试口令,仅存在于 runner 运行期,不入库、不用于任何真实环境 + E2E_OPERATOR_PASSWORD: ci-e2e-only-password + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install E2E dependencies + run: pip install -r tests/e2e/requirements.txt + + - name: 生成 CI 测试用 .env(仅运行期存在,不入库) + run: | + cat > infrastructure/.env <<'ENV' + DB_PASSWORD=ci-e2e-db-password + REDIS_PASSWORD=ci-e2e-redis-password + MINIO_ACCESS_KEY=ci-e2e-minio + MINIO_SECRET_KEY=ci-e2e-minio-secret + JWT_ACCESS_SECRET=ci-e2e-access-secret-only-for-ci + JWT_REFRESH_SECRET=ci-e2e-refresh-secret-only-for-ci + ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + WECHAT_APP_SECRET= + ENV + + - name: 起真实栈(postgres + redis + backend) + working-directory: infrastructure + run: docker compose up -d --build postgres redis backend + + - name: 等待 backend 就绪(超时即失败,不静默跳过) + run: | + for i in $(seq 1 60); do + if curl -sf "$E2E_BASE_URL/health/live" > /dev/null; then + echo "backend READY after ${i} checks"; exit 0 + fi + sleep 5 + done + echo "::error::backend 未在 5 分钟内就绪" + docker compose -f infrastructure/docker-compose.yml logs backend + exit 1 + + - name: Seed E2E 运营账号(迁移不含 operator 种子,全新库无账号可登录) + working-directory: infrastructure + run: | + docker compose exec -T postgres psql -U postgres -d danyang_match \ + -v pwd="$E2E_OPERATOR_PASSWORD" -f - < ../tests/e2e/sql/seed_e2e_operators.sql + + - name: Run E2E + run: pytest -v + + - name: Teardown(无论成败都清理并留日志) + if: always() + run: | + docker compose -f infrastructure/docker-compose.yml logs backend || true + docker compose -f infrastructure/docker-compose.yml down -v || true diff --git a/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java b/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java index 6ea3b65..32a1d9b 100644 --- a/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java +++ b/backend/src/main/java/com/danyang/match/platform/counseling/controller/CounselingController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.*; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; /** * 婚恋咨询 / 婚前辅导 端点(Phase 13A) @@ -49,6 +50,10 @@ import java.util.Map; @RequiredArgsConstructor public class CounselingController { + /** 主键为 uuid 列,非法字符串必须先挡在 Controller 层,避免打到数据库变成 500。 */ + private static final Pattern UUID_PATTERN = Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + private final CounselingService counselingService; // ==================== 用户端 ==================== @@ -91,6 +96,12 @@ public class CounselingController { public ApiResponse detail(@PathVariable String id, @AuthenticationPrincipal UserPrincipal principal) { if (principal == null) return ApiResponse.unauthorized(); + // 非法 UUID 直接 400。否则参数会打到数据库,PostgreSQL 抛 + // `invalid input syntax for type uuid` → 500 且泄漏数据库异常。 + if (!UUID_PATTERN.matcher(id).matches()) { + throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException( + com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "非法的 ID 格式"); + } CounselingServiceRecord svc = counselingService.getDetail(principal.id(), principal.role(), id); if (svc == null) return ApiResponse.forbidden(); return ApiResponse.ok(svc); diff --git a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java index 3aabe53..29762bb 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/exception/GlobalExceptionHandler.java @@ -111,6 +111,18 @@ public class GlobalExceptionHandler { return ApiResponse.error(e.getCode(), e.getMessage()); } + /** + * 参数非法(例:活动类型不在 {@code chk_activity_type} 白名单内)必须回 400。 + * + *

此前未映射 {@link IllegalArgumentException},这类"客户端传错值"的问题 + * 会被兜底成 500:调用方无法区分是自己的参数错还是服务端故障, + * 真实 PostgreSQL 上还会伴随数据库异常语义外泄(H2 测试库则不报错 → 零覆盖)。 + */ + @ExceptionHandler(IllegalArgumentException.class) + public ApiResponse handleIllegalArgument(IllegalArgumentException e) { + return ApiResponse.error(ApiResponseCode.BAD_REQUEST, e.getMessage()); + } + @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ApiResponse handleAccessDenied(AccessDeniedException e) { diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 1f9e54a..4da8ca3 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -1,6 +1,9 @@ spring: datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:danyang_match} + # stringtype=unspecified:Java 侧实体主键/外键统一用 String,而 PostgreSQL 列是 uuid。 + # 不加该参数时 pgjdbc 以 varchar 发送,PostgreSQL 在比较操作上不做隐式转换, + # 会抛 `operator does not exist: uuid = character varying`(H2 测试库不报错 → 零覆盖)。 + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:danyang_match}?stringtype=unspecified username: ${DB_USER:postgres} password: ${DB_PASSWORD} diff --git a/backend/src/main/resources/db/migration/V044__grant_all_permissions_to_super_admin.sql b/backend/src/main/resources/db/migration/V044__grant_all_permissions_to_super_admin.sql new file mode 100644 index 0000000..c35d9b7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V044__grant_all_permissions_to_super_admin.sql @@ -0,0 +1,20 @@ +-- ============================================================ +-- V044: 补齐 super_admin 权限(实现与定义对齐,非扩权) +-- +-- 发现方式:Phase 24 搭建 E2E 基建、用真实 PostgreSQL 活栈执行时发现 +-- - permissions 共 48 条,role_permissions 里 super_admin 只有 18 条 +-- - matchmaker 角色为 0 条(见报告 D-24-E 遗留项) +-- +-- 根因:V015 的「为 super_admin 分配全部权限」是**执行时点快照**, +-- V016 之后新增的 30 条权限从未回补给 super_admin。 +-- 后果:全新环境起栈后,超级管理员创建服务站、查看咨询等操作全部 403。 +-- +-- super_admin 在 V015 中的定义即「系统超级管理员,拥有全部权限」, +-- 本迁移只是让实现回到定义;不新增权限、不授予其它角色。 +-- 幂等:ON CONFLICT DO NOTHING。 +-- ============================================================ + +INSERT INTO role_permissions (role_id, permission_id) +SELECT '00000000-0000-0000-0000-000000000001', id +FROM permissions +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 8f75c95..a8f94fe 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -18,7 +18,8 @@ class MigrationValidationTest { // 基线守卫:新增 Flyway 迁移时必须同步递增此值 // Phase 19:V042 善缘服务站「临时公告」表(service_location_announcements) // Phase 20:V043 打通 analytics.view 授权(data_viewer 专设却零权限 + platform_admin 被老权限段漏掉) - private static final int LATEST_MIGRATION = 43; + // Phase 24:V044 补齐 super_admin 权限(V015 之后新增的 30 条权限从未回补给 super_admin) + private static final int LATEST_MIGRATION = 44; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; @@ -151,6 +152,37 @@ class MigrationValidationTest { assertTrue(v014.contains("user_privacy_settings"), "V014 应创建 privacy settings 表"); } + /** + * 源码目录版本号唯一 ≠ 打包出来的 jar 版本号唯一。 + * + *

未 clean 时 {@code target/classes} 会残留历史文件(例如被改名/删除的旧 + * {@code V042__*.sql}),打包进 jar 后 Flyway 直接抛 + * {@code Found more than one migration with version 042} —— **应用起不来**。 + * 该问题在源码层扫不出来(src 是干净的),只有扫构建产物才能发现。 + */ + @Test + void testBuildOutputHasNoDuplicateMigrationVersions() throws IOException { + Path dir = Paths.get("backend/target/classes/db/migration"); + assertTrue(Files.exists(dir), "构建产物目录不存在,无法校验:" + dir.toAbsolutePath()); + + Map> byVersion = new HashMap<>(); + Pattern versionPattern = Pattern.compile("^V(\\d+)__"); + try (DirectoryStream stream = Files.newDirectoryStream(dir, "V*.sql")) { + for (Path p : stream) { + Matcher m = versionPattern.matcher(p.getFileName().toString()); + if (m.find()) { + byVersion.computeIfAbsent(Integer.valueOf(m.group(1)), k -> new ArrayList<>()) + .add(p.getFileName().toString()); + } + } + } + assertFalse(byVersion.isEmpty(), "构建产物中没有任何迁移文件,校验无意义"); + + byVersion.forEach((version, names) -> assertEquals(1, names.size(), + "构建产物中版本号 " + version + " 重复 " + names + + " —— 多为未 clean 的陈旧文件残留,会导致 Flyway 启动失败;请执行 mvn clean")); + } + @Test void testNoBackwardIncompatibleChanges() throws IOException { // 验证没有破坏性变更(如 DROP COLUMN) diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 40fd870..52e4a56 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -180,7 +180,8 @@ | Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 已复验关闭(2026-09-18,650 tests 全绿)** | | Phase 21 账户生命周期增量复验 | 已完成(2026-09-18) | 见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **6 处真实缺陷 + 假绿测试**:① `AuthService.checkAccountStatus` 不拒 `CLOSED` → 注销后仍可登录(Gate ❌→✅);② `notifyActiveMatches` 为 TODO 桩 → 注销未关闭活跃 Match/会话(Gate ❌→✅,已实现关闭 Match + `chatService.closeConversationForMatch`);③ `cancelPendingActivityRegistrations` 为 TODO 桩 → 未来活动报名未关闭(Gate ❌→✅);④ `anonymizeServiceHistory` 已实现却**从未被 `closeAccount` 调用(死代码)** → 个人/咨询正文暴露(Gate ❌→✅,已接线 + 脱敏身份证占位符 + `counseling_followups` 正文);⑤ `closeAccount` 在 blockers 处硬抛异常使 `cancelXxx` 永远不可达(死代码矛盾 → 信息性日志 + 继续清理);⑥ 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫;跨模块防线(推荐引擎/匹配资格/Capability 已拒非 NORMAL)确认有效;后端 **650 tests 全绿**(起点 648,净 +2);**Phase 22 已复验关闭(2026-09-18,657 tests 全绿)** | | Phase 22 部署运维配置扩展(Feature Flag)复验 | 已完成(2026-09-18) | 见 `docs/Phase-22-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **2 处真实代码缺陷 + 2 处文档夸大**:① D-22-A(High)`FeatureFlagController.updateFlag` 仅支持布尔 flag(反射 `getMethod("set"+cap, boolean.class)`)→ 7 个非布尔 flag 经 API 更新必失败,已改为按字段类型分派 boolean/int/String;② D-22-B(Medium)`body.getOrDefault("value", false)` 缺 value 静默置 false + 非布尔转型崩,已改为缺 value/类型错→400 且不改原值;T01–T05 模块开关(`isCounselingEnabled` 等)全仓无业务调用点 → 死配置(暴露未 gate,遗留交各模块 Phase);T06 上传策略实测已在 `LocalEligibilityService`+`PhotoService` 真强制 ✅;T07 私有桶零引用 → 死配置(遗留);yml 无 `features:` 块、默认值实为 Java 字段默认(文档纠正);新增 `FeatureFlagControllerTest`(7 用例)+ 负向验证(仅布尔缺陷版恰 2 用例变红);后端 **657 tests 全绿**(起点 650,净 +7);**Phase 23 已复验(2026-09-18,部分关闭)** | -| Phase 23 可信系统测试重建(REFACTOR)复验 | ⚠️ 部分关闭(2026-09-18) | 见 `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **5 处真实缺陷**:① **D-23-A(Critical)** `V11InvariantsTest` 原 14 个用例全为常量字符串断言 —— INV-016「Banner 目标类型」实际断言**文章状态**常量(跑错域)、INV-017「首页 8 入口」是**对自己刚声明的字面量数组做 `length==4` 的重言式**(从未调用首页服务)、INV-018/019/020 用预约/文章/账号状态常量冒充权限与数据范围验证;已重写为 12 个用例,含 **4 个真实服务行为守卫**(INV-013 走真实 `CounselingService.getDetail` 断言 Matchmaker 读到 `userRequestSummary==null` 并以 assigned Counselor 能读正文作反证;INV-016 反射读生产 `ALLOWED_TARGET_TYPES` 断言不含外链;INV-017 真实 `getHome` 断言 8 入口编码与顺序固定;INV-019 断言 `content_editor` 读到 null)+ `assertBehaviorTestExists` 用 `Class.forName` 把「行为验证见 XxxTest」变成可执行契约;② **D-23-B(High)** `tests/e2e/` **44 个 `pass` 空壳**(4 个文件 100% 为空壳,另 2 个混合),违反 Gate「不允许 skip/pass 空壳」→ 已移除并改为诚实占位(保留完整场景清单);③ **D-23-C(High)** pytest E2E 套件**整体不可执行**:全仓无 `conftest.py`,约 15 个 fixture(`api_client`/`verified_user`/`banner_factory`…)不存在,唯一可执行的 `run_v11_tests.py`(16 用例)需活栈 + `E2E_PASSWORD`;④ **D-23-D(High,遗留未修)** CI 未串联 E2E(计划明确要求)→ 需先决定 CI 活栈供给方式,未实施;⑤ **D-23-E(Medium)** CI `migration` job 注释称「验证 SQL 语法」实为只查文件非空(真实语法校验在 `MigrationValidationTest`,随 backend job 跑)→ 改为真实序列校验(命名/非空/版本号唯一/无缺号,43 个文件 1~43 连续唯一)。**新增闸门**:`scripts/check-no-empty-tests.py`(AST 扫描阻断 `pass` 空壳与无条件 `skip`,已双向验证)+ CI `test-shell-guard` job。**证据**:负向注入 3 处生产缺陷 → 恰好 `inv013`/`inv016`/`inv017` 3 个用例精准变红、其余 9 绿、零无关失败,`md5sum -c` 还原一致;`655 tests / 通过 652 / 失败 0 / 错误 0 / 跳过 3`(起点 657,净 −2 因 14 空转用例合并为 12;3 个跳过均有显式原因,非空壳)。**遗留**:E2E 入 CI 待基础设施决策、pytest fixture 体系缺失、INV-011 缺显式守卫。**结论:不宣布「失败为零可进入 UAT」**;**Phase 24 尚未获准** | +| Phase 23 可信系统测试重建(REFACTOR)复验 | ⚠️ 部分关闭(2026-09-18) | 见 `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **5 处真实缺陷**:① **D-23-A(Critical)** `V11InvariantsTest` 原 14 个用例全为常量字符串断言 —— INV-016「Banner 目标类型」实际断言**文章状态**常量(跑错域)、INV-017「首页 8 入口」是**对自己刚声明的字面量数组做 `length==4` 的重言式**(从未调用首页服务)、INV-018/019/020 用预约/文章/账号状态常量冒充权限与数据范围验证;已重写为 12 个用例,含 **4 个真实服务行为守卫**(INV-013 走真实 `CounselingService.getDetail` 断言 Matchmaker 读到 `userRequestSummary==null` 并以 assigned Counselor 能读正文作反证;INV-016 反射读生产 `ALLOWED_TARGET_TYPES` 断言不含外链;INV-017 真实 `getHome` 断言 8 入口编码与顺序固定;INV-019 断言 `content_editor` 读到 null)+ `assertBehaviorTestExists` 用 `Class.forName` 把「行为验证见 XxxTest」变成可执行契约;② **D-23-B(High)** `tests/e2e/` **44 个 `pass` 空壳**(4 个文件 100% 为空壳,另 2 个混合),违反 Gate「不允许 skip/pass 空壳」→ 已移除并改为诚实占位(保留完整场景清单);③ **D-23-C(High)** pytest E2E 套件**整体不可执行**:全仓无 `conftest.py`,约 15 个 fixture(`api_client`/`verified_user`/`banner_factory`…)不存在,唯一可执行的 `run_v11_tests.py`(16 用例)需活栈 + `E2E_PASSWORD`;④ **D-23-D(High,遗留未修)** CI 未串联 E2E(计划明确要求)→ 需先决定 CI 活栈供给方式,未实施;⑤ **D-23-E(Medium)** CI `migration` job 注释称「验证 SQL 语法」实为只查文件非空(真实语法校验在 `MigrationValidationTest`,随 backend job 跑)→ 改为真实序列校验(命名/非空/版本号唯一/无缺号,43 个文件 1~43 连续唯一)。**新增闸门**:`scripts/check-no-empty-tests.py`(AST 扫描阻断 `pass` 空壳与无条件 `skip`,已双向验证)+ CI `test-shell-guard` job。**证据**:负向注入 3 处生产缺陷 → 恰好 `inv013`/`inv016`/`inv017` 3 个用例精准变红、其余 9 绿、零无关失败,`md5sum -c` 还原一致;`655 tests / 通过 652 / 失败 0 / 错误 0 / 跳过 3`(起点 657,净 −2 因 14 空转用例合并为 12;3 个跳过均有显式原因,非空壳)。**遗留**:pytest fixture 体系缺失、INV-011 缺显式守卫(E2E 入 CI 与 fixture 体系已由 Phase 23B 补齐)。**结论:不宣布「失败为零可进入 UAT」**;**Phase 23B(E2E 测试基建)已于 2026-09-18 完成并关闭** | +| Phase 23B E2E 测试基建重建 | 已完成(2026-09-18) | 补齐 Phase 23 遗留 D-23-C(E2E 整体不可执行)与 D-23-D(CI 未串联 E2E)。**交付**:`tests/e2e/conftest.py`(ApiClient / `operator_token` / `new_user` / 栈探活)、`pytest.ini`、`tests/e2e/requirements.txt`、`tests/e2e/sql/seed_e2e_operators.sql`(迁移里**没有任何 operator 种子**,全新库无账号可登录 → 6 个 e2e 账号 + 角色绑定,幂等,口令由 `psql -v pwd` 注入)、CI job `e2e`(起真实栈→等就绪→seed→pytest→失败阻断→清理)、8 个 E2E 文件改造(消除 import 期 `KeyError`,替换恒真/名不副实断言)。**核心设计**:栈不可达时 `pytest.exit(4)` **而非 skip**(skip 会把未执行伪装成通过)。**验证**:本机真实活栈 **33 passed**;栈不可达/缺口令均 exit 4;后端 **656 tests / 0 failures / 0 errors / 3 skipped**(基线 655,+1 为新增构建产物守卫,已负向验证:注入重复 `V042` → 恰 1 用例变红)。**本轮暴露并修复 6 处真实生产缺陷**(H2 零覆盖):D-23B-A 构建产物残留重复迁移版本号 042 → 应用起不来;D-23B-B 非 UUID path → 500 + 数据库异常外泄;D-23B-C 6 个 E2E 文件 import 期崩溃 → 从未执行;D-23B-E `super_admin` 仅 18/48 权限、`matchmaker` 0 权限(matchmaker 部分列遗留,需产品定义);D-23B-I 非法枚举参数 → 500(应 BAD_REQUEST);D-23B-J `uuid = character varying` → JDBC 加 `stringtype=unspecified`。**另有一处判断被证伪后撤回**:初查以为 `service_appointment.register` 权限丢失,核实后端点实际用 `service.assisted_registration`(V029 已 seed),未据此改动任何代码。详见 `docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md` | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md b/docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md new file mode 100644 index 0000000..35031dd --- /dev/null +++ b/docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md @@ -0,0 +1,126 @@ +# Phase 23B — E2E 测试基建重建:复验完成报告 + +- 日期:2026-09-18 +- 范围:**补上 E2E 基建**(用户批准:Phase 23 暴露 E2E 层几乎为空后的决定) +- 结论:**E2E 层首次真实可执行**;过程中暴露并修复 6 处真实生产缺陷 +- 基线:`v1.0.0-RC.2` / V1.1 KEEP-VERIFY 纪律(历史 ✅ 不可信,必须由运行时证明) + +--- + +## 一、为什么有这一轮 + +Phase 23 的结论是「部分关闭」:假覆盖归零了,但 **E2E 这一环仍然是空的**—— + +- 全仓无 `conftest.py`,约 15 个 fixture(`api_client` / `verified_user` / `banner_factory` …)根本不存在; +- 多个文件在 **import 期**就 `os.environ["E2E_PASSWORD"]` → `KeyError` → 收集失败; +- CI 从不跑 E2E(D-23-D)。 + +即:STATUS 里「10 个 E2E 文件 ~80+ 用例 ✅」描述的,是一批**从未被执行过**的文件。 + +本轮的目标不是"补几个用例",而是把「能真实执行"这件事本身"建起来: +栈怎么起、账号从哪来、缺栈时怎样失败、CI 怎样阻断。 + +--- + +## 二、交付物 + +| 交付 | 文件 | 作用 | +|---|---|---| +| fixture 体系 | `tests/e2e/conftest.py` | `ApiClient`、`admin_token`、`operator_token(username)`、`new_user`、`live_stack` 探活 | +| pytest 配置 | `pytest.ini` | `testpaths=tests/e2e`、标记 `e2e`、说明为何不收集 `run_v11_tests.py` | +| 依赖清单 | `tests/e2e/requirements.txt` | pytest / requests | +| 运营账号自举 | `tests/e2e/sql/seed_e2e_operators.sql` | 6 个 e2e 账号 + 角色绑定,幂等,密码由 `psql -v pwd` 注入 | +| CI 串联 | `.github/workflows/ci.yml` → job `e2e` | 起真实栈 → 等就绪 → seed → pytest → 失败阻断 → 清理 | +| 8 个测试文件改造 | `tests/e2e/*_test.py` | 消除 import 期崩溃;把恒真/名不副实的断言换成真实断言 | + +### conftest 的三条硬设计(写进代码注释,防止回退) + +1. **栈不可达 = `pytest.exit(4)`,不是 `skip`。** + `skip` 会把"未执行"伪装成"通过"——那正是 Phase 23 清掉的 44 个 `pass` 空壳的另一种变体。 +2. **`session.trust_env = False`。** + 宿主机若设了 `http_proxy`,requests 会把 localhost 请求也发给代理并返回 502, + 表现为"连接失败"而不是真实业务结果(本机实测踩到)。 +3. **用户隔离。** mock 微信登录同 code 同 openid,换 code 即换人;每个用例用独立新用户。 + +--- + +## 三、本轮发现并修复的真实生产缺陷 + +> 这些不是"测试问题",是**起真栈才暴露**的生产缺陷。H2 测试库全部零覆盖。 + +| 编号 | 级别 | 真相 | 处置 | +|---|---|---|---| +| **D-23B-A** | Critical | `backend/target/classes/db/migration/` 残留旧 `V042__add_appointment_operator_register_permission.sql`(src 中已被 `V042__add_service_location_announcements.sql` 取代)。未 clean 打包 → jar 内两个 042 → Flyway 抛 `Found more than one migration with version 042` → **应用起不来** | 清理残留;新增守卫 `testBuildOutputHasNoDuplicateMigrationVersions`(扫构建产物,src 干净也扫得出) | +| **D-23B-B** | High | `GET /counseling/services/{id}` 传入非 UUID → PostgreSQL 抛 `invalid input syntax for type uuid` → **500 且泄漏数据库异常** | Controller 层 UUID 格式校验 → `BAD_REQUEST` | +| **D-23B-C** | High | 6 个 E2E 文件 import 期 `os.environ["E2E_PASSWORD"]` → `KeyError` → 收集失败,**从未执行** | 全部迁移到 conftest fixture | +| **D-23B-E** | Critical | 权限 seed 缺口:permissions 共 **48** 条,`super_admin` 只有 **18** 条,`matchmaker` **0** 条。V015 的"为 super_admin 分配全部权限"是**执行时点快照**,之后新增的 30 条从未回补 → 全新环境下管理员创建服务站 403、红娘全部功能 403 | 新增 `V044__grant_all_permissions_to_super_admin.sql`(对齐 super_admin 定义,非扩权)。**matchmaker 权限集需产品定义,列为遗留** | +| **D-23B-I** | Medium | `ActivityService` 对非法活动类型抛 `IllegalArgumentException`,全局异常处理器未映射 → **500**(客户端参数错被伪装成服务端故障) | `GlobalExceptionHandler` 增加 `IllegalArgumentException → BAD_REQUEST` | +| **D-23B-J** | High | `operator does not exist: uuid = character varying`:Java 实体主键/外键统一用 `String`,PostgreSQL 列是 `uuid`,比较操作不做隐式转换 → 多处查询 500(`/service-appointments/my` 等)。**H2 不报错 → 零覆盖** | docker profile JDBC URL 增加 `?stringtype=unspecified`(pgjdbc 官方解法,一处解决全局) | + +### 一处"判断被证伪",如实撤回 + +初查时发现 `V042__add_appointment_operator_register_permission.sql` 的内容 +(`service_appointment.register` 权限)不在 src 里,一度判定为"功能缺失"。 +进一步核实:端点实际用的是 `service.assisted_registration`(V029 已 seed 并授予 +服务站运营/平台管理员),Controller 注释明确写着"刻意收敛,避免权限膨胀"。 +→ **该判断不成立,已撤回**,未据此改动任何代码。 + +--- + +## 四、验证证据 + +### 1. E2E 真实执行(本机活栈:postgres + redis + 当前代码 jar @8085) + +``` +33 passed in 3.21s +``` + +覆盖:首页 8 入口固定顺序(INV-017)、Banner 禁外链(INV-016)、本地资格声明与 +用户隔离(INV-011)、咨询正文权限(INV-012/013)、内容编辑员边界(INV-019)、 +服务站/咨询师越权(INV-018/020)、活动类型筛选与非法值、文章生命周期、咨询取消。 + +### 2. 基建自身的负向属性("未执行"不会被算成"通过") + +| 场景 | 结果 | +|---|---| +| `E2E_BASE_URL` 指向未监听端口 | `pytest.exit`,**退出码 4**,输出明确提示 | +| 未设置 `E2E_OPERATOR_PASSWORD` | `pytest.exit`,退出码 4 | + +### 3. 后端全量回归 + +``` +Tests run: 656, Failures: 0, Errors: 0, Skipped: 3 BUILD SUCCESS +``` + +基线 655(Phase 23 后),净 +1 为新增的构建产物守卫 `testBuildOutputHasNoDuplicateMigrationVersions`。 +3 个 skip 均有显式原因(2× `@EnabledIfEnvironmentVariable(CI=true)`、1× `@Disabled("需要完整数据库连接")`),无一为空壳。 + +**守卫负向验证**:向 `target/classes/db/migration` 注入一个重复的 `V042__*.sql` → +恰好 `testBuildOutputHasNoDuplicateMigrationVersions` 变红(报"版本号 42 重复"), +其余 12 个用例仍绿;移除注入文件后恢复全绿。 + +--- + +## 五、遗留(未静默扩权) + +1. **matchmaker 角色 0 权限**(D-23B-E 一半):红娘该拥有哪些权限属产品定义, + 本轮不擅自授予;转介类 E2E 场景因此仍无法正向执行(已在文件中注明,未写假用例)。 +2. **NPE → 500 的跨模块根因**:`@AuthenticationPrincipal UserPrincipal` 在 + operator token 下为 `null`,部分端点未做 null 检查 → 500(应 401/403)。 + 本轮只修了被 E2E 命中的路径,其余建议统一处理。 +3. **`run_v11_tests.py` 未被 pytest 收集**:它在 import 期登录、依赖 + `docker exec` 取库内 ID,是"人工跑的活栈脚本"。其 16 个场景应逐步迁移进 + fixture 体系。 +4. **生产环境 JDBC**:`stringtype=unspecified` 本次只改了 docker profile; + 生产的 datasource 由部署环境变量提供,需同步加上(运维项)。 +5. **CI 首次运行成本**:`e2e` job 需构建 backend 镜像(Dockerfile 内联网构建),耗时较长。 + +--- + +## 六、结论 + +Phase 23B **关闭**。 + +E2E 从"一批从未执行过的文件 + 一个不存在的 fixture 体系",变为 +「**有栈就真跑、没栈就退出、CI 会阻断**」的可执行基建; +顺带用真实 PostgreSQL 挖出 6 个 H2 永远发现不了的生产缺陷。 diff --git a/docs/STATUS.md b/docs/STATUS.md index d8e36b8..351971a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -660,6 +660,36 @@ Invariant测试:10个(INV-011~020)→ **实测:原 14 个用例无任何 **Phase 22(部署运维配置扩展)— 已完成** ✅ +### Phase 23B — E2E 测试基建重建(2026-09-18)✅ 已关闭 + +> 本轮补齐的是 Phase 23 的两条遗留(D-23-C「E2E 整体不可执行」、D-23-D「CI 未串联 E2E」), +> 与下方 **「Phase 24 — UAT增量」是不同工作**,不要混为一谈。 + +| 交付 | 说明 | +|---|---| +| `tests/e2e/conftest.py` | fixture 体系:ApiClient、`operator_token(username)`、`new_user`、栈探活 | +| `pytest.ini` / `tests/e2e/requirements.txt` | 收集范围与依赖 | +| `tests/e2e/sql/seed_e2e_operators.sql` | 运营账号自举(**迁移里没有任何 operator 种子**,全新库无账号可登录) | +| CI job `e2e` | 起真实栈 → 等就绪 → seed → pytest → 失败阻断 → 清理 | +| 8 个 E2E 文件改造 | 消除 import 期 `KeyError`;替换恒真 / 名不副实的断言 | + +**核心设计**:栈不可达时 `pytest.exit(4)`,**不是 skip** —— skip 会把"未执行"伪装成"通过"。 + +**验证**:本机真实活栈 **33 passed**;后端全量回归见本节末。 + +**本轮暴露并修复的真实生产缺陷**(H2 测试库全部零覆盖,只有真 PostgreSQL 才现形): + +| 编号 | 真相 | +|---|---| +| D-23B-A | 构建产物残留重复迁移版本号 042 → Flyway 报错 → **应用起不来** | +| D-23B-B | 非 UUID path 参数 → 500 且泄漏数据库异常 | +| D-23B-C | 6 个 E2E 文件 import 期崩溃 → **从未被执行过** | +| D-23B-E | 权限 seed 缺口:`super_admin` 仅 18/48 条、`matchmaker` **0** 条(matchmaker 部分列为遗留) | +| D-23B-I | 非法枚举参数 `IllegalArgumentException` 未映射 → 500(应 BAD_REQUEST) | +| D-23B-J | `operator does not exist: uuid = character varying` → JDBC 增加 `stringtype=unspecified` | + +详见 `docs/Phase-23B-V11-E2E-Infrastructure-Completion-Report-2026-09-18.md`。 + ### Phase 24 — UAT增量 ✅ 已就绪(测试数据支持) | 任务 | 状态 | |------|------| diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5525bb3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +# E2E 只收集 tests/e2e。 +# +# 注意:tests/e2e/run_v11_tests.py 故意**不**被收集(文件名不匹配 *_test.py): +# 它在 import 期就执行 operator 登录、依赖 docker exec 取库内 ID, +# 是一条"人工跑的活栈脚本",不是可收集的 pytest 用例。 +# 该文件的 16 个场景应逐步迁移到 conftest fixture 体系下(见遗留项)。 +testpaths = tests/e2e +python_files = *_test.py +python_classes = Test* +python_functions = test_* +addopts = -ra --strict-markers +markers = + e2e: 端到端测试,需要活栈(E2E_BASE_URL + E2E_OPERATOR_PASSWORD) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..e78f445 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,172 @@ +""" +善缘丹阳 E2E 测试基建(pytest conftest)。 + +设计纪律(Phase 23 复验后确立,不得回退): + +1. **栈不可达 = 退出,不是跳过。** `skip` 会把"未执行"伪装成"通过", + 这正是 Phase 23 清掉的 44 个 `pass` 空壳的另一种变体。因此本文件在 + 活栈不可达时调用 `pytest.exit(returncode=4)` —— 明确报"没跑成"。 +2. **不 mock 业务行为。** 所有 fixture 走真实 HTTP 与真实数据库; + 唯一被替换的是微信登录(后端 `MockWechatLoginProvider` 默认激活)。 +3. **用户隔离。** 每个用例拿独立新用户(mock 登录同 code 同 openid, + 换 code 即换人),避免用例互相污染与"上一个用例造的数据让本用例变绿"。 + +环境变量: +- `E2E_BASE_URL` 活栈地址,默认 http://localhost:8085/api/v1 +- `E2E_OPERATOR_PASSWORD` 运营账号密码(必需;CI 由生成的 .env 提供) +""" +from __future__ import annotations + +import os +import time +import uuid +from typing import Any, Callable, Dict, Optional + +import pytest +import requests + +DEFAULT_BASE_URL = "http://localhost:8085/api/v1" +HEALTH_PATH = "/health/live" + + +class ApiClient: + """极薄 HTTP 客户端:只负责超时与错误上下文,不做业务判断。""" + + def __init__(self, base_url: str, timeout: float = 15.0) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + # E2E 打的是 localhost 活栈。若宿主机设置了 http_proxy, + # requests 默认会把 localhost 请求也发给代理并返回 502, + # 表现为"连接失败"而非真实的业务结果——直接绕开环境变量。 + self.session.trust_env = False + + def call( + self, + method: str, + path: str, + *, + token: Optional[str] = None, + json: Optional[Dict[str, Any]] = None, + params: Optional[Dict[str, Any]] = None, + ) -> requests.Response: + headers = {"Authorization": f"Bearer {token}"} if token else {} + return self.session.request( + method, + f"{self.base_url}{path}", + headers=headers, + json=json, + params=params, + timeout=self.timeout, + ) + + def ok(self, method: str, path: str, **kwargs: Any) -> Any: + """断言 HTTP 200 且业务 `success=true`,返回 `data`。""" + resp = self.call(method, path, **kwargs) + assert resp.status_code == 200, ( + f"{method} {path} -> HTTP {resp.status_code}: {resp.text[:400]}" + ) + body = resp.json() + assert body.get("success") is True, f"{method} {path} -> 业务失败: {body}" + return body.get("data") + + +# ---------------------------------------------------------------- fixtures + + +@pytest.fixture(scope="session") +def base_url() -> str: + return os.environ.get("E2E_BASE_URL", DEFAULT_BASE_URL).rstrip("/") + + +@pytest.fixture(scope="session") +def api(base_url: str) -> ApiClient: + return ApiClient(base_url) + + +@pytest.fixture(scope="session", autouse=True) +def live_stack(api: ApiClient) -> None: + """会话级探活。栈不可达 → 退出(退出码 4),绝不 skip。""" + last = "unknown" + for _ in range(3): + try: + resp = api.call("GET", HEALTH_PATH) + if resp.status_code == 200: + return + last = f"HTTP {resp.status_code}" + except requests.RequestException as exc: + last = repr(exc) + time.sleep(2) + pytest.exit( + f"E2E 需要活栈:GET {api.base_url}{HEALTH_PATH} 不可达({last})。\n" + f"请先起栈(infrastructure/docker-compose.yml)或设置 E2E_BASE_URL。", + returncode=4, + ) + + +@pytest.fixture(scope="session") +def operator_password() -> str: + pwd = os.environ.get("E2E_OPERATOR_PASSWORD") + if not pwd: + pytest.exit("E2E_OPERATOR_PASSWORD 未设置:E2E 无法登录运营账号,不能执行", returncode=4) + return pwd + + +@pytest.fixture(scope="session") +def admin_token(api: ApiClient, operator_password: str) -> str: + data = api.ok( + "POST", + "/auth/operator/login", + json={"username": "admin", "password": operator_password}, + ) + token = data.get("accessToken") + assert token, "admin 登录未返回 accessToken" + return token + + +@pytest.fixture +def operator_token( + api: ApiClient, operator_password: str +) -> Callable[[str], str]: + """按用户名取运营 token(会话内缓存)。用法:`operator_token("counselor01")`。""" + cache: Dict[str, str] = {} + + def _get(username: str) -> str: + if username not in cache: + data = api.ok( + "POST", + "/auth/operator/login", + json={"username": username, "password": operator_password}, + ) + token = data.get("accessToken") + assert token, f"{username} 登录未返回 accessToken" + cache[username] = token + return cache[username] + + return _get + + +@pytest.fixture +def new_user(api: ApiClient) -> Callable[[], Dict[str, Any]]: + """注册并登录一个全新用户,返回 {'token','user_id','code'}。""" + created: list = [] + + def _create() -> Dict[str, Any]: + code = f"e2e-{uuid.uuid4()}" + data = api.ok("POST", "/auth/wechat/login", json={"code": code}) + user = { + "token": data.get("accessToken"), + "user_id": data.get("userId"), + "code": code, + } + assert user["token"] and user["user_id"], f"新用户登录返回异常: {data}" + created.append(user) + return user + + return _create + + +@pytest.fixture +def user_token(new_user: Callable[[], Dict[str, Any]]) -> str: + """独立新用户的 token。""" + return new_user()["token"] diff --git a/tests/e2e/content_e2e_test.py b/tests/e2e/content_e2e_test.py index 72f44ad..5633db0 100644 --- a/tests/e2e/content_e2e_test.py +++ b/tests/e2e/content_e2e_test.py @@ -1,72 +1,42 @@ """ -E2E 测试:Phase 23 - 内容管理 E2E +内容管理 E2E(内容编辑员权限边界 + 文章生命周期)。 + +迁移说明:原文件在 import 期就读 `os.environ["E2E_PASSWORD"]`, +栈没配该变量时**整个文件收集失败**(从未执行过)。现改为 conftest fixture。 """ -import os import pytest -import requests -BASE_URL = 'http://localhost:8085/api/v1' -E2E_PASSWORD = os.environ["E2E_PASSWORD"] +pytestmark = pytest.mark.e2e -def get_admin_token(): - r = requests.post(f'{BASE_URL}/auth/operator/login', json={'username': 'admin', 'password': E2E_PASSWORD}) - return r.json()['data']['accessToken'] -def auth_header(token): - return {'Authorization': f'Bearer {token}'} - -def test_get_content_categories(): - r = requests.get(f'{BASE_URL}/content/categories') - assert r.status_code == 200 - data = r.json()['data'] +def test_content_categories_are_listable(api, user_token): + data = api.ok("GET", "/content/categories", token=user_token) assert isinstance(data, list) -def test_create_content_category(): - token = get_admin_token() - r = requests.post( - f'{BASE_URL}/content/operator/categories', - headers=auth_header(token), - json={'name': '测试分类', 'code': 'test_cat_001', 'sortOrder': 0} - ) - assert r.status_code == 200 - assert r.json()['data']['code'] == 'test_cat_001' -def test_create_and_publish_article(): - token = get_admin_token() - # 创建文章 - create_r = requests.post( - f'{BASE_URL}/content/operator/articles', - headers=auth_header(token), - json={'title': '测试文章', 'summary': '测试摘要', 'content': '测试内容', 'sortOrder': 0} +def test_article_lifecycle_draft_publish_offline(api, operator_token): + """文章生命周期:草稿 → 发布 → 下架,状态必须真实流转。""" + token = operator_token("e2e_content") + created = api.ok( + "POST", + "/content/operator/articles", + token=token, + json={"title": "E2E 文章", "summary": "E2E 摘要", "content": "E2E 正文", "sortOrder": 0}, ) - assert create_r.status_code == 200 - article_id = create_r.json()['data']['id'] - assert create_r.json()['data']['status'] == 'draft' - # 发布 - pub_r = requests.post( - f'{BASE_URL}/content/operator/articles/{article_id}/publish', - headers=auth_header(token) - ) - assert pub_r.status_code == 200 - assert pub_r.json()['data']['status'] == 'published' - # 用户端可访问 - r = requests.get(f'{BASE_URL}/content/articles/{article_id}') - assert r.status_code == 200 - # 下架 - off_r = requests.post( - f'{BASE_URL}/content/operator/articles/{article_id}/offline', - headers=auth_header(token) - ) - assert off_r.status_code == 200 - assert off_r.json()['data']['status'] == 'offline' + article_id = created["id"] + assert created["status"] == "draft", f"新建文章状态应为 draft:{created}" -def test_content_stats(): - r = requests.get(f'{BASE_URL}/operator/analytics/v11/content') - assert r.status_code == 200 - data = r.json()['data'] - assert 'totalPublishedArticles' in data - assert 'totalViews' in data - assert 'topArticles' in data + published = api.ok("POST", f"/content/operator/articles/{article_id}/publish", token=token) + assert published["status"] == "published", f"发布后状态错误:{published}" -if __name__ == '__main__': - pytest.main([__file__, '-v']) + offline = api.ok("POST", f"/content/operator/articles/{article_id}/offline", token=token) + assert offline["status"] == "offline", f"下架后状态错误:{offline}" + + +def test_content_editor_cannot_read_user_matching_data(api, operator_token): + """INV-019:内容编辑员不得访问用户婚恋数据。""" + token = operator_token("e2e_content") + resp = api.call("GET", "/operator/users", token=token) + assert resp.status_code == 403, ( + f"内容编辑员竟能访问用户库:HTTP {resp.status_code} {resp.text[:200]}" + ) diff --git a/tests/e2e/counseling_permission_test.py b/tests/e2e/counseling_permission_test.py index 0e7b3e6..243da3c 100644 --- a/tests/e2e/counseling_permission_test.py +++ b/tests/e2e/counseling_permission_test.py @@ -1,41 +1,75 @@ """ -V1.1 咨询 / 服务站 / 内容编辑员权限 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18) +婚恋咨询权限 E2E(INV-012 只能访问分配的咨询 / INV-013 红娘不能读咨询正文)。 -历史问题 --------- -原文件 10 个测试函数**全部**为 `pass` 空壳(注释「TODO: P13A / P17 / P19 实现后完善」), -依赖不存在的 `counselor_client` / `counseling_service_factory` / `content_editor_client` -等 fixture(全局无 `conftest.py`),从未执行过, -却在 `docs/STATUS.md` 中被计为 P23-T07「咨询权限 ✅」。 - -计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。 - -当前状态 --------- -本文件现**不含任何测试函数**,pytest 收集 0 个用例。 - -仍待真实实现的场景(需求不因移除空壳而丢失) ------------------------------------------- -1. Counselor 只能看到被分配给自己的咨询(INV-012) -2. Counselor 不可查看其他咨询师的服务 -3. 红娘不可查看咨询正文(INV-013) -4. 普通管理员默认不可查看咨询敏感备注 -5. 咨询管理员可分配咨询人员 -6. 咨询过程中可转交风控 -7. 服务站人员不能浏览全平台用户(INV-018) -8. 服务站人员可处理本服务站预约 -9. 内容编辑员不能访问用户婚恋档案(INV-019) -10. 内容编辑员可管理文章内容 - -现状补充(Phase 23 实测) ------------------------- -- INV-012 已由 `CounselingPhase13ATest` 的 4 个真实用例覆盖; -- INV-013 / INV-019 已由 Phase 23 新增的真实行为用例覆盖 - (`V11InvariantsTest#inv013MatchmakerCannotReadCounselingBody`、 - `V11InvariantsTest#inv019ContentEditorHasNoUserDataPermission`, - 走真实 `CounselingService.getDetail`,已通过负向注入验证); -- INV-018 已由 `ServiceAppointmentDataScopeTest` 覆盖。 -即:E2E 层待补,但不变量本身已有真实覆盖。 - -真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py` +关键设计:**先证明本人能读到正文**,再证明他人读不到。 +只断言"他人读不到"会被"全局脱敏/接口全量报错"轻易蒙混过关—— +那证明的不是 INV-013,而是接口坏了。 """ +import pytest + +pytestmark = pytest.mark.e2e + +SENSITIVE = "SENSITIVE-BODY-MARKER" + + +def _create_counseling(api, token: str) -> str: + data = api.ok( + "POST", + "/counseling/services", + token=token, + json={ + "serviceType": "relationship_guidance", + "requestSource": "user_self", + "userRequestSummary": SENSITIVE, + "preferredServiceMode": "online", + "preferredTime": "morning", + }, + ) + assert data["status"] == "pending", f"咨询初始状态异常:{data}" + return data["id"] + + +def test_owner_can_read_own_request_summary(api, user_token): + """反证:接口确实会返回正文,"读不到"才可能是权限生效而非接口失效。""" + service_id = _create_counseling(api, user_token) + data = api.ok("GET", f"/counseling/services/{service_id}", token=user_token) + assert data["userRequestSummary"] == SENSITIVE, f"本人竟读不到自己的咨询正文:{data}" + + +def test_matchmaker_has_no_counseling_view(api, user_token, operator_token): + """INV-013:红娘不具备 counseling.service.view,读取咨询详情必须被拒。""" + service_id = _create_counseling(api, user_token) + resp = api.call( + "GET", + f"/counseling/services/operator/{service_id}", + token=operator_token("e2e_matchmaker"), + ) + assert resp.status_code == 403, ( + f"红娘竟被允许访问咨询详情:HTTP {resp.status_code} {resp.text[:200]}" + ) + assert SENSITIVE not in resp.text, "响应体泄漏了咨询正文" + + +def test_counselor_cannot_read_unassigned_service(api, user_token, operator_token): + """INV-012:咨询师只能访问明确分配给自己的服务。""" + service_id = _create_counseling(api, user_token) + resp = api.call( + "GET", + f"/counseling/services/operator/{service_id}", + token=operator_token("e2e_counselor"), + ) + body = resp.json() + assert body.get("success") is False, f"未分配的咨询竟被咨询师读到:{body}" + assert SENSITIVE not in resp.text, "响应体泄漏了咨询正文" + + +def test_non_uuid_service_id_is_rejected_not_500(api, user_token): + """ + 非 UUID 的 path 参数应被判为 400,而不是打到数据库抛出 + `operator does not exist: uuid = character varying` 变成 500。 + """ + resp = api.call("GET", "/counseling/services/not-a-uuid", token=user_token) + assert resp.status_code != 500, ( + f"非法 ID 触发了 500(数据库类型异常外泄):HTTP {resp.status_code}" + ) + assert 400 <= resp.status_code < 500, f"非法 ID 应返回 4xx,实际 {resp.status_code}" diff --git a/tests/e2e/counseling_v11_test.py b/tests/e2e/counseling_v11_test.py index aba1143..176a324 100644 --- a/tests/e2e/counseling_v11_test.py +++ b/tests/e2e/counseling_v11_test.py @@ -1,124 +1,58 @@ """ -E2E 测试:Phase 13A - 公益婚恋咨询与婚前辅导 -验证 INV-012(Counselor assigned scope)等不变量 +婚恋咨询 / 婚前辅导 E2E(咨询主流程)。 + +迁移说明:原文件依赖 3 个环境变量(import 期即 KeyError → 从未被收集执行), +并直连 psycopg2 校验通知落库,且用不存在的固定 ID 做断言。 +现统一改为 conftest fixture + 真实 API;数据库断言交后端集成测试,E2E 只走 HTTP。 """ -import os import pytest -import requests -BASE_URL = "http://localhost:8085/api/v1" -E2E_PASSWORD = os.environ["E2E_PASSWORD"] -E2E_USER_TOKEN = os.environ["E2E_USER_TOKEN"] -E2E_DB_PASSWORD = os.environ["E2E_DB_PASSWORD"] - -def get_user_token(): - return E2E_USER_TOKEN - -def get_counselor_token(): - r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "counselor01", "password": E2E_PASSWORD}) - return r.json()["data"]["accessToken"] - -def auth_header(token): - return {"Authorization": f"Bearer {token}"} +pytestmark = pytest.mark.e2e -# ---- P13A-T05: 获取咨询介绍 ---- -def test_get_counseling_intro(): - r = requests.get(f"{BASE_URL}/counseling/services/intro") - assert r.status_code == 200 - data = r.json()["data"] - assert isinstance(data, list) - assert len(data) >= 3 - types = {item["type"] for item in data} - assert "relationship_guidance" in types - assert "premarital_guidance" in types +def test_counseling_intro_lists_guidance_types(api, user_token): + data = api.ok("GET", "/counseling/services/intro", token=user_token) + assert isinstance(data, list) and len(data) >= 2, f"咨询介绍异常:{data}" + types = {item.get("type") for item in data} + for expected in ("relationship_guidance", "premarital_guidance"): + assert expected in types, f"咨询介绍缺少 {expected}:{sorted(types)}" -# ---- P13A-T06: 申请咨询 ---- -def test_create_counseling(): - token = get_user_token() - r = requests.post( - f"{BASE_URL}/counseling/services", - headers=auth_header(token), - json={"serviceType": "relationship_guidance", "userRequestSummary": "恋爱沟通困难"} +def test_create_counseling_is_user_self_and_pending(api, user_token): + """用户端创建咨询:source 必须恒为 user_self,不可由请求体伪造。""" + data = api.ok( + "POST", + "/counseling/services", + token=user_token, + json={"serviceType": "relationship_guidance", "userRequestSummary": "恋爱沟通困难"}, ) - assert r.status_code == 200 - data = r.json()["data"] assert data["status"] == "pending" assert data["serviceType"] == "relationship_guidance" - assert data["requestSource"] == "user_self" - counseling_id = data["id"] - - # 验证通知已发送(通过查询数据库) - import psycopg2 - conn = psycopg2.connect(f"dbname=danyang_match user=postgres password={E2E_DB_PASSWORD} host=localhost port=5434") - cur = conn.cursor() - cur.execute("SELECT COUNT(*) FROM notifications WHERE business_id = %s AND type = 'counseling'", (counseling_id,)) - count = cur.fetchone()[0] - assert count >= 1 - cur.close() - conn.close() + assert data["requestSource"] == "user_self", f"来源被污染:{data}" -# ---- P13A-T07: 获取我的咨询列表 ---- -def test_get_my_counselings(): - token = get_user_token() - r = requests.get(f"{BASE_URL}/counseling/services", headers=auth_header(token)) - assert r.status_code == 200 - assert isinstance(r.json()["data"], list) +def test_my_counseling_list_is_accessible(api, user_token): + api.ok("POST", "/counseling/services", token=user_token, + json={"serviceType": "relationship_guidance", "userRequestSummary": "列表用例"}) + data = api.ok("GET", "/counseling/services", token=user_token) + assert isinstance(data, list) -# ---- P13A-T08: 咨询详情(无权访问他人记录) ---- -def test_counseling_detail_no_access(): - token = get_user_token() - # 尝试访问不属于自己的咨询 - r = requests.get(f"{BASE_URL}/counseling/services/non-existent-id", headers=auth_header(token)) - assert r.status_code in (400, 404) - - -# ---- P13A-T09: 取消咨询 ---- -def test_cancel_counseling(): - token = get_user_token() - # 先创建再取消 - create_r = requests.post( - f"{BASE_URL}/counseling/services", - headers=auth_header(token), - json={"serviceType": "emotional_support", "userRequestSummary": "测试取消"} +def test_cancel_counseling(api, user_token): + created = api.ok( + "POST", + "/counseling/services", + token=user_token, + json={"serviceType": "emotional_support", "userRequestSummary": "测试取消"}, ) - counseling_id = create_r.json()["data"]["id"] - cancel_r = requests.post( - f"{BASE_URL}/counseling/services/{counseling_id}/cancel", - headers=auth_header(token) + cancelled = api.ok( + "POST", f"/counseling/services/{created['id']}/cancel", token=user_token ) - assert cancel_r.status_code == 200 - assert cancel_r.json()["data"]["status"] == "cancelled" + assert cancelled["status"] == "cancelled", f"取消后状态错误:{cancelled}" -# ---- INV-012: Counselor只能访问分配给自己的服务 ---- -def test_counselor_assigned_scope(): - counselor_token = get_counselor_token() - # Counselor 只能看到分配给自己的 - r = requests.get(f"{BASE_URL}/counseling/services/operator/assigned", headers=auth_header(counselor_token)) - assert r.status_code == 200 - # 不应包含未分配给自己的记录 - # (假设数据库中 counselor01 没有被分配的记录,返回空列表) - assert isinstance(r.json()["data"], list) - - -# ---- INV-013: Matchmaker不能读取Counseling正文 ---- -# Phase 23 KEEP-VERIFY(2026-09-18):原 test_matchmaker_cannot_read_counseling_body -# 的函数体为 `pass` 空壳(注释自述「在当前测试框架下简化验证」),从未真实断言过任何权限, -# 却在 docs/STATUS.md 中被计为 P23-T06「咨询 E2E ✅」的一部分。 -# 计划 Phase 23 Gate「不允许 skip/pass 空壳」→ 已移除该空壳。 -# -# INV-013 现由后端真实行为用例覆盖: -# backend/.../invariants/V11InvariantsTest#inv013MatchmakerCannotReadCounselingBody -# 走真实 CounselingService.getDetail:断言 Matchmaker 读到的 user_request_summary 为 null, -# 并以「assigned Counselor 能读到正文」作反证,避免全局脱敏造成的假阳性; -# 已通过负向注入验证(把 isMatchmaker 分支改为返回全文 → 该用例精准变红)。 -# -# E2E 层待补场景:用真实红娘 token 调用咨询详情接口并断言被拒。 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +def test_counselor_assigned_list_is_scoped(api, operator_token): + """INV-012:咨询师只能看到分配给自己的服务(列表必须可访问且为列表)。""" + token = operator_token("e2e_counselor") + data = api.ok("GET", "/counseling/services/operator/assigned", token=token) + assert isinstance(data, list) diff --git a/tests/e2e/home_v11_test.py b/tests/e2e/home_v11_test.py index e1a1aa4..99fc833 100644 --- a/tests/e2e/home_v11_test.py +++ b/tests/e2e/home_v11_test.py @@ -1,44 +1,49 @@ """ -V1.1 首页 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18) +首页 E2E(INV-017 八入口固定 / INV-016 Banner 禁外链 / 首页结构固定)。 -历史问题 --------- -原文件 9 个测试函数中 **7 个是 `pass` 空壳**,其余 5 个虽含断言, -但全部依赖 `api_client` / `authenticated_user` / `banner_factory` / -`user_factory` / `verified_user` / `searching_user` / `paused_user` / -`successful_user` / `admin_client` 等**在本仓库中根本不存在**的 pytest fixture -(全局无 `conftest.py`)→ 整个文件**一次都没执行过**, -却在 `docs/STATUS.md` 中被计为 P23-T01~T04「首页 E2E ✅」。 - -计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。 - -当前状态 --------- -本文件现**不含任何测试函数**,pytest 收集 0 个用例。 - -仍待真实实现的场景(需求不因移除空壳而丢失) ------------------------------------------- -1. `GET /api/v1/home` 返回 userState -2. Banner 按时间过滤(未开始 / 已过期不显示) -3. Banner 按 audience_scope 过滤 -4. 首页 8 核心入口顺序固定(INV-017) -5. 未审核用户不返回真实推荐 -6. Paused 用户不返回新推荐 -7. Successful 用户不返回找对象推荐 -8. 服务站可展示 -9. Banner 不能创建外部 URL 跳转(INV-016) -10. `counselingEnabled` flag 控制 `canApplyCounseling` -11. Capability:`canReceiveRecommendations` 随账号状态变化 - -现状补充(Phase 23 实测) ------------------------- -- 首页聚合与 8 入口已有真实 HTTP 覆盖: - `tests/e2e/run_v11_tests.py::test_02_home` 与 `::test_03_home_8_entries`; -- **INV-017 已由 Phase 23 新增的后端真实行为用例覆盖** - (`V11InvariantsTest#inv017QuickEntriesFixedOrder`,走真实 - `HomeApplicationService.getHome(userId)`,断言 8 个编码与顺序完全固定); -- INV-016 已有 `BannerOperatorPhase19IntegrationTest` 真实覆盖。 -即:E2E 层待补,但不变量本身已有真实覆盖。 - -真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py` +这些断言打的是**真实活栈**:`GET /api/v1/home` 的真实响应,不是常量、不是自造字面量。 """ +import pytest + +pytestmark = pytest.mark.e2e + +# AGENTS.md 硬规则 #14/#15:首页 8 核心入口固定,顺序不可更改、运营不可重排。 +EXPECTED_QUICK_ENTRIES = [ + "find_object", # 第一排 + "matchmaker", + "activities", + "counseling", + "classroom", # 第二排 + "premarital", + "anti_fraud", + "service_station", +] + +# INV-016:Banner 仅内部跳转,target_type 白名单(与生产 HomeBannerOperatorController 一致)。 +ALLOWED_BANNER_TARGETS = { + "activity", "matchmaker", "counseling", "premarital", + "service_location", "article", "internal_page", "none", +} + + +def test_home_returns_fixed_sections(api, user_token): + """规则 #14:首页区块齐备(缺一个即结构被改)。""" + data = api.ok("GET", "/home", token=user_token) + for key in ("quickEntries", "banners", "userState", "capabilities", "safetyNotice"): + assert key in data, f"首页缺少区块 {key},实际 keys={sorted(data.keys())}" + + +def test_quick_entries_fixed_order(api, user_token): + """INV-017:8 个核心入口固定且顺序不可更改。""" + data = api.ok("GET", "/home", token=user_token) + codes = [e.get("code") for e in data["quickEntries"]] + assert codes == EXPECTED_QUICK_ENTRIES, f"首页入口顺序被改动:{codes}" + + +def test_banner_target_types_are_internal_only(api, user_token): + """INV-016:Banner 不允许外链(target_type 必须落在白名单内)。""" + data = api.ok("GET", "/home", token=user_token) + banners = data.get("banners") or [] + for b in banners: + target = b.get("targetType") + assert target in ALLOWED_BANNER_TARGETS, f"Banner 出现非白名单 target_type={target!r}" diff --git a/tests/e2e/local_eligibility_test.py b/tests/e2e/local_eligibility_test.py index 759d3c0..5f16140 100644 --- a/tests/e2e/local_eligibility_test.py +++ b/tests/e2e/local_eligibility_test.py @@ -1,39 +1,54 @@ """ -V1.1 本地资格 E2E —— 空壳已移除(Phase 23 KEEP-VERIFY,2026-09-18) +本地资格 E2E(INV-011:本地资格佐证不得被未授权方读取)。 -历史问题 --------- -原文件 11 个测试函数**全部**为 `pass` 空壳(注释均为「TODO: P04/P05/P06 实现后完善」), -且依赖 `api_client` / `verified_user` / `local_eligibility_factory` 等 -**在本仓库中根本不存在**的 pytest fixture(全局无 `conftest.py`)。 -这些用例从未执行过,却在 `docs/STATUS.md` 中被计为 P23-T05「本地资格 E2E ✅」。 - -计划 Phase 23 Gate:「**不允许 skip/pass 空壳**」。 - -当前状态 --------- -本文件现**不含任何测试函数**,pytest 将收集到 0 个用例 —— -以「没有假覆盖」取代「假装有覆盖」。 - -仍待真实实现的场景(需求不因移除空壳而丢失) ------------------------------------------- -1. 用户可声明丹阳工作 / 户籍 / 居住 -2. 声明本地资格不默认要求上传佐证 -3. 佐证文件上传需要鉴权 -4. 审核员可访问本地资格佐证(INV-011) -5. 红娘不可访问本地资格佐证(INV-011) -6. `/me/status` 返回 localEligibilityStatus -7. 本地资格未核验影响匹配资格 -8. 本地资格审核进入 `review_type=local_eligibility` 队列 -9. 审核通过更新 local_eligibility 状态 -10. 需补充佐证时触发用户上传 -11. 查看佐证文件须记录审计日志 - -现状补充(Phase 23 实测) ------------------------- -`LocalEligibilityService` 全部方法均以 `userId` 自限定、**无任何 role 分支**, -佐证无独立的服务端读取入口(仅经受权限控制的审核队列 `ReviewController` 访问), -故 INV-011 在结构上成立;但缺少显式守卫测试,列为遗留待补。 - -真实可执行的 V1.1 E2E 见:`tests/e2e/run_v11_tests.py` +打真实活栈:新用户经 mock 微信登录,走真实的声明/查询链路。 """ +import pytest + +pytestmark = pytest.mark.e2e + +# 生产 local_eligibility 的 eligibility_type CHECK 白名单内的合规取值。 +ELIGIBILITY_TYPE = "household_registration" +REGION_CODE = "321181" # 丹阳 + + +def test_new_user_is_not_declared(api, user_token): + data = api.ok("GET", "/local-eligibility", token=user_token) + assert data["verificationStatus"] == "not_declared", f"新用户初始状态异常:{data}" + + +def test_declaration_is_persisted(api, user_token): + """声明后应能查回声明内容(真实落库,不是内存态)。""" + api.ok( + "POST", + "/local-eligibility/declaration", + token=user_token, + json={"eligibilityType": ELIGIBILITY_TYPE, "regionCode": REGION_CODE}, + ) + data = api.ok("GET", "/local-eligibility", token=user_token) + assert data["eligibilityType"] == ELIGIBILITY_TYPE, f"声明未落库:{data}" + assert data["verificationStatus"] != "not_declared", f"声明后状态未推进:{data}" + + +def test_other_user_cannot_read_my_declaration(api, new_user): + """ + INV-011(数据侧):资格查询是 userId 自限定的, + 另一个用户读到的必须是**他自己的**空状态,绝不能看到 A 的声明。 + """ + user_a = new_user() + api.ok( + "POST", + "/local-eligibility/declaration", + token=user_a["token"], + json={"eligibilityType": ELIGIBILITY_TYPE, "regionCode": REGION_CODE}, + ) + mine = api.ok("GET", "/local-eligibility", token=user_a["token"]) + assert mine["eligibilityType"] == ELIGIBILITY_TYPE + + user_b = new_user() + other = api.ok("GET", "/local-eligibility", token=user_b["token"]) + + assert other["userId"] == user_b["user_id"], "返回了他人 userId(越权)" + assert other["userId"] != user_a["user_id"] + assert other["eligibilityType"] is None, f"B 看到了 A 的资格声明(越权):{other}" + assert other["verificationStatus"] == "not_declared" diff --git a/tests/e2e/multi_type_activity_test.py b/tests/e2e/multi_type_activity_test.py index 24adf62..d4a16cf 100644 --- a/tests/e2e/multi_type_activity_test.py +++ b/tests/e2e/multi_type_activity_test.py @@ -1,47 +1,45 @@ """ -E2E 测试:Phase 23 - 多类型活动 E2E +多类型活动 E2E。 + +迁移说明:原文件 import 期读环境变量导致收集失败; +且 `assert item.get("activityType") == x or not item.get("activityType")` 是 +**恒真断言**(字段缺失或列表为空都通过),已改为真实类型校验。 """ -import os import pytest -import requests -BASE_URL = "http://localhost:8085/api/v1" -E2E_PASSWORD = os.environ["E2E_PASSWORD"] +pytestmark = pytest.mark.e2e -def get_admin_token(): - r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "admin", "password": E2E_PASSWORD}) - return r.json()["data"]["accessToken"] -def auth_header(token): - return {"Authorization": f"Bearer {token}"} - -def test_get_activities_by_type(): - r = requests.get(f"{BASE_URL}/activities/types/matchmaking") - assert r.status_code == 200 - data = r.json()["data"] +def test_activities_list_is_accessible(api, user_token): + data = api.ok("GET", "/activities", token=user_token) assert isinstance(data, list) -def test_activity_type_filter(): - r = requests.get(f"{BASE_URL}/activities/types/anti_fraud") - assert r.status_code == 200 - for item in r.json()["data"]: - assert item.get("activityType") == "anti_fraud" or not item.get("activityType") -def test_activity_organizations(): - # 创建活动后获取组织列表 - r = requests.get(f"{BASE_URL}/activities/act-001/organizations") - assert r.status_code == 200 +# 必须是生产 chk_activity_type 白名单内的合法值(ActivityService.VALID_ACTIVITY_TYPES); +# 传非法值应为 400 而非 500 —— 由 test_invalid_activity_type_returns_400 覆盖。 +@pytest.mark.parametrize( + "activity_type", + ["matchmaking", "interest_social", "relationship_education", + "premarital_guidance", "marriage_custom_culture", "anti_fraud"], +) +def test_activities_can_be_filtered_by_type(api, user_token, activity_type): + """按类型筛选:返回的每一项都必须是该类型(不能靠字段缺失蒙混)。""" + data = api.ok("GET", f"/activities/types/{activity_type}", token=user_token) + assert isinstance(data, list) + for item in data: + assert item.get("activityType") == activity_type, ( + f"类型筛选返回了其它类型的活动:{item}" + ) -def test_activity_capacity_groups(): - r = requests.get(f"{BASE_URL}/activities/act-001/capacity-groups") - assert r.status_code == 200 -def test_registration_source_stats(): - r = requests.get(f"{BASE_URL}/operator/analytics/v11/registration-sources") - assert r.status_code == 200 - data = r.json()["data"] - assert "totalRegistrations" in data - assert "bySource" in data +def test_invalid_activity_type_returns_bad_request_not_500(api, user_token): + """ + 非法类型属客户端参数错误,必须回 BAD_REQUEST。 -if __name__ == "__main__": - pytest.main([__file__, "-v"]) + 注意本项目的约定:业务错误是 **HTTP 200 + body.code**(只有框架层 + 鉴权失败才是真 HTTP 403)。因此这里断言的是 code,不是 HTTP 状态码。 + """ + resp = api.call("GET", "/activities/types/not-a-real-type", token=user_token) + assert resp.status_code != 500, f"非法活动类型触发了 500:{resp.text[:200]}" + body = resp.json() + assert body.get("code") == "BAD_REQUEST", f"非法类型应回 BAD_REQUEST,实际 {body}" diff --git a/tests/e2e/organization_isolation_test.py b/tests/e2e/organization_isolation_test.py index 5f8d31f..fd498bf 100644 --- a/tests/e2e/organization_isolation_test.py +++ b/tests/e2e/organization_isolation_test.py @@ -1,32 +1,31 @@ """ -E2E 测试:Phase 23 - 组织关系隔离 +组织关系隔离 E2E(INV-018 / INV-020)。 + +迁移说明:原文件两个用例只断言 `status_code == 200`,却宣称验证了 +"组织关系不自动授予用户数据访问权限" —— 这与 INV-020 毫无逻辑关联, +属典型假覆盖。现改为真实的越权访问断言。 """ -import os import pytest -import requests -BASE_URL = "http://localhost:8085/api/v1" -E2E_PASSWORD = os.environ["E2E_PASSWORD"] +pytestmark = pytest.mark.e2e -def get_admin_token(): - r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "admin", "password": E2E_PASSWORD}) - return r.json()["data"]["accessToken"] -def auth_header(token): - return {"Authorization": f"Bearer {token}"} +def test_station_operator_cannot_browse_all_users(api, operator_token): + """ + INV-018:服务站运营人员只能处理现场预约与指定任务, + 不得浏览全平台用户库(INV-020:组织关系不自动授予数据权限)。 + """ + token = operator_token("e2e_station") + resp = api.call("GET", "/operator/users", token=token) + assert resp.status_code == 403, ( + f"服务站运营竟能浏览全用户库:HTTP {resp.status_code} {resp.text[:200]}" + ) -def test_activity_organizations_are_isolated(): - # 组织A的管理员不应看到组织B的活动详情 - token = get_admin_token() - r = requests.get(f"{BASE_URL}/activities", headers=auth_header(token)) - assert r.status_code == 200 - # 验证活动列表返回正常 -def test_no_auto_permission_from_org_relationship(): - # 验证组织关系不自动授予用户数据访问权限(INV-020) - # 此测试验证数据库中不存在基于组织关系的自动权限授予 - r = requests.get(f"{BASE_URL}/operator/analytics/v11/service-programs", headers=auth_header(get_admin_token())) - assert r.status_code == 200 - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +def test_counselor_cannot_browse_all_users(api, operator_token): + """INV-012/020:咨询师同理,组织/角色关系不等于全量数据权限。""" + token = operator_token("e2e_counselor") + resp = api.call("GET", "/operator/users", token=token) + assert resp.status_code == 403, ( + f"咨询师竟能浏览全用户库:HTTP {resp.status_code} {resp.text[:200]}" + ) diff --git a/tests/e2e/referral_v11_test.py b/tests/e2e/referral_v11_test.py index 09af1bb..be6138a 100644 --- a/tests/e2e/referral_v11_test.py +++ b/tests/e2e/referral_v11_test.py @@ -1,145 +1,50 @@ """ -E2E 测试:Phase 13 - 红娘服务转介 -验证 INV-005(红娘不可代用户操作)及转介追踪链路 +红娘转介 E2E(INV-005:红娘不可代用户操作)。 + +迁移说明:原文件依赖 3 个环境变量(import 期 KeyError → 从未执行), +用 `user-test-001` / `loc-001` 等**不存在的固定 ID** 构造请求, +甚至出现 `auth_header("Bearer dummy")` 这种错误用法 —— 从未真正跑通过。 + +改造后只保留可真实执行的断言;被 D-24-E 权限缺口阻塞的场景在下方注释中 +如实记录,不写成"看起来在测、实际恒过"的假用例。 """ -import os import pytest -import requests -BASE_URL = "http://localhost:8085/api/v1" -E2E_PASSWORD = os.environ["E2E_PASSWORD"] -E2E_USER_TOKEN = os.environ["E2E_USER_TOKEN"] -E2E_DB_PASSWORD = os.environ["E2E_DB_PASSWORD"] - -# ---- 工具函数 ---- -def get_user_token(): - return E2E_USER_TOKEN - -def get_matchmaker_token(): - r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "mm001", "password": E2E_PASSWORD}) - return r.json()["data"]["accessToken"] - -def auth_header(token): - return {"Authorization": f"Bearer {token}"} +pytestmark = pytest.mark.e2e -# ---- P13-T05: 转介确认后激活 ---- -def test_referral_requires_user_confirmation(): - """红娘转介后,预约状态为 submitted,需用户 confirm 后才变为 pending_confirmation""" - user_token = get_user_token() - mm_token = get_matchmaker_token() +def test_referral_history_is_accessible(api, user_token): + data = api.ok("GET", "/referrals/my-history", token=user_token) + assert isinstance(data, list) - # 红娘创建转介预约 - r = requests.post( - f"{BASE_URL}/referrals/service-station", - headers=auth_header(mm_token), - json={"userId": "user-test-001", "locationId": "loc-001", "serviceType": "counselor_consultation"} + +def test_matchmaker_cannot_confirm_on_users_behalf(api, operator_token, new_user): + """ + INV-005:红娘不能替用户确认转介。 + + 用一个**不存在的转介 ID** 只能证明"资源不存在",证明不了权限生效, + 因此这里断言的是端点级拒绝:红娘侧 confirm 不应返回 2xx。 + """ + mm_token = operator_token("e2e_matchmaker") + resp = api.call( + "POST", + "/referrals/00000000-0000-0000-0000-000000000000/confirm", + token=mm_token, + json={"targetType": "service_station"}, ) - assert r.status_code == 200 - appt_id = r.json()["data"]["appointmentId"] - assert r.json()["data"]["status"] == "submitted" - assert r.json()["data"]["needsUserConfirmation"] is True - - # 用户确认 - r2 = requests.post( - f"{BASE_URL}/referrals/{appt_id}/confirm", - headers=auth_header(user_token), - json={"targetType": "service_station"} + assert not (200 <= resp.status_code < 300), ( + f"红娘竟能代用户确认转介:HTTP {resp.status_code} {resp.text[:200]}" ) - assert r2.status_code == 200 - assert r2.json()["data"]["status"] == "pending_confirmation" -# ---- P13-T02: 转介活动 ---- -def test_matchmaker_refers_activity(): - """红娘转介用户到公益活动,需用户确认""" - user_token = get_user_token() - mm_token = get_matchmaker_token() - - r = requests.post( - f"{BASE_URL}/referrals/activity", - headers=auth_header(mm_token), - json={"userId": "user-test-001", "activityId": "act-001", - "emergencyContactName": "张某某", "emergencyContactPhoneEnc": "enc123"} - ) - assert r.status_code == 200 - reg_id = r.json()["data"]["registrationId"] - assert r.json()["data"]["referralType"] == "activity" - assert r.json()["data"]["needsUserConfirmation"] is True - - # 用户确认 - r2 = requests.post( - f"{BASE_URL}/referrals/{reg_id}/confirm", - headers=auth_header(user_token), - json={"targetType": "activity"} - ) - assert r2.status_code == 200 - - -# ---- P13-T04: 转介历史查询 ---- -def test_referral_history(): - """查询用户的转介历史记录""" - user_token = get_user_token() - r = requests.get(f"{BASE_URL}/referrals/my-history", headers=auth_header(user_token)) - assert r.status_code == 200 - history = r.json()["data"] - assert isinstance(history, list) - - -# ---- INV-005: 红娘不能确认用户的转介 ---- -def test_matchmaker_cannot_confirm_for_user(): - """INV-005: 红娘不能替用户确认转介,必须用户自己确认""" - mm_token = get_matchmaker_token() - - # 红娘尝试确认(应该失败) - r = requests.post( - f"{BASE_URL}/referrals/some-id/confirm", - headers=auth_header(mm_token), - json={"targetType": "service_station"} - ) - # 非对应用户ID,应返回 403 或 404 - assert r.status_code in (400, 403, 404) - - -# ---- INV-005: 红娘不能取消用户的转介 ---- -def test_matchmaker_cannot_cancel_for_user(): - """红娘不能取消用户的预约,只能用户自己取消""" - mm_token = get_matchmaker_token() - r = requests.post( - f"{BASE_URL}/service-appointments/some-id/cancel", - headers=auth_header(mm_token), - json={"reason": "测试取消"} - ) - assert r.status_code in (400, 403, 404) - - -# ---- 转介来源追踪验证 ---- -def test_referral_source_tracking(): - """验证转介时 source 字段正确设置""" - mm_token = get_matchmaker_token() - r = requests.post( - f"{BASE_URL}/referrals/service-station", - headers=auth_header(mm_token), - json={"userId": "user-test-001", "locationId": "loc-001", "serviceType": "station_visit"} - ) - assert r.status_code == 200 - appt_id = r.json()["data"]["appointmentId"] - - # 查询预约详情,验证 source - r2 = requests.get(f"{BASE_URL}/service-appointments/{appt_id}", - headers=auth_header("Bearer dummy")) - # 通过数据库直接验证 source 字段 - import psycopg2 - conn = psycopg2.connect(f"dbname=danyang_match user=postgres password={E2E_DB_PASSWORD} host=localhost port=5434") - cur = conn.cursor() - cur.execute("SELECT source, request_source, created_by_operator_id FROM service_appointments WHERE id = %s", (appt_id,)) - row = cur.fetchone() - assert row[0] == "matchmaker_referral" - assert row[1] == "matchmaker_referral" - assert row[2] is not None - cur.close() - conn.close() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +# --------------------------------------------------------------------------- +# 被阻塞场景(D-24-E:全新库下 matchmaker 角色 0 权限、super_admin 仅 18/48) +# +# 下列场景当前无法真实执行,故**不写成用例**(写上去只能靠"资源不存在"恒过, +# 那正是本轮要清除的假覆盖): +# - 红娘创建转介(POST /referrals/service-station)→ 当前 403 +# - 红娘转介活动(POST /referrals/activity)→ 当前 403 +# - 转介 source 追踪(需真实 appointmentId) +# +# 待权限 seed 补齐(见 Phase 24 报告 D-24-E)后,按上述真实链路补齐用例。 +# --------------------------------------------------------------------------- diff --git a/tests/e2e/requirements.txt b/tests/e2e/requirements.txt new file mode 100644 index 0000000..94ded93 --- /dev/null +++ b/tests/e2e/requirements.txt @@ -0,0 +1,2 @@ +pytest>=7.4.0 +requests>=2.31.0 diff --git a/tests/e2e/service_station_permission_test.py b/tests/e2e/service_station_permission_test.py index 5c364d9..0397cae 100644 --- a/tests/e2e/service_station_permission_test.py +++ b/tests/e2e/service_station_permission_test.py @@ -1,40 +1,37 @@ """ -E2E 测试:Phase 23 - 服务站权限隔离 +V1.1 新增 Operator 角色权限边界 E2E(INV-012 / INV-018 / INV-019)。 + +迁移说明:原文件用 counselor 的 token 去"模拟" content_editor 的权限边界 +(名不副实——测的根本不是 content_editor),且 import 期读环境变量导致收集失败。 +现改为真实的角色账号。 """ -import os import pytest -import requests -BASE_URL = "http://localhost:8085/api/v1" -E2E_PASSWORD = os.environ["E2E_PASSWORD"] +pytestmark = pytest.mark.e2e -def get_station_operator_token(): - r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "stationop", "password": E2E_PASSWORD}) - return r.json()["data"]["accessToken"] -def get_counselor_token(): - r = requests.post(f"{BASE_URL}/auth/operator/login", json={"username": "counselor01", "password": E2E_PASSWORD}) - return r.json()["data"]["accessToken"] +def test_station_operator_cannot_view_counseling_queue(api, operator_token): + """INV-012:服务站运营人员不得进入咨询队列。""" + token = operator_token("e2e_station") + resp = api.call("GET", "/counseling/services/operator/queue", token=token) + assert resp.status_code == 403, ( + f"服务站运营竟能查看咨询队列:HTTP {resp.status_code} {resp.text[:200]}" + ) -def auth_header(token): - return {"Authorization": f"Bearer {token}"} -def test_station_operator_cannot_view_counseling(): - token = get_station_operator_token() - r = requests.get(f"{BASE_URL}/counseling/services/operator/queue", headers=auth_header(token)) - # 服务站运营人员不应有咨询队列权限,返回403或空 - assert r.status_code == 403 +def test_counselor_cannot_view_all_service_appointments(api, operator_token): + """INV-012:咨询师不得浏览全量服务预约。""" + token = operator_token("e2e_counselor") + resp = api.call("GET", "/service-appointments/operator/list", token=token) + assert resp.status_code == 403, ( + f"咨询师竟能浏览全量预约:HTTP {resp.status_code} {resp.text[:200]}" + ) -def test_counselor_cannot_view_service_appointments(): - token = get_counselor_token() - r = requests.get(f"{BASE_URL}/service-appointments/operator", headers=auth_header(token)) - # Counselor只能查看自己相关的预约,不能浏览全部 - assert r.status_code == 403 -def test_content_editor_cannot_access_user_data(): - token = get_counselor_token() # 复用counselor token模拟content_editor权限边界 - r = requests.get(f"{BASE_URL}/operator/appeals/pending", headers=auth_header(token)) - assert r.status_code == 403 - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) +def test_content_editor_cannot_access_appeals(api, operator_token): + """INV-019:内容编辑员不得访问申诉等用户数据处理职能。""" + token = operator_token("e2e_content") + resp = api.call("GET", "/operator/appeals/pending", token=token) + assert resp.status_code == 403, ( + f"内容编辑员竟能访问申诉队列:HTTP {resp.status_code} {resp.text[:200]}" + ) diff --git a/tests/e2e/sql/seed_e2e_operators.sql b/tests/e2e/sql/seed_e2e_operators.sql new file mode 100644 index 0000000..190e9e9 --- /dev/null +++ b/tests/e2e/sql/seed_e2e_operators.sql @@ -0,0 +1,41 @@ +-- ============================================================ +-- E2E 测试运营账号 seed(幂等,CI 与本机通用) +-- +-- 为什么需要: +-- 迁移里只有 roles / permissions 的种子,**没有任何 operator 账号**。 +-- 全新库起栈后一个运营账号都没有,E2E 无法登录,operator 侧链路 +-- (权限矩阵、代登记、审核队列)全部无法验证。本脚本把"可自举" +-- 做成基建的一部分,而不是依赖某台机器上手工创建的遗留账号。 +-- +-- 用法(密码由环境变量注入,不入库明文、不进版本库): +-- psql -v pwd="$E2E_OPERATOR_PASSWORD" -f seed_e2e_operators.sql +-- +-- 幂等:全部走 ON CONFLICT DO NOTHING,可重复执行。 +-- ============================================================ + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- 账号:密码相同($pwd),角色不同,用于验证权限隔离(INV-012/013/019 等) +INSERT INTO operators (id, name, username, password_hash, status) VALUES + ('e2e00000-0000-0000-0000-000000000001', 'E2E 超级管理员', 'e2e_admin', crypt(:'pwd', gen_salt('bf', 8)), 'active'), + ('e2e00000-0000-0000-0000-000000000002', 'E2E 平台管理员', 'e2e_platform', crypt(:'pwd', gen_salt('bf', 8)), 'active'), + ('e2e00000-0000-0000-0000-000000000003', 'E2E 婚恋咨询师', 'e2e_counselor', crypt(:'pwd', gen_salt('bf', 8)), 'active'), + ('e2e00000-0000-0000-0000-000000000004', 'E2E 服务站运营', 'e2e_station', crypt(:'pwd', gen_salt('bf', 8)), 'active'), + ('e2e00000-0000-0000-0000-000000000005', 'E2E 公益红娘', 'e2e_matchmaker', crypt(:'pwd', gen_salt('bf', 8)), 'active'), + ('e2e00000-0000-0000-0000-000000000006', 'E2E 内容编辑', 'e2e_content', crypt(:'pwd', gen_salt('bf', 8)), 'active') +ON CONFLICT (username) DO NOTHING; + +-- 角色绑定(role id 取自 V015 seed) +-- super_admin / platform_admin +INSERT INTO operator_roles (operator_id, role_id) VALUES + ('e2e00000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'), + ('e2e00000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002'), + -- counselor + ('e2e00000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000011'), + -- service_station_operator + ('e2e00000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000010'), + -- matchmaker + ('e2e00000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000005'), + -- content_editor + ('e2e00000-0000-0000-0000-000000000006', '00000000-0000-0000-0000-000000000013') +ON CONFLICT (operator_id, role_id) DO NOTHING;