风控测试不能只断言“某个分数得到某个 Level”。生产门禁必须证明信号可靠、挑战真的阻断、Ticket 不可盗用、证据不会越租户或过度泄露,并且故障策略符合业务风险等级。
1. 当前已有证据
| 测试集 | 已覆盖 | 明显缺口 |
|---|---|---|
RuleBasedRiskEngineTests | 租户策略生效、因子异常稳定失败 | 四个真实因子组合、并行与窗口边界 |
RiskPolicyConfigTests | 区间完整、无重叠、快照映射 | 管理 Endpoint、并发更新、审批 |
SettingsRiskPolicyProviderTests | 读取/配置失败回退、取消传播 | 缓存失效、多实例传播、fallback 告警 |
RiskAssessmentTests | AOT JSON、深快照、损坏数据拒绝 | 数据库约束、保留、脱敏 |
RiskAssessmentPersistencePipelineBehaviorTests | 业务失败/异常后记录、持久化失败隔离 | 真实 Pipeline + 双 ORM、提交不确定性 |
GetRiskAssessmentsHandlerTests | 当前租户 Fake Store、过滤分页 | 两个 ORM、Host 模拟、真实授权 |
LoginRiskIntegrationTests | 五种 Challenge、Captcha 错答、Engine fail-open | 真实 Provider / Redis / HTTP |
| Captcha Provider / Filter | 未发现专门测试 | 生成、重放、容差、厂商协议、真实挂载 |
Fake Captcha 可以证明 LoginFlow 检查 Passed,不能证明 Redis GETDEL、Challenge 绑定或 Provider 实现正确。
2. 规则边界表
每个边界都测试下界、上界和相邻值:
| 分数 | 期望 |
|---|---|
| 0、30 | Low / None |
| 31、50 | Medium / Captcha |
| 51、75 | High / MFA |
| 76、90 | Critical / StepUp |
| 91、100 | Block |
| -1、100.01 | 创建拒绝 |
还要遍历四因子的 16 种命中组合,证明总分、Factor Code、Evidence 与 Challenge 一致,并明确默认 Block 不可达。
3. Captcha 安全合同
[Theory][InlineData(CaptchaType.ImageCode)][InlineData(CaptchaType.Slider)][InlineData(CaptchaType.PointSelect)]public async Task Ticket_Should_Be_Consumed_Atomically(CaptchaType type){ var provider = fixture.Resolve(type); var challenge = await fixture.GenerateKnownAnswerAsync(provider, "tenant-a", "session-a");
// 第一次正确作答成功,并通过 Redis GETDEL 原子消费。 var first = await provider.VerifyAsync( new CaptchaVerifyRequest(challenge.Id, challenge.Answer), CancellationToken.None); first.IsSuccess.Should().BeTrue(); first.Value!.Passed.Should().BeTrue();
// 相同 Ticket 无论答案是否正确都不能再次通过。 var replay = await provider.VerifyAsync( new CaptchaVerifyRequest(challenge.Id, challenge.Answer), CancellationToken.None); replay.IsSuccess.Should().BeTrue(); replay.Value!.Passed.Should().BeFalse();}Provider 使用随机数,测试需要注入可控 Challenge Factory 或解析 RenderData,不能把生产随机逻辑替换为 Fake 后仍称为 Provider 测试。
4. 上下文盗用测试
至少验证:
- tenant-a / session-a 的 Ticket 不能用于 tenant-b;
- 同租户 session-a 不能用于 session-b;
- Login Ticket 不能用于 password-reset / contact;
- 前缀不匹配时不调用 GETDEL,合法持有者仍能使用;
- ChallengeId 超长、控制字符、异常分隔段拒绝;
- 用户名大小写规范与 pseudo-session 计算一致。
[Fact]public async Task Filter_Should_Reject_Successful_Result_With_Failed_Verification(){ provider.VerifyAsync(Arg.Any<CaptchaVerifyRequest>(), Arg.Any<CancellationToken>()) .Returns(Result.Success(new CaptchaVerification(false, "mismatch")));
// Filter 当前会错误调用 next;修复后应返回 400 且业务处理器调用次数为 0。 // 通过真实映射的 Endpoint 发起请求,同时证明 Filter 已正确挂载。 var response = await client.PostAsJsonAsync( "/api/protected-form", new { captchaChallengeId = "captcha:tenant-a:session-a:n1", captchaAnswer = "wrong" });
response.StatusCode.Should().Be(HttpStatusCode.BadRequest); protectedHandler.CallCount.Should().Be(0);}5. 行为 Provider 合同
用本地 Stub HTTP Server 覆盖:
- Aliyun / Tencent 成功、拒绝、损坏 JSON;
- 非 2xx、连接失败、DNS、超时和调用方取消;
- Provider 非法配置启动失败,而不是静默走 Aliyun;
- Secret、Ticket、Randstr 不出现在日志、Trace URL 或 Error Description;
- Tencent 使用客户端 Randstr 与真实 UserIp;
- 响应体大小、Content-Type 和重定向限制;
- SDK URL 只允许 HTTPS 白名单。
外部失败应拒绝当前挑战,但是否自动降级 ImageCode 必须由应用显式发起新 Challenge;不能把已消费的 Behavior Ticket 当作 ImageCode Ticket。
6. 双 ORM 与租户合同
真实数据库插入 tenant-a / tenant-b 同用户名、同 Assessment 时间,再对 EF Core 与 SqlSugar 运行同一合同:
- 普通 tenant-a 查询只返回 a;
- Level/User/Time Predicate 与分页下推;
- 软删除默认不可见;
- 损坏 FactorsJson 返回稳定 Failure;
- 正式租户模拟使用 Effective Tenant,而非操作人 CurrentUser Tenant;
- Host 全局分析走独立权限与审计端口。
7. 故障注入
| 故障 | 当前预期 | 生产告警 |
|---|---|---|
| LoginHistory 查询异常 | Engine Failure,Login 无挑战继续 | engine_failure + tenant |
| 策略读取/解析异常 | 默认策略 | policy_fallback + reason |
| 单个 Factor 异常 | 整次 Engine Failure | factor name + latency |
| Redis 缺失 | 生成成功、所有验证失败 | startup/readiness red |
| Redis GETDEL 异常 | Provider 抛/Failure,请求拒绝 | ticket_store_error |
| Captcha 厂商超时 | Behavior Failure,请求拒绝 | provider + timeout |
| RiskAssessment Commit 失败 | 登录结果不变、证据丢失 | persistence_failure critical |
| Query 快照损坏 | API Failure | invalid_snapshot + assessment id |
8. 指标与日志
建议低基数指标:
risk_assessment_total{level,challenge,source};risk_engine_failure_total{stage};risk_policy_fallback_total{reason};captcha_generated_total{type,provider};captcha_verified_total{type,outcome};captcha_ticket_store_failure_total{operation};risk_assessment_persistence_failure_total;- 评估、因子、Provider 的 p50/p95/p99 延迟。
TenantId、UserId、IP、ChallengeId 不放进 Metric Label;它们只在受控结构化日志中以必要、脱敏形式出现。不要记录答案、Ticket 存储值、厂商 Secret 或完整 DeviceInfo。
9. 排障路径
10. 发布前演练
- 关闭 Redis,确认 Readiness 阻止安全入口接流量;
- 注入策略非法 JSON,确认 fallback 告警和严格租户处置;
- 让 Behavior Provider 超时,确认无 Secret 泄露且请求拒绝;
- 模拟 RiskAssessment 数据库不可用,确认告警与补偿;
- 从 tenant-b 使用 tenant-a Challenge,确认不消费合法 Ticket;
- 用错误答案访问挂 Filter Endpoint,确认业务处理器未执行;
- 以 Host operate-as 查询目标租户历史,确认权限、数据面和审计一致。
11. GA 门禁
- Captcha Provider 和 Filter 的真实测试补齐并接入 CI;
- 所有保护 Endpoint 都有挂载清单与反射/生成式架构测试;
- Challenge 与 tenant/session/scenario 绑定,错误上下文不消费 Ticket;
- 行为 Provider Secret 不入 URL,配置和协议符合厂商合同;
- Engine/Policy/Persistence 的不同降级有指标、告警和租户级策略;
- 双 ORM、租户模拟、敏感 Evidence 脱敏与保留通过集成测试;
- 默认四因子可达性与 Block 策略在运营界面清晰展示。