# 内部小工具自助部署平台 - 测试场景

> **module**: internal-app-platform
> **doc_type**: TestScenarios
> **status**: Draft
> **owner**: lijian.dai
> **upstream_docs**: 01-prd.md, 03-architecture.md, 06-data-model.md, 07-api.md
> **last_verified**: 2026-05-13
>
> **事实源**: 本文档定义 MVP 阶段全部 L1（集成测试）+ L2（MCP E2E）+ Phase 0 PoC 端到端验收剧本。

---

## 0. 测试范围与分层

按 `docs/standards/05-development-workflow.md` 测试金字塔分层：

| 层 | 范围 | 本模块产出 |
|----|-----|----------|
| **L0a/L0b** 契约校验 | 前端 ↔ 后端 字段对齐 | 走平台脚本 `testing/scripts/contract-check.ts`，本文档不单列 |
| **L0c** 响应快照 | 真实响应 vs 前端 interface | 同上 |
| **L1** 集成测试 | HTTP → 真实 DB | **本文档主体（§1-§5）** |
| **L1c** 数据质量 | 种子数据 / 脱敏快照结构 | 本模块无种子数据，免做 |
| **L2** MCP E2E | Playwright MCP 走业务流程 | **§6 FFOA "我的 Apps" 页**（唯一前端页） |
| **L3** 人工验收 | 用户按清单逐项点 | **§7 Phase 0 PoC 验收剧本** |

> **不规划独立 `10-e2e-test-spec.md`**（README 已声明）——E2E 嵌入本文档 §6/§7，避免文档膨胀。

---

## 1. 集成测试 - token 生命周期（L1）

### 场景 1.1: 首次颁发 token + 首次接入冻结 employeeSlug

**优先级**: P0

**前置**: 员工首次访问 FFOA "我的 Apps" 页（Entra ID `mailNickname='Zhang San'`，无任何记录）

**步骤**:
1. `POST /api/v1/internal-apps/tokens`（携 Entra session）

**断言**:
- 返回 `tokenPlaintext` 明文（`ffoa_` + 32 字符），仅此一次
- `expiresAt = now + 90d`（容差 ±10 秒）
- `employee_slug_bindings` 新增一行：`employee_slug='zhang-san'`，`source_mail_nickname='Zhang San'`
- `employee_tokens` 新增 ACTIVE 行，`token_hash = SHA256(tokenPlaintext)`，DB 不存明文
- audit 写入 `internal_app.token.issued`，载荷含 `employeeSlug` + `prefix`

---

### 场景 1.2: 重新生成 token → 旧 token 原子吊销

**优先级**: P0

**前置**: 员工已有 ACTIVE token T1

**步骤**:
1. 再次 `POST /api/v1/internal-apps/tokens`

**断言**:
- 同事务内：T1 `status='REVOKED', revoked_reason='rotated'`，T2 `status='ACTIVE'`
- 部分唯一索引 `uq_tokens_active_per_employee` 不触发冲突（说明事务原子完成）
- 旧 T1 立即用作 MCP 调用 → 返回 `revoked_token`

---

### 场景 1.3: 90 天到期 + 7 天 warning

**优先级**: P0

**步骤**（用 SQL 把 `issued_at` / `expires_at` 改造造已过 83 天的 token）:
1. 任意 MCP 工具调用，token 哈希匹配

**断言**:
- 返回 `ok: true` + `warning='token 还有 7 天过期，去 https://ffworkspace.faradayfuture.com/internal-apps 续期'`
- 把 `issued_at` 改造为 91 天前 → 返回 `expired_token`，附 `onboardUrl`

---

### 场景 1.4: 显式撤销 + 强确认

**优先级**: P0

**步骤**:
1. `POST /api/v1/internal-apps/tokens/revoke`，body `{ confirmText: 'WRONG' }`
2. 同上，body `{ confirmText: 'REVOKE' }`

**断言**:
- 步骤 1 返回 400 `invalid_confirm_text`，token 仍 ACTIVE
- 步骤 2 返回 200，token 转 REVOKED，`revoked_reason='self'`
- audit `internal_app.token.revoked` 写入

---

### 场景 1.5: Entra ID disable 联动

**优先级**: P0

**前置**: 员工有 ACTIVE token + 2 个 HEALTHY app

**步骤**: 模拟 Entra webhook `employee.disabled`

**断言**:
- 该员工所有 token → `DISABLED, revoked_reason='entra_disabled'`
- app 容器**继续运行**（30 天 grace）
- 用旧 token 调任意 MCP 工具 → `disabled_token`
- audit `internal_app.token.disabled` 写入
- 30 天后 TTL Sweeper：app `status='DISABLED_ARCHIVED'`，Gitea 仓库 transfer 至 `FFAIApps-Archive`

