using System.Net; using System.Security.Cryptography; using WebDrop.Core; using static WebDrop.Api.Tests.ServerFixture; namespace WebDrop.Api.Tests; public sealed class ShareTests(ServerFixture server) : IClassFixture { private sealed class ShareClient(string id) { public string Id { get; } = id; public byte[] ReadToken { get; } = RandomNumberGenerator.GetBytes(32); public byte[] OwnerToken { get; } = RandomNumberGenerator.GetBytes(32); public byte[] OwnerEndpoint { get; } = RandomNumberGenerator.GetBytes(32); public byte[] VisitorEndpoint { get; } = RandomNumberGenerator.GetBytes(32); public byte[] OtherEndpoint { get; } = RandomNumberGenerator.GetBytes(32); public Who Owner => new("owner", OwnerToken, OwnerEndpoint); public Who CreatorReader => new("read", ReadToken, OwnerEndpoint); public Who Visitor => new("read", ReadToken, VisitorEndpoint); public Who OtherVisitor => new("read", ReadToken, OtherEndpoint); } [Fact] public async Task DraftSealsIntoReadOnlyShareWithVisitorAndDownloadLimits() { var share = new ShareClient(NewId()); using (var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new { id = share.Id, read_hash = B64(TokenHash.Compute(share.ReadToken)), owner_hash = B64(TokenHash.Compute(share.OwnerToken)), settings = new { ttl_seconds = 3600, max_visitors = 1, file_max_downloads = 1 }, }))) using (var created = await server.Http.SendAsync(req)) { Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.Equal("draft", (await ReadJsonAsync(created)).GetProperty("state").GetString()); } using (var draftRead = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.Owner))) Assert.Equal(HttpStatusCode.OK, draftRead.StatusCode); await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.CreatorReader)), 404, "not_found"); string manifestId = NewId(); byte[] manifest = Frame(manifestId, 0, FinalEnc, EncPayload, 0x11); using (var putManifest = await server.Http.SendAsync(server.Req(HttpMethod.Post, $"/api/v1/shares/{share.Id}/frames", share.Owner, Bytes(manifest)))) Assert.Equal(HttpStatusCode.Created, putManifest.StatusCode); string fileId = NewId(); byte[] first = Frame(fileId, 0, GatedEnc, EncPayload, 0x21); byte[] last = Frame(fileId, 1, GatedFinalEnc, EncPayload, 0x22); using (var putFirst = await SendFrame(share, share.Owner, first, "1")) Assert.Equal(HttpStatusCode.Created, putFirst.StatusCode); using (var putLast = await SendFrame(share, share.Owner, last)) Assert.Equal(HttpStatusCode.Created, putLast.StatusCode); using (var finish = await server.Http.SendAsync(server.Req(HttpMethod.Post, $"/api/v1/shares/{share.Id}/finish", share.Owner, JsonBody(new { manifest_item_id = manifestId })))) { Assert.Equal(HttpStatusCode.OK, finish.StatusCode); var info = await ReadJsonAsync(finish); Assert.Equal("sealed", info.GetProperty("state").GetString()); Assert.Equal(manifestId, info.GetProperty("manifest_item_id").GetString()); Assert.Single(info.GetProperty("items").EnumerateArray()); // manifest is not a downloadable file } await AssertErrorAsync(await SendFrame(share, share.Owner, Frame(NewId(), 0, FinalEnc, EncPayload)), 409, "sealed"); using (var invalidRole = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", new Who("write", share.ReadToken, share.OtherEndpoint)))) await AssertErrorAsync(invalidRole, 400, "bad_request"); var (manifestResponse, manifestBody) = await server.GetBytesAsync( $"/api/v1/shares/{share.Id}/manifest", share.CreatorReader); using (manifestResponse) Assert.Equal(manifest, manifestBody); var (visitorManifestResponse, visitorManifest) = await server.GetBytesAsync( $"/api/v1/shares/{share.Id}/manifest", share.Visitor); using (visitorManifestResponse) Assert.Equal(manifest, visitorManifest); await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}/manifest", share.OtherVisitor)), 403, "visitors_full"); string downloadPath = $"/api/v1/shares/{share.Id}/items/{fileId}/frames"; var (downloadResponse, downloaded) = await server.GetBytesAsync(downloadPath, share.Visitor); using (downloadResponse) Assert.Equal(first.Concat(last), downloaded); await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, downloadPath, share.Visitor)), 410, "gone"); var (resumeResponse, resumed) = await server.GetBytesAsync(downloadPath + "?from=1", share.Visitor); using (resumeResponse) Assert.Equal(last, resumed); using var finalInfo = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.CreatorReader)); var file = (await ReadJsonAsync(finalInfo)).GetProperty("items").EnumerateArray().Single(); Assert.Equal(0, file.GetProperty("downloads_left").GetInt32()); } [Fact] public async Task ShareAndChannelIdsShareOneUniqueNamespace() { var share = new ShareClient(NewId()); using (var created = await CreateShare(share)) Assert.Equal(HttpStatusCode.Created, created.StatusCode); byte[] channelOwnerToken = RandomNumberGenerator.GetBytes(32); var channelOwner = new Who("owner", channelOwnerToken, RandomNumberGenerator.GetBytes(32)); using (var collision = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", channelOwner, JsonBody(new { id = share.Id, mode = "raw", settings = new { name = "Collision" }, owner_hash = B64(TokenHash.Compute(channelOwnerToken)) })))) await AssertErrorAsync(collision, 409, "conflict"); string channelId = NewId(); byte[] secondOwnerToken = RandomNumberGenerator.GetBytes(32); var secondOwner = new Who("owner", secondOwnerToken, RandomNumberGenerator.GetBytes(32)); using (var channel = await server.Http.SendAsync(server.Req(HttpMethod.Post, "/api/v1/channels", secondOwner, JsonBody(new { id = channelId, mode = "raw", settings = new { name = "Second space" }, owner_hash = B64(TokenHash.Compute(secondOwnerToken)) })))) Assert.Equal(HttpStatusCode.Created, channel.StatusCode); using var shareCollision = await CreateShare(new ShareClient(channelId)); await AssertErrorAsync(shareCollision, 409, "conflict"); } [Fact] public async Task ResumeWithoutAnInitialDownloadCannotBypassShareDownloadLimit() { var share = new ShareClient(NewId()); using (var created = await CreateShare(share)) Assert.Equal(HttpStatusCode.Created, created.StatusCode); string manifestId = NewId(); using (var manifest = await SendFrame(share, share.Owner, Frame(manifestId, 0, FinalEnc, EncPayload))) Assert.Equal(HttpStatusCode.Created, manifest.StatusCode); string itemId = NewId(); using (var first = await SendFrame(share, share.Owner, Frame(itemId, 0, GatedEnc, EncPayload), "1")) Assert.Equal(HttpStatusCode.Created, first.StatusCode); using (var last = await SendFrame(share, share.Owner, Frame(itemId, 1, GatedFinalEnc, EncPayload))) Assert.Equal(HttpStatusCode.Created, last.StatusCode); using (var finish = await server.Http.SendAsync(server.Req(HttpMethod.Post, $"/api/v1/shares/{share.Id}/finish", share.Owner, JsonBody(new { manifest_item_id = manifestId })))) Assert.Equal(HttpStatusCode.OK, finish.StatusCode); string path = $"/api/v1/shares/{share.Id}/items/{itemId}/frames"; await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, path + "?from=1", share.OtherVisitor)), 410, "gone"); var (initialResponse, initialBytes) = await server.GetBytesAsync(path, share.Visitor); using (initialResponse) Assert.Equal(Frame(itemId, 0, GatedEnc, EncPayload, 0x42).Concat(Frame(itemId, 1, GatedFinalEnc, EncPayload, 0x42)), initialBytes); var (resumeResponse, resumedBytes) = await server.GetBytesAsync(path + "?from=1", share.Visitor); using (resumeResponse) Assert.Equal(Frame(itemId, 1, GatedFinalEnc, EncPayload, 0x42), resumedBytes); using var infoResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.CreatorReader)); var item = (await ReadJsonAsync(infoResponse)).GetProperty("items").EnumerateArray().Single(); Assert.Equal(0, item.GetProperty("downloads_left").GetInt32()); } private async Task CreateShare(ShareClient share) { using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new { id = share.Id, read_hash = B64(TokenHash.Compute(share.ReadToken)), owner_hash = B64(TokenHash.Compute(share.OwnerToken)), })); return await server.Http.SendAsync(req); } private async Task SendFrame(ShareClient share, Who who, byte[] bytes, string? maxDownloads = null) { using var req = server.Req(HttpMethod.Post, $"/api/v1/shares/{share.Id}/frames", who, Bytes(bytes)); if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads); return await server.Http.SendAsync(req); } }