using System.Net; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using WebDrop.Core; using WebDrop.FileApi; using WebDrop.Storage; using static WebDrop.Api.Tests.ServerFixture; namespace WebDrop.Api.Tests; /// 主后端 + 文件服务两个独立的服务一起跑:文件内容只在文件服务上;上传、登记、下载、计次、删除的完整流程。 public sealed class FileApiTests : IAsyncLifetime { private const string Secret = "files-test-secret-0123456789abcdef-xyz"; private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-files-" + Guid.NewGuid().ToString("N")); private WebApplication _files = null!; private ServerFixture _main = null!; private HttpClient _fileHttp = null!; private string _fileUrl = ""; private Signer _owner = null!; private sealed record Signer(string Token, string Username, string DeviceId, byte[] PrivateKey); public async Task InitializeAsync() { _files = FileApiApp.Build([], b => { b.Configuration["FileApi:DataDir"] = _dir; b.Configuration["FileApi:Secret"] = Secret; b.Configuration["urls"] = "http://127.0.0.1:0"; b.Logging.ClearProviders(); }); await _files.StartAsync(); _fileUrl = _files.Services.GetRequiredService().Features.Get()!.Addresses.First().TrimEnd('/'); _fileHttp = new HttpClient { BaseAddress = new Uri(_fileUrl) }; _main = ServerFixture.WithSettings(new() { ["WebDrop:Files:Enabled"] = "true", ["WebDrop:Files:PublicUrl"] = _fileUrl, ["WebDrop:Files:Secret"] = Secret, }); await _main.InitializeAsync(); _owner = await RegisterSignerAsync("files_owner"); } public async Task DisposeAsync() { await _main.DisposeAsync(); _fileHttp.Dispose(); await _files.StopAsync(); await _files.DisposeAsync(); try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } } // ───── 账号与签名:带设备签名密钥的账号,文件和聊天对象的最后一帧必须带签名事件 ───── private async Task RegisterSignerAsync(string username) { var accounts = _main.App.Services.GetRequiredService(); var verifier = RandomNumberGenerator.GetBytes(32); using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256); using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256); static string Jwk(ECPoint p) => JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(p.X!), y = B64(p.Y!) }); var devicePoint = device.ExportParameters(false).Q; Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], Jwk(invitation.ExportParameters(false).Q), Jwk(root.ExportParameters(false).Q))); string deviceId = Guid.NewGuid().ToString("D"); var token = accounts.Authenticate(username, verifier, deviceId, "Test device")!; var cert = root.SignData(SignatureCodec.BuildMessage("device.certificate", SignatureCodec.DeviceCertificatePayload(username, deviceId, devicePoint.X!, devicePoint.Y!)), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); using var request = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key") { Content = JsonBody(new { device_id = deviceId, public_key = Jwk(devicePoint), signature = Convert.ToBase64String(cert) }) }; request.Headers.Add("X-WD-Account", token); Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(request)).StatusCode); return new Signer(token, username, deviceId, device.ExportPkcs8PrivateKey()); } /// 条目最后一帧的签名事件头:对所有帧的摘要(清单)签名 private Dictionary SignedHeaders(string spaceId, string channelId, string itemId, CryptoDomain domain, string kind, IReadOnlyList frames) { Assert.True(Codec.TryDecodeExact(spaceId, 16, out var space)); Assert.True(Codec.TryDecodeExact(itemId, 16, out var item)); var manifest = SignatureCodec.FrameManifestHash(frames.Select(frame => SHA256.HashData(frame)).ToList()); var eventId = RandomNumberGenerator.GetBytes(16); var payload = SignatureCodec.ContentEventPayload(space, channelId, _owner.Username, _owner.DeviceId, eventId, kind, domain, 1, item, frames.Count, manifest, []); using var device = ECDsa.Create(); device.ImportPkcs8PrivateKey(_owner.PrivateKey, out _); var signature = device.SignData(SignatureCodec.BuildMessage("content.event", payload), HashAlgorithmName.SHA256, DSASignatureFormat.IeeeP1363FixedFieldConcatenation); return new Dictionary { ["X-WD-Event-Id"] = B64(eventId), ["X-WD-Event-Kind"] = kind, ["X-WD-Event-Channel"] = channelId, ["X-WD-Event-Domain"] = domain == CryptoDomain.Chat ? "chat" : "files", ["X-WD-Event-Epoch"] = "1", ["X-WD-Event-Frames"] = frames.Count.ToString(), ["X-WD-Event-Manifest"] = B64(manifest), ["X-WD-Event-Signature"] = B64(signature), }; } private Who Owner(Chan ch) => ch.Owner with { AccountToken = _owner.Token }; private async Task CreateSpaceAsync() { var ch = new Chan(NewId(), ChannelMode.Enc); var body = new Dictionary { ["id"] = ch.Id, ["mode"] = "enc", ["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)), ["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)), ["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)), ["settings"] = new { name = "Files space", max_members = 2, join_policy = "open" }, }; using var response = await _main.Http.SendAsync(_main.Req(HttpMethod.Post, "/api/v1/channels", Owner(ch), JsonBody(body))); Assert.Equal(HttpStatusCode.Created, response.StatusCode); return ch; } /// 空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),并为它创建一个文件域(同样是客户端做的事),之后才能声明对象 private async Task PrepareAsync(Chan ch) { string channelId; using (var request = _main.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", Owner(ch))) using (var response = await _main.Http.SendAsync(request)) channelId = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!; foreach (var domain in new[] { "chat", "files" }) { using var bootstrap = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/crypto/domains", Owner(ch), JsonBody(new { channelId, domain, epoch = 1, envelopes = new[] { new { channelId, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } })); Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode); } await CreateDomainAsync(ch, channelId); return channelId; } private async Task DeclareAsync(Chan ch, string channelId, string itemId, string domain = "files") { using var declare = _main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/crypto/objects", Owner(ch), JsonBody(new { itemId, channelId, domain, epoch = 1, keyEnvelope = "opaque-object-key" })); Assert.Equal(HttpStatusCode.Created, (await _main.Http.SendAsync(declare)).StatusCode); } // ───── 主后端发票据,文件服务存字节 ───── private readonly Dictionary _domains = []; private async Task GrantRequestAsync(Chan ch, string op, string? item = null, uint from = 0) => await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", Owner(ch), new StringContent(JsonSerializer.Serialize(new { op, item, from }), Encoding.UTF8, "application/json"))); private async Task<(string Url, string Domain, string Ticket)> GrantAsync(Chan ch, string op, string? item = null, uint from = 0) { using var response = await GrantRequestAsync(ch, op, item, from); if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException($"grant {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var root = doc.RootElement; return (root.GetProperty("url").GetString()!, root.GetProperty("domain").GetString()!, root.GetProperty("ticket").GetString()!); } /// 前端创建频道时做的事:向空间要「创建」票据,去文件服务建文件域,再把域 id 记到频道上 private async Task CreateDomainAsync(Chan ch, string channelId) { var grant = await GrantAsync(ch, "create"); using var create = new HttpRequestMessage(HttpMethod.Post, "/file/v1/domains") { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; create.Headers.Add("X-WD-Ticket", grant.Ticket); using var created = await _fileHttp.SendAsync(create); Assert.Equal(HttpStatusCode.Created, created.StatusCode); using var doc = JsonDocument.Parse(await created.Content.ReadAsStringAsync()); string domain = doc.RootElement.GetProperty("id").GetString()!; using var record = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/channels/{channelId}/file-domain", Owner(ch), JsonBody(new { fileDomain = domain })); Assert.Equal(HttpStatusCode.OK, (await _main.Http.SendAsync(record)).StatusCode); return _domains[ch.Id] = domain; } /// 把一帧存进文件域,返回文件服务算的摘要 private async Task StoreAsync(Chan ch, string item, byte[] frame, uint index) { var grant = await GrantAsync(ch, "put", item); using var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/{index}") { Content = new ByteArrayContent(frame) }; put.Headers.Add("X-WD-Ticket", grant.Ticket); using var stored = await _fileHttp.SendAsync(put); Assert.Equal(HttpStatusCode.OK, stored.StatusCode); using var doc = JsonDocument.Parse(await stored.Content.ReadAsStringAsync()); string digest = doc.RootElement.GetProperty("digest").GetString()!; Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(frame)), digest); return digest; } /// 到主后端一次登记整个文件:末帧的 26 字节头部 + 每一帧的(负载长度,整帧摘要),不带任何字节 private async Task RegisterAsync(Chan ch, IReadOnlyList frames, string? maxDownloads = null, Dictionary? signed = null, IReadOnlyList? digests = null) { var body = new byte[26 + frames.Count * 36]; frames[^1][..26].CopyTo(body, 0); for (int i = 0; i < frames.Count; i++) { System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(26 + i * 36), (uint)(frames[i].Length - 26)); Convert.FromHexString(digests?[i] ?? Convert.ToHexStringLower(SHA256.HashData(frames[i]))).CopyTo(body, 26 + i * 36 + 4); } var register = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(body)); if (maxDownloads is not null) register.Headers.Add("X-WD-Max-Downloads", maxDownloads); foreach (var (name, value) in signed ?? []) register.Headers.Add(name, value); return await _main.Http.SendAsync(register); } /// 上传一个文件:每一帧先存进文件域,全部存好后一次到主后端登记(末帧的签名事件盖着所有帧的摘要) private async Task UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList frames, string? maxDownloads = null) { for (int i = 0; i < frames.Count; i++) await StoreAsync(ch, item, frames[i], (uint)i); using var response = await RegisterAsync(ch, frames, maxDownloads, SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", frames)); return response.StatusCode; } private async Task<(HttpResponseMessage Response, byte[] Body)> ReadFromFilesAsync(string domain, string item, string ticket, uint from) { using var get = new HttpRequestMessage(HttpMethod.Get, $"/file/v1/domains/{domain}/items/{Hex(item)}/frames?from={from}"); get.Headers.Add("X-WD-Ticket", ticket); var response = await _fileHttp.SendAsync(get); return (response, await response.Content.ReadAsByteArrayAsync()); } private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill); private static string Hex(string base64UrlId) { Assert.True(Codec.TryDecodeExact(base64UrlId, 16, out var bytes)); return Convert.ToHexStringLower(bytes); } // ───── 测试 ───── [Fact] public async Task FileBytesLiveInTheFileDomainWhileChatStaysOnTheHost() { var info = await _main.Http.GetStringAsync("/api/v1/info"); Assert.Contains("\"files\":{\"enabled\":true", info); Assert.DoesNotContain("threshold", info); var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); string item = NewId(), tiny = NewId(), chat = NewId(); await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, tiny); await DeclareAsync(ch, channelId, chat, "chat"); var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) }; var tinyFrame = Small(tiny, 7); // 文件域的对象不能把字节直接交给主后端,不管多大 using (var direct = await _main.AppendAsync(ch, Owner(ch), frames[0], maxDownloads: "1")) Assert.Equal(HttpStatusCode.Conflict, direct.StatusCode); using (var directSmall = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(tinyFrame))) { foreach (var (name, value) in SignedHeaders(ch.Id, channelId, tiny, CryptoDomain.Files, "file.publish", [tinyFrame])) directSmall.Headers.Add(name, value); using var rejected = await _main.Http.SendAsync(directSmall); Assert.Equal(HttpStatusCode.Conflict, rejected.StatusCode); } Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "1")); Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, tiny, [tinyFrame])); // 聊天(聊天域的对象)照旧存在主后端,日志里带着内容 var chatFrame = Small(chat, 5); using (var chatRequest = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(chatFrame))) { foreach (var (name, value) in SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame])) chatRequest.Headers.Add(name, value); using var chatResponse = await _main.Http.SendAsync(chatRequest); Assert.Equal(HttpStatusCode.Created, chatResponse.StatusCode); } var (_, logBody) = await _main.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", Owner(ch)); var log = ParseLog(logBody); Assert.Equal(2, log.Count(r => r.Kind == LogKind.Announce)); // 两个文件条目都只是「公布」 Assert.Equal(1, log.Count(r => r.Kind == LogKind.Frame)); // 只有聊天帧带内容 await AssertErrorAsync(await _main.Http.SendAsync(_main.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/items/{item}/frames", Owner(ch))), 409, "use_file_api"); // 受限条目:要票据,按原来的规则计次(上限 1 次) var grant = await GrantAsync(ch, "get", item); Assert.Equal(_domains[ch.Id], grant.Domain); var (read, body) = await ReadFromFilesAsync(grant.Domain, item, grant.Ticket, 0); Assert.Equal(HttpStatusCode.OK, read.StatusCode); Assert.Equal(frames.SelectMany(f => f).ToArray(), body); using (var second = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.Gone, second.StatusCode); var resume = await GrantAsync(ch, "get", item, 2); Assert.Equal(frames[2], (await ReadFromFilesAsync(resume.Domain, item, resume.Ticket, 2)).Body); // 单帧不限次的文件:想读几次都行 for (int i = 0; i < 3; i++) { var t = await GrantAsync(ch, "get", tiny); Assert.Equal(tinyFrame, (await ReadFromFilesAsync(t.Domain, tiny, t.Ticket, 0)).Body); } // 聊天条目没有文件域,不发文件票据 using (var chatGrant = await GrantRequestAsync(ch, "get", chat)) Assert.Equal(HttpStatusCode.NotFound, chatGrant.StatusCode); } [Fact] public async Task TicketsFollowTheSpacePermissionsAndAreBoundToTheirItem() { var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); string item = NewId(), other = NewId(); await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, other); var f0 = Frame(item, 0, GatedEnc, EncPayload, 1); var f1 = Frame(item, 1, GatedFinalEnc, EncPayload, 2); // 没有空间里的身份,什么票据都拿不到 var stranger = ch.Owner with { Token = new byte[] { 1, 2, 3 }, AccountToken = null }; using (var denied = await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", stranger, new StringContent(JsonSerializer.Serialize(new { op = "create" }), Encoding.UTF8, "application/json")))) Assert.False(denied.IsSuccessStatusCode); // 别的条目的票据传不进这个条目;没有票据也不行 var otherGrant = await GrantAsync(ch, "put", other); using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(f0) }) { put.Headers.Add("X-WD-Ticket", otherGrant.Ticket); using var wrongItem = await _fileHttp.SendAsync(put); Assert.Equal(HttpStatusCode.Unauthorized, wrongItem.StatusCode); } using (var noTicket = await _fileHttp.PutAsync($"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0", new ByteArrayContent(f0))) Assert.Equal(HttpStatusCode.Unauthorized, noTicket.StatusCode); // 同一个序号不能存不同的内容;同样的内容重传是幂等的 string digest0 = await StoreAsync(ch, item, f0, 0); var grant = await GrantAsync(ch, "put", item); using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 9)) }) { put.Headers.Add("X-WD-Ticket", grant.Ticket); using var conflict = await _fileHttp.SendAsync(put); Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode); } Assert.Equal(digest0, await StoreAsync(ch, item, f0, 0)); // 登记整个文件(一次请求):先把第二帧也存进文件域 string digest1 = await StoreAsync(ch, item, f1, 1); // 末帧的签名清单盖着所有帧的摘要:登记的摘要和清单对不上,整个文件就不被接受 using (var wrongDigest = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, digest0])) Assert.Equal(HttpStatusCode.Conflict, wrongDigest.StatusCode); using (var good = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]))) Assert.Equal(HttpStatusCode.Created, good.StatusCode); // 同一个文件重复登记幂等;摘要不同(同一个条目)就是冲突 using (var again = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]))) Assert.True(again.IsSuccessStatusCode); using (var different = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, new string('a', 64)])) Assert.Equal(HttpStatusCode.Conflict, different.StatusCode); // 请求体格式不对 using (var malformed = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(new byte[10]))) using (var bad = await _main.Http.SendAsync(malformed)) Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode); // 不是文件域的条目不能走登记 string chat = NewId(); await DeclareAsync(ch, channelId, chat, "chat"); var chatFrame = Small(chat); using (var notAFile = await RegisterAsync(ch, [chatFrame], null, SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame]))) Assert.Equal(HttpStatusCode.BadRequest, notAFile.StatusCode); } [Fact] public async Task ItemsInAChannelWithoutAFileDomainGetNoTicketsAndNeverRegisteredFilesCannotBeDownloaded() { var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); string item = NewId(); await DeclareAsync(ch, channelId, item); // 字节存进了文件域、但没有到主后端登记的文件(上传到一半放弃):主后端不认识它,不发下载票据 await StoreAsync(ch, item, Frame(item, 0, GatedEnc, EncPayload, 1), 0); using (var notRegistered = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.NotFound, notRegistered.StatusCode); // 第二个空间里的默认频道没有文件域:声明了文件对象也要不到上传票据 var bare = await CreateSpaceAsync(); string bareChannel; using (var request = _main.Req(HttpMethod.Get, $"/api/v1/spaces/{bare.Id}/structure", Owner(bare))) using (var response = await _main.Http.SendAsync(request)) bareChannel = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!; foreach (var domain in new[] { "chat", "files" }) { using var bootstrap = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{bare.Id}/crypto/domains", Owner(bare), JsonBody(new { channelId = bareChannel, domain, epoch = 1, envelopes = new[] { new { channelId = bareChannel, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } })); Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode); } string orphan = NewId(); await DeclareAsync(bare, bareChannel, orphan); using (var noDomain = await GrantRequestAsync(bare, "put", orphan)) Assert.Equal(HttpStatusCode.Conflict, noDomain.StatusCode); } [Fact] public async Task DeletingAFileTakesAGrantThenDeletesOnTheHostAndInTheFileDomain() { var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); var store = _files.Services.GetRequiredService(); string finished = NewId(); await DeclareAsync(ch, channelId, finished); var finishedFrame = Small(finished, 3); Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, finished, [finishedFrame])); string domain = _domains[ch.Id]; Assert.True(store.UsedBy(domain) > 0); foreach (var item in new[] { finished }) { // 先向空间要「删除」票据(主后端确认能删),再删主后端的条目,最后用票据清文件域里的字节 var grant = await GrantAsync(ch, "delete", item); using (var hostDelete = _main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", Owner(ch))) { // 删除文件要带作者设备签名的 file.delete 事件(清单和发布时一致) foreach (var (name, value) in SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.delete", [finishedFrame])) hostDelete.Headers.Add(name, value); using var host = await _main.Http.SendAsync(hostDelete); Assert.Equal(HttpStatusCode.NoContent, host.StatusCode); } using var remove = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}"); remove.Headers.Add("X-WD-Ticket", grant.Ticket); using var removed = await _fileHttp.SendAsync(remove); Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode); } Assert.Equal(0, store.UsedBy(domain)); // 取消上传:主后端那边还没有这个条目(整个文件传完才登记),用上传这个条目的票据就能清掉自己传的字节 string cancelled = NewId(); await DeclareAsync(ch, channelId, cancelled); await StoreAsync(ch, cancelled, Frame(cancelled, 0, GatedEnc, EncPayload, 5), 0); Assert.True(store.UsedBy(domain) > 0); var put = await GrantAsync(ch, "put", cancelled); using var abandon = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{put.Domain}/items/{Hex(cancelled)}"); abandon.Headers.Add("X-WD-Ticket", put.Ticket); using var abandoned = await _fileHttp.SendAsync(abandon); Assert.Equal(HttpStatusCode.NoContent, abandoned.StatusCode); Assert.Equal(0, store.UsedBy(domain)); } [Fact] public async Task AFileUploadAsTheClientDoesItCompletesWithCatalogAndLocation() { var ch = await CreateSpaceAsync(); string channelId = await PrepareAsync(ch); string item = NewId(), catalog = NewId(); await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, catalog); var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) }; // 1. 字节进文件域,整个文件一次登记 Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "0")); // 2. 目录条目:一个控制帧(小,不受限),走主后端,带 file.catalog 签名事件 var control = Frame(catalog, 0, 16 | 8 | 1, EncPayload, 7); using (var request = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(control))) { foreach (var (name, value) in SignedHeaders(ch.Id, channelId, catalog, CryptoDomain.Files, "file.catalog", [control])) request.Headers.Add(name, value); using var response = await _main.Http.SendAsync(request); Assert.True(response.IsSuccessStatusCode, $"目录条目被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); } // 3. 位置登记:文件放进频道 using (var location = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/locations", Owner(ch), JsonBody(new { itemIds = new[] { item }, threadId = channelId }))) using (var response = await _main.Http.SendAsync(location)) Assert.True(response.IsSuccessStatusCode, $"位置登记被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); } }