---

### 场景 1.6: mailNickname 变更 → employeeSlug 不漂移

**优先级**: P0

**前置**: 员工 `employee_slug='zhang-san'`，已绑定

**步骤**: Entra ID 把 `mailNickname` 改为 `Zhang-San-2`，员工再次 `POST /tokens`

**断言**:
- `employee_slug_bindings` 该行**未更新** `employee_slug`
- `source_mail_nickname` 保持原值（仅审计追溯，不参与逻辑）
- 新 token 关联仍是 `'zhang-san'`，URL / Gitea 仓库名稳定

---

## 2. 集成测试 - deploy_prepare（L1）

### 场景 2.1: 首次部署成功路径

**前置**: 有效 token，员工首次部署

**入参**:
```json
{
  "appSlug": "birthday-reminder",
  "detected": { "hasPackageJson": true, "hasStartScript": true, "hasIndexHtml": false }
}
```

**断言**:
- 返回 `isFirstDeploy: true`，`runtime: 'node'`
- Gitea 在 `FFAIApps/zhang-san-birthday-reminder` 建仓成功（mock Gitea API 或测试用 Gitea 实例）
- `internal_apps` 新增行，`status='PENDING'`，`gitea_repo_full_name` 写入
- `pushCredential.expiresAt = now + 5min`（±10 秒）
- audit `internal_app.app.created` 写入

---

### 场景 2.2: `unsupported_runtime` 各分支

**优先级**: P0

| sub | detected | 期望 hint 关键词 |
|----|----------|---------------|
| 2.2a | 全 false | "需要 package.json + start script 或 index.html" |
| 2.2b | `hasDockerfile=true`，其他 false | "检测到 Dockerfile，但平台不支持自定义镜像" |
| 2.2c | `hasRequirementsTxt=true`，其他 false | "Python，等待 V2" |
| 2.2d | `hasGoMod=true`，其他 false | "Go，等待 V2" |
| 2.2e | `hasPackageJson=true, hasStartScript=false` | "package.json 缺 start script，加一条 `\"start\": \"node index.js\"` 即可" |

**断言**:
- 返回 `error.code='unsupported_runtime'` + `details.hint` 包含期望关键词
- `internal_apps` **不**新增行（建仓也不能发生）

---

### 场景 2.3: slug 校验

| sub | appSlug 入参 | 期望 |
|----|-------------|------|
| 2.3a | `admin` | `reserved_slug`，附 `reservedList` |
| 2.3b | `Birthday-Reminder!` | `invalid_slug` |
| 2.3c | `ab` (太短) | `invalid_slug` |
| 2.3d | 23 字符全合法 | `invalid_slug`（超 22 上限） |
| 2.3e | `-birthday` (首字符 `-`) | `invalid_slug` |
| 2.3f | `birthday-reminder` （另一员工已占用） | `app_not_owned` |

---

### 场景 2.4: 增量部署复用仓库

**前置**: app 已存在且 HEALTHY，同 owner

**入参**: 同首次 `deploy_prepare`

**断言**:
- 返回 `isFirstDeploy: false`
- Gitea API **不再被调用建仓**（mock 校验调用次数）
- 新颁发 push 凭据，旧凭据失效（5min TTL 各自独立）

---

### 场景 2.5: app 配额上限

**前置**: 员工已有 20 个 ACTIVE app（未 destroy）

**步骤**: 第 21 个 `deploy_prepare`

**断言**:
- 返回 `app_quota_exceeded`
- `internal_apps` 不新增

---

## 3. 集成测试 - env 操作（L1）

### 场景 3.1: set 触发滚动重启

**前置**: app HEALTHY，无任何 env

**步骤**: `env` set `OPENAI_API_KEY=sk-xxx`

**断言**:
- `app_env_vars` 新增加密行，`value_encrypted` ≠ 明文
- 解密返回值 = `sk-xxx`
- 新增 `deployments` 行，`trigger='env_change'`，复用现镜像
- 健康检查通过后新容器接流量，旧容器停止
- audit `internal_app.app.env_changed`，载荷**不含 value 明文**

---

### 场景 3.2: env 校验失败

| sub | key | value | 期望 |
|----|-----|-------|------|
| 3.2a | `lowercase` | `x` | `invalid_env_key` |
| 3.2b | `FFOA_HACK` | `x` | `reserved_env_prefix` |
| 3.2c | `PLATFORM_FOO` | `x` | `reserved_env_prefix` |
| 3.2d | `OK_KEY` | `'x'.repeat(5000)` | `env_value_too_large` |

