Files 的验收不能停在“能上传和下载一个小文件”。真正的交付证据必须跨客户端、API、数据库、对象存储和 JobHost,证明非可信字节不会提前变成业务资产,租户与 Owner 不会越界,失败后两个事实源能够收敛。
1. 当前已有测试证据
| 测试 | 已证明 | 尚未证明 |
|---|---|---|
FileAssetLifecycleCommandHandlerTests | 服务端 Key、元数据 fail-closed、保存后发布、软删除 | 并发、事务、真实 S3、扫描 |
FileAssetFinalizeTests | Uploaded→Finalized、重复冲突、绑定红线 | Handler 的 Provider 回退风险 |
FileAssetPolicyTests | 租户/Owner/提升权限/非 Finalized | ReadModel 真正跨租户隔离、Catalog 可分配性 |
FileDownloadAccessQueryHandlerTests | 签发、五分钟响应、Provider fail-closed | URL 真实 TTL、重放、撤权 |
LocalFileStorageTests | 直接读写、删除、路径穿越、presign 不支持 | 当前 HTTP 闭环 |
ReadModelStoreParityTests | SqlSugar/EF Core 摘要一致 | 命令并发与软删除外部合同 |
FilesInfrastructureArchitectureTests | 统一聚合、读模型端口、无旧 1:1 模型 | 生产运行时就绪 |
当前没有 S3-compatible 行为契约、孤儿清理回归测试、HTTP 端到端文件测试、恶意文件测试、配额测试或负载测试。
2. 单元状态矩阵
状态测试应覆盖每个来源、动作和错误码:
[Theory][InlineData("PendingUpload", "mark", true, "Uploaded")][InlineData("PendingUpload", "finalize", false, "PendingUpload")][InlineData("Uploaded", "finalize", true, "Finalized")][InlineData("Finalized", "finalize", false, "Finalized")][InlineData("Finalized", "delete", true, "Deleted")][InlineData("Deleted", "delete", false, "Deleted")]public void State_transition_should_be_explicit( string from, string action, bool expectedSuccess, string expectedState){ // 每一行从显式状态开始,禁止 Fixture 预先执行隐藏迁移。 var state = new FileAssetState(FileAssetStatus.FromName(from));
// switch 强制新增动作进入测试决策,避免默认成功或静默忽略。 var result = action switch { "mark" => state.MarkUploaded(), "finalize" => state.Finalize(), "delete" => state.Delete(), _ => throw new ArgumentOutOfRangeException(nameof(action)) };
result.IsSuccess.ShouldBe(expectedSuccess); state.Status.ShouldBe(FileAssetStatus.FromName(expectedState));}还要单独测试聚合允许 Pending 在一次 Finalize 中推进两步,避免把 State 类型与 Aggregate 类型的合同混为一谈。
3. 元数据与攻击输入
至少覆盖:
- Size 为 0、负数、1、最大允许值和溢出边界;
- 36/37 字符 FileId、50/51 OwnerType、255/256 FileName;
- 空、大小写差异、参数化与伪造 ContentType;
- SHA-256 空值、非十六进制、错误长度、大小写和不同内容同声明;
- ETag 为 Multipart 格式;
../、CRLF、双引号、Unicode 组合字符与双向文本文件名;- StorageKey 跨租户前缀和相似前缀
tenant-a2; - 0 字节对象、上传中 HEAD、最终一致性延迟和对象被覆盖。
当前聚合不验证哈希格式,也不设置平台最大 Size;测试应先固定现状,再用目标测试驱动合同升级。
4. Provider 契约套件
同一组测试必须运行在 Local、S3-compatible 和每个 Connector Provider 上:
public abstract class FileStorageContract{ // 派生类只替换 Provider 构造,行为断言保持完全一致。 protected abstract IFileStorage CreateStorage();
[Fact] public async Task Upload_metadata_read_and_delete_should_converge() { // 每次生成租户范围 Key,测试结束不与其他并发用例共享对象。 var storage = CreateStorage(); var key = $"tenants/contract/{Guid.CreateVersion7():N}"; var bytes = "contract-body"u8.ToArray();
await storage.UploadAsync( key, new MemoryStream(bytes), "text/plain", CancellationToken.None);
var metadata = await storage.GetMetadataAsync(key, CancellationToken.None); metadata.ContentLength.ShouldBe(bytes.Length);
await storage.DeleteAsync(key, CancellationToken.None); (await storage.ExistsAsync(key, CancellationToken.None)).ShouldBeFalse(); }}Presign 契约还要通过真实 HTTP PUT/GET 验证:过期时间、Content-Type、下载名、范围请求、404、撤销权限和对象覆盖。只断言 URL 非空没有意义。
5. 双事实源故障注入
| 注入点 | 期望数据库状态 | 期望对象状态 | 必须有的恢复证据 |
|---|---|---|---|
| 签发后 Save 失败 | 无 FileAsset | Key 可能可写 | Inventory 清理 |
| PUT 中途断线 | Pending | 缺失或部分对象 | 不可 Finalized;过期清理 |
| HEAD 暂时超时 | Pending | 存在 | 安全重试,不改变状态 |
| Metadata 不匹配 | Pending | 存在 | 隔离/删除与审计 |
| Finalize Save 死锁 | Pending | 存在 | 同 FileId 重试 |
| Outbox 发布失败 | 取决于事务 | 存在 | 重启后恢复,不重复消费 |
| Delete 后 Storage 失败 | Deleted | 存在 | Purge 重试队列 |
| JobHost 崩溃 | 不丢 Checkpoint | 部分已删 | 幂等续跑 |
现有实现没有显式 Purge 状态或 Checkpoint;这些目标测试会暴露需要新增的工作流合同。
6. 租户与 Owner HTTP 测试
构造 Tenant A/B、User A1/A2、App A 和 Operator:
- A1 创建的 private 文件只能由 A1 或获准 Operator 下载;
- A2 不能因拥有普通 View 就绕过 Owner Policy;
- App Owner 只匹配 ClientId,不匹配 UserId;
- Tenant A 的
public不能由 Tenant B 下载; - 伪造 FileId 不能通过错误差异探测跨租户存在性;
- 未 Finalized 文件不能下载或绑定;
- Delete 权限边界与 Owner 策略必须由产品明确;
- operate-as 分开记录 Actor Tenant 与 Effective Tenant。
路由测试必须从治理 Permission Catalog 建角色,不能在测试里直接塞入实现硬编码但目录不存在的权限。
7. 并发与幂等测试
使用真实数据库和同一对象并发执行:
- 两个相同会话创建请求的业务幂等策略;
- 两个 finalize 是否只有一个状态提交和一个事件;
- finalize 与 delete 竞争的唯一合法结果;
- download 与 delete 同时发生时已签发 URL 的语义;
- 清理与 finalize 竞争,尤其 PendingUpload 当前立即入选缺陷;
- 多 JobHost 实例是否重复清理同一 Key;
- Provider Delete 的重复调用是否成功收敛。
每个测试都断言数据库、对象、事件和审计四个面,不只断言 HTTP 状态码。
8. 性能与容量
重点不是 API 传输带宽,而是控制面和对象存储限额:
- 会话创建 P50/P95/P99;
- S3 HEAD 与 presign 延迟;
- 每租户并发上传和未完成会话数;
- Bucket 对象数、总字节与 Inventory 扫描成本;
- finalize 事务冲突率;
- 下载签发率与对象 GET 出站带宽;
- 清理每批对象数、API 限速与执行窗口;
- Event/Outbox 积压。
负载测试不要上传客户数据。使用可生成、可校验哈希、可批量销毁的测试对象,并为测试 Bucket 设置独立生命周期。
9. 指标
| 指标 | 用途 |
|---|---|
files_upload_sessions_total{result} | 会话成功/拒绝/Provider 失败 |
files_pending_assets | 未完成积压 |
files_session_age_seconds | 最老 Pending 年龄 |
files_finalize_total{result,reason} | 完成率与不匹配原因 |
files_metadata_verification_seconds{provider} | HEAD/metadata 延迟 |
files_download_grants_total{result} | 签发与拒绝 |
files_assets_bytes{state} | 状态容量 |
files_cleanup_total{reason,result} | 会话/删除/Inventory 清理 |
files_orphan_objects | 对象有、元数据无 |
files_missing_objects | 元数据有、对象无 |
TenantId、UserId、FileId、StorageKey 和 FileName 不能作为低基数指标标签。诊断用 Hash 或 Trace 属性,并设置访问控制与保留期。
10. SLO 与告警
示例内部 SLO 需要按部署容量校准:
- 可用 Provider 下,会话签发成功率与 P95 延迟;
- PUT 成功后的 finalize 成功预算;
- Pending 超过 URL TTL + 宽限的数量;
- Finalized 但对象 NotFound 必须为零;
- Deleted 超过保留期但对象仍存在的数量;
- 清理连续失败次数、最老积压和删除速率;
- S3 Readiness、时钟偏差与凭据到期;
- metadata mismatch 或跨租户拒绝突增。
单次用户误传不要告警;系统性 mismatch、清理停摆和跨租户尝试需要告警。
11. 排障路径
不要通过数据库直接把 Status 改成 Finalized。这样会绕过 metadata 不变量、FinalizedAt、事件与安全扫描目标。
12. 生产演练
每次重大升级至少执行:
- 真实浏览器跨域上传并完成;
- URL 过期前后 PUT/GET;
- 大小、类型、哈希不匹配;
- API 保存失败后的对象 Inventory;
- finalize 与清理并发;
- 删除后对象 Purge;
- S3 凭据轮换与最小权限;
- API/JobHost Provider 配置漂移;
- Bucket 不可用、慢响应与限速;
- 从备份恢复数据库后对账对象;
- Tenant-public 与跨租户攻击;
- 恶意样本使用专用安全测试环境验证隔离。
13. GA 阻断门禁
- 上传准入含大小上限、租户配额、OwnerType/Visibility 目录和 MIME 策略;
- 预签名上传约束大小、类型和可信 checksum;
- Finalize 检查 UploadExpiresAt,服务端计算/验证哈希;
- 扫描/隔离状态机落地,扫描前不可绑定或下载;
- Create/Finalize/Delete 的幂等和并发合同完成;
- Catalog、普通授权、Owner Policy 与 operate-as 一致;
- 删除对象、失败重试、Tombstone 与保留策略形成闭环;
- 修复 PendingUpload 立即清理和 Deleted 不清理缺陷;
- API 与 JobHost Provider、Bucket、Key、凭据权限一致;
- S3 与所有 Connector Provider 通过统一契约;
- 下载响应不泄露 StorageKey,真实 TTL 与 DTO 一致;
- 指标、告警、Inventory 对账、Runbook 与值班责任就绪;
- 双 ORM、HTTP、安全、故障注入、并发和容量测试通过;
- 文档中的现状、受限能力和目标能力与源码一致。
14. 本地校验
# 运行 Files 相关单元、应用、集成与架构测试。dotnet test tests/BitzOrcas.Unit.Tests --filter FullyQualifiedName~FileAssetdotnet test tests/BitzOrcas.Application.Tests --filter FullyQualifiedName~Filedotnet test tests/BitzOrcas.Integration.Tests \ --filter "FullyQualifiedName~FileAsset|FullyQualifiedName~LocalFileStorage"dotnet test tests/BitzOrcas.Architecture.Tests --filter FullyQualifiedName~Files
# 全局暴露当前生产缺口;扫描/配额/删除工作流修复前预期无关键实现命中。rg -n "IFileScanner|UploadQuota|DeleteRequested|Purged|UploadSessionExpired" \ src tests -g '*.cs'
# 核对清理 SQL,Pending 分支修复后应包含时间条件,Deleted 应有独立生命周期路径。rg -n "Status.*PendingUpload|Status.*Deleted|UploadExpiresAt|CreateTime.*cutoff" \ src/Framework/BitzOrcas.Infrastructure.SqlSugar/DataLifecycle -g '*.cs'