---

### 场景 3.3: list 不泄漏明文

**步骤**: set `SECRET=abcdef123` → 调 `env list`

**断言**:
- 返回 `valuePreview` 形如 `'abcd****'` 或 `'****'`（前 4 字符 + 掩码），绝不含完整明文

---

### 场景 3.4: get / unset

- `env get` 返回明文（仅 owner）
- `env unset` 删行 + 触发滚动重启，行为同 `set`

---

## 4. 集成测试 - destroy + retention（L1）

### 场景 4.1: 员工 destroy

**步骤**: `destroy` confirm=true

**断言**:
- app `status='DESTROYED'`，`destroyed_at=now`，`retention_until=now+30d`
- 容器停 + Caddy 路由移除
- Gitea 仓库归档（保留代码）
- audit `internal_app.app.destroyed`，`byAdmin: false`

### 场景 4.2: TTL Sweeper 推到 PURGED

**前置**: app `retention_until = now - 1h`

**步骤**: 跑 TTL Sweeper job

**断言**:
- app `status='PURGED'`
- 对象存储该 app 备份对象被删
- audit `internal_app.app.purged`

### 场景 4.3: destroy 后期间内不可操作

**步骤**: 已 DESTROYED 的 app 调 `deploy_prepare` / `logs` / `env`

**断言**: 全部返回 `app_destroyed` (HTTP 410)

---

## 5. 集成测试 - 管理端 + Webhook（L1）

### 场景 5.1: IT-Admin 强制停用

**前置**: 非 owner 用户但有 `internal-app:admin` 权限

**步骤**: `POST /admin/apps/:id/disable` body `{ reason: 'violation' }`

**断言**:
- app `status='DISABLED'`，`force_disabled_*` 三字段同时写入
- 容器停 + Caddy 503 友好页
- 邮件通知 owner（mock 邮件服务校验调用）
- audit `internal_app.app.force_disabled`

### 场景 5.2: 普通员工调管理端 → 403

**步骤**: 无 `internal-app:admin` 权限的员工调 `GET /admin/apps`

**断言**: 403 forbidden

### 场景 5.3: Gitea webhook 签名校验

| sub | X-Gitea-Signature | 期望 |
|----|-------------------|------|
| 5.3a | 正确 HMAC | 201 + `deploymentId` |
| 5.3b | 错误 HMAC | 401 `invalid_signature` |
| 5.3c | 缺 header | 401 |

### 场景 5.4: webhook push 到非 main 分支 → 忽略

**步骤**: webhook payload `ref='refs/heads/dev'`

**断言**: 返回 200 `{ ignored: true }`，不创建 deployment 行

---

## 6. MCP E2E（L2）- FFOA "我的 Apps" 页

> 范围：唯一前端页，AI + Playwright MCP 执行；不写组件测试代码（CLAUDE.md §测试）

### E2E 1: 首次进入页面 → 颁发 token → 复制到剪贴板

**前置**: 员工 Entra ID 已登录态，未生成过 token

**MCP 步骤**（Playwright accessibility tree 定位）:
1. 打开 `https://ffworkspace.faradayfuture.com/internal-apps`
2. 验证页面渲染区块 A "尚未生成 token"
3. 点击按钮 "生成新 token"
4. 验证：toast "已复制"、卡片切到 "有效·本次生成"、token 明文**不在 DOM 任何位置**（用 page.content() 全文搜索 `ffoa_` 前缀）
5. 验证剪贴板内容 = 完整 `claude mcp add --transport http ffoa-apps <endpoint> --header "Authorization: Bearer ffoa_xxx..."`
6. 等待 5 分钟（或 mock 时钟），验证内存中明文被清空（再次点 "复制" 应失败或重新生成提示）

**i18n**: 切换 zh-CN ↔ en-US，文案不硬编码，关键按钮文本两语言均合理。

### E2E 2: 撤销 token + 强确认

**MCP 步骤**:
1. 已有 ACTIVE token 页面
2. 点 "撤销 token"
3. 弹框要求输入 "REVOKE"
4. 输错 "REVOK" → 提示错误，token 仍 ACTIVE
5. 正确输入 "REVOKE" → toast 成功，卡片切回 "尚未生成 token"

### E2E 3: 查看自己的 app 列表

**前置**: 员工已有 1 个 HEALTHY app + 1 个 DESTROYED 在 30 天恢复期内

**MCP 步骤**:
1. 进入页面
2. 默认 tab 不显示 DESTROYED
3. 切到 "包含已销毁" → DESTROYED app 出现，附 `retention_until` 倒计时

### E2E 4: token 即将过期 banner

**前置**: token `expires_at = now + 5d`

**步骤**: 进入页面 → 顶部 banner "token 还有 5 天过期，建议续期"，附 "立即重新生成" 按钮直链

---

## 7. Phase 0 PoC 端到端验收（L3 人工）

> PRD §实施阶段 / §成功指标的硬验收锚点。**Phase 0 不通过则 Phase 1 不启动**。

### 7.1 PoC 前置（运维 + 法务）

- [ ] 公网域名 `apps.ffworkspace.faradayfuture.com` DNS + 公网 wildcard TLS 已就绪
- [x] PoC 员工 = HR 团队（具体人选由 HR 内部分配，2026-05-13 已锁定方向）
- [x] PoC 真实需求 = **生日提醒**小工具（同事生日 + 弹窗提醒）
- [x] 法务认可 = 直属领导已批准（2026-05-13）
- [x] 对象存储 = MinIO 自建 docker（桶 `internal-apps-backups`）
- [x] KMS = 优先复用 `platform_iam` 现有客户端，无则降级 env master key
- [x] **Claude Code 远程 MCP 公网可达性验证通过**（2026-05-13 lijian 实测）—— `curl -v https://ffworkspace.faradayfuture.com/api/v1/health` 返回 HTTP/2 200 + TLS 1.3 + Let's Encrypt E8 证书未被中间代理篡改 + 1ms backend 响应；Streamable HTTP MCP 走同链路无阻
- [x] **MCP 协议合规真客户端握手验证通过**（2026-05-18 lijian 实测）—— `claude mcp add --transport http ffoa-apps <url> --header "Authorization: Bearer ffoa_..."` + `claude mcp list` 显示 `✓ Connected`。**curl 看响应字段不算数**——必须用真实 Claude Code CLI 跑过完整 `initialize` 握手 + `tools/list` + `tools/call` 才算 PoC 通过。详见 [`.learnings/2026-05-18-mcp-controller-not-jsonrpc-compliant.md`](../../../.learnings/2026-05-18-mcp-controller-not-jsonrpc-compliant.md)
- [x] **Gitea `git push` 在员工电脑可达性验证通过**（2026-05-13 lijian 实测）—— Gitea API 走 HTTP 直连 TCP 0.8ms + 总 37ms；日常 git remote 走 SSH `ssh://git@43.130.59.228:2222` 长期稳定；HR 居家场景由 VPN 兜底

### 7.2 PoC 主流程验收

**计时起点**: 员工首次打开 Claude Code
**计时终点**: 同事浏览器看到工具页面

**步骤**:

| # | 员工动作 | 期望结果 | 时间预算 |
|---|---------|---------|---------|
| 1 | 打开 FFOA "我的 Apps" 页 → 生成 token → 在 shell 终端粘贴 `claude mcp add --transport http ...` 命令 | `claude mcp list` 看到 ffoa-apps；启动 Claude Code 下一次对话即生效 | 2 min |
| 2 | 对 Claude Code 说 "帮我做一个生日提醒工具，能输入同事姓名和生日，到点弹窗" | Claude Code 生成代码（package.json + index.js）| 10-15 min |
| 3a | 员工说 "部署一下" | Claude Code 调 MCP → 收到 `pushCredential` → 跑 `git ls-remote` 探测 Gitea 连通性 | < 30 sec |
| 3b | 居家场景：Gitea 不通 → Claude Code 提示 "请先连公司 VPN，连上后说一声'继续部署'" | 员工连 VPN，说"继续部署" | 1-2 min |
| 3c | git push 成功 → 构建 → 健康检查通过 → 返回 URL | Claude Code 说 "好了，发给同事就能用" | 3 min |
| 4 | 同事打开 URL（已登录 FFOA Entra SSO） | 看到生日工具页面 | 1 min |
| 5 | 员工添加数据 → 重启服务器（运维触发） | 数据未丢 | — |
| 6 | 员工说 "改一下，加生日卡片功能" → "再部署一下" | 新版本上线，旧数据保留 | 3 min |

**硬验收点**:
- [ ] 计时 ≤ **30 分钟**（PRD §成功指标）
- [ ] 全过程**零工程师介入**——员工没问开发同事任何问题
- [ ] 步骤 5 重启后数据持久化
- [ ] 步骤 6 增量部署不丢数据 + 不丢 env

### 7.3 PoC 异常场景验收

| 场景 | 期望 |
|------|------|
| 员工居家忘连 VPN 直接说"部署" | Claude Code `git ls-remote` 探测失败 → 友好提示 "Gitea 还连不上，居家请先连公司 VPN，连上后说'继续部署'" → 员工连 VPN 后接着说 → 重试成功 |
| 员工 Claude Code 生成了带 Dockerfile 的项目 | MCP 返回 `unsupported_runtime` + Claude Code 主动说 "我看到有 Dockerfile，平台不支持，要不要改用 Node 重写？" 并自动改写 |
| 员工 app `node_modules` 巨大 | Layer 1 自动 ignore + Layer 2 友好告知一句，员工不感知 Layer 3 |
| 员工首次部署，目录无 `.gitignore` | Claude Code 自动注入默认模板，员工对话里完全没看到 `.gitignore` 这个词 |
| 同事未登录 FFOA 直接访问 app URL | Caddy 跳 Entra SSO 登录页，登录后自动回跳；员工不需要解释 |
| 构建失败（语法错误等）| MCP 返回 `build_failed` + 日志摘要，Claude Code 主动说 "构建失败了，是因为 X，我帮你改" + 重试一次 |

### 7.4 PoC 退出条件

**通过**: 7.2 主流程全过 + 7.3 异常全过 → 进 Phase 1 MVP

**不通过**:
- 计时超 30 分钟 → 分析瓶颈，可能架构需重审
- MCP 网络不可达 → 重新评估整体方案（不引入本地 npx 包降级，见 PRD §假设）
- 员工 UX 体验差（"我不会用"）→ 重审 Claude Code prompt + Layer 2 文案

---

## 8. 测试基础设施

### 8.1 测试数据隔离（CLAUDE.md §测试）

- 集成测试创建资源用随机后缀：`appSlug = \`test-${Date.now()}-${randSuffix}\``
- cleanup 用前缀过滤：`DELETE FROM internal_apps WHERE app_slug LIKE 'test-%'`
- 种子数据：本模块无（权限码在 `platform_iam` seed 注册，那里覆盖）

### 8.2 外部依赖 mock 策略

| 依赖 | 集成测试做法 |
|-----|------------|
| Gitea | 用测试用 Gitea 实例（docker-compose），不 mock—— 验证 REST API 真实兼容性 |
| Azure Entra ID | mock session middleware，注入测试用户 |
| KMS | 测试 env 配本地 dev KMS key，不调云 |
| 对象存储 | MinIO 测试容器 |
| Docker daemon | **不在 L1 跑真容器**——把"启容器"步骤抽象成 service 接口，L1 mock 该接口，真实容器在 Phase 0 PoC 验 |
| Litestream | 同 Docker |

### 8.3 测试文件位置

- L1：`testing/backend/integration/internal-app-platform/`
  - `tokens.spec.ts` / `deploy.spec.ts` / `env.spec.ts` / `destroy.spec.ts` / `admin.spec.ts` / `webhook.spec.ts`
- L2 不写测试代码（AI + MCP 执行，报告落 `testing/reports/e2e/`）
- L3 PoC 报告落 `testing/reports/poc/2026-xx-xx-phase0.md`

---

## 9. 验收门禁

| 阶段 | 门禁 |
|------|------|
| 提 PR | 本模块涉及 API 变更 → 同步更新 `testing/backend/integration/internal-app-platform/` 对应 spec；CI 跑 L0a/L0b/L0c |
| 合 develop | L1 spec 全绿 |
| 合 staging | L2 MCP E2E 至少跑 E2E 1 + E2E 2（token 颁发 + 撤销，最高频路径）|
| 合 production | Phase 0 PoC §7 全过；Phase 1 MVP 满足 PRD §成功指标 ≥ 3 名员工各自部署 |

---

## 10. 待定项

- [ ] L1 测试用 Gitea 实例是否复用 dev `43.130.59.228` 还是单独起 testing-Gitea —— 推荐单独起（避免测试污染生产仓库列表）
- [ ] Litestream 备份恢复的 L1 测试用例（如何 mock S3 时间偏移）—— Phase 0 实测优先，L1 暂跳过
- [ ] Phase 0 PoC 的真实员工选定 —— PRD §待定项已记录

---

## 11. 相关文档

- [01-prd.md](./01-prd.md) — 成功指标 / Phase 0 退出条件
- [03-architecture.md](./03-architecture.md) — 流程图（场景与 §4.x 流程一一对应）
- [06-data-model.md](./06-data-model.md) — schema 字段（断言依据）
- [07-api.md](./07-api.md) — 错误码 / 权限码 / 审计事件清单
- [docs/standards/05-development-workflow.md](../../standards/05-development-workflow.md) — L0/L1/L2/L3 测试金字塔
