feat: integrate client and server sharing workflows

This commit is contained in:
justme committed 2026-10-02 10:49:45 +08:00
1 parent 42b93ae2d8
commit 7406e2fe37
101 files changed
+3153 -253

No files matched your search

@@ -1,5 +1,7 @@
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using WebDrop.Api;
using WebDrop.Core;
using static WebDrop.Api.Tests.ServerFixture;
@@ -17,6 +19,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
var json = await ReadJsonAsync(resp);
Assert.Equal(1, json.GetProperty("protocol").GetInt32());
Assert.Equal(ApiContract.Version, json.GetProperty("contract_version").GetString());
Assert.True(json.GetProperty("transports").GetProperty("websocket").GetBoolean());
Assert.Equal(1048592, json.GetProperty("limits").GetProperty("max_payload_bytes").GetInt32());
// 默认不启用验证码,不下发 Cap 的地址
@@ -89,6 +92,31 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
}
[Fact]
public async Task OwnerCapabilityCanManageWithoutOccupyingAnotherSeat()
{
var ch = await s.CreateAsync();
using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
var unseatedOwner = new Who("owner", ch.OwnerToken, RandomNumberGenerator.GetBytes(32));
async Task<HttpResponseMessage> OwnerRequest(HttpMethod method, string path, HttpContent? body = null)
{
using var request = s.Req(method, path, unseatedOwner, body);
request.Headers.Remove("X-WD-Endpoint");
return await s.Http.SendAsync(request);
}
using (var patch = await OwnerRequest(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", JsonBody(new { seats = 3 })))
Assert.Equal(HttpStatusCode.OK, patch.StatusCode);
using (var seats = await OwnerRequest(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats"))
Assert.Equal(HttpStatusCode.OK, seats.StatusCode);
using (var absentSeat = await OwnerRequest(HttpMethod.Delete,
$"/api/v1/channels/{ch.Id}/seats/{B64(RandomNumberGenerator.GetBytes(32))}"))
await AssertErrorAsync(absentSeat, 404, "not_found");
using (var deleted = await OwnerRequest(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}"))
Assert.Equal(HttpStatusCode.NoContent, deleted.StatusCode);
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found");
}
[Fact]
public async Task AppendIsIdempotentOverHttp()
{
@@ -287,6 +315,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
public async Task DeleteChannelRemovesIt()
{
var ch = await s.CreateAsync();
using (var joined = await s.JoinAsync(ch, ch.Member)) Assert.Equal(HttpStatusCode.OK, joined.StatusCode);
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Member)), 403, "forbidden");
@@ -119,6 +119,37 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixtu
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<HttpResponseMessage> CreateShare(ShareClient share)
{
using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new
@@ -114,6 +114,26 @@ public class ChannelServiceTests : IDisposable
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
}
[Fact]
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
{
var otherOwnerEndpoint = TestEnv.Endpoint();
var first = _env.Create(_ownerEp);
var second = _env.Create(otherOwnerEndpoint);
var sharedItemId = TestEnv.NewId();
var firstOnlyItemId = TestEnv.NewId();
await AppendSmall(first, first.Owner(_ownerEp), sharedItemId, fill: 0x11);
await AppendSmall(second, second.Owner(otherOwnerEndpoint), sharedItemId, fill: 0x22);
await AppendSmall(first, first.Owner(_ownerEp), firstOnlyItemId, fill: 0x33);
var firstFrame = (await Svc.ReadItemFramesAsync(first.Id, first.Owner(_ownerEp), sharedItemId, 0, 10)).Frames.Single();
var secondFrame = (await Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), sharedItemId, 0, 10)).Frames.Single();
Assert.Equal(0x11, firstFrame[^1]);
Assert.Equal(0x22, secondFrame[^1]);
await AssertCode(ErrorCode.NotFound,
() => Svc.ReadItemFramesAsync(second.Id, second.Owner(otherOwnerEndpoint), firstOnlyItemId, 0, 10));
}
[Fact]
public async Task ReleasedSeatLosesAccess()
{
@@ -143,6 +163,22 @@ public class ChannelServiceTests : IDisposable
Assert.Equal(2, seats.Count);
}
[Fact]
public void OwnerCapabilityCanManageWithoutOccupyingAnotherSeat()
{
var ch = _env.Create(_ownerEp);
Svc.Join(ch.Id, ch.Reader(_memberEp));
var unseatedOwner = ch.Owner(TestEnv.Endpoint());
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { Seats = 3 }).Settings.Seats);
Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count);
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint()));
Svc.DeleteChannel(ch.Id, unseatedOwner);
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
}
[Fact]
public void SeatLabelIsStoredAndBounded()
{
@@ -361,6 +397,7 @@ public class ChannelServiceTests : IDisposable
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
await Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); // 乱序到达,缺 1
Svc.StartItemDownload(ch.Id, o, item, 0);
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
Assert.Single(chunk.Frames);
Assert.Equal(1u, chunk.NextIndex);
@@ -403,7 +440,7 @@ public class ChannelServiceTests : IDisposable
Assert.Single((await Svc.ReadItemFramesAsync(ch.Id, ch.Reader(_memberEp), item, 1, 10)).Frames);
// 宽限期过后拒绝,清理任务删除密文并留下墓碑
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds + 1));
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds));
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1));
Assert.Equal(1, Svc.Sweep());
@@ -412,6 +449,29 @@ public class ChannelServiceTests : IDisposable
AssertCode(ErrorCode.NotFound, () => Svc.StartItemDownload(ch.Id, o, item, 0));
}
[Fact]
public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint()
{
var otherEndpoint = TestEnv.Endpoint();
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 3, FileMaxDownloads = 1 });
Svc.Join(ch.Id, ch.Reader(_memberEp));
Svc.Join(ch.Id, ch.Reader(otherEndpoint));
var item = await UploadGated(ch, ch.Owner(_ownerEp), 2);
var otherItem = await UploadGated(ch, ch.Owner(_ownerEp), 2);
var reader = ch.Reader(_memberEp);
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, item, 1));
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10));
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(otherEndpoint), item, 1));
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, reader, otherItem, 1));
Svc.StartItemDownload(ch.Id, reader, item, 0);
Svc.StartItemDownload(ch.Id, reader, item, 1);
var resumed = await Svc.ReadItemFramesAsync(ch.Id, reader, item, 1, 10);
Assert.Single(resumed.Frames);
Assert.Equal(2u, resumed.NextIndex);
}
[Fact]
public async Task ZeroMaxDownloadsMeansUnlimitedAndHeaderOverridesDefault()
{
@@ -506,9 +566,16 @@ public class ChannelServiceTests : IDisposable
await AppendSmall(ch, o, old);
_env.Clock.Advance(TimeSpan.FromHours(12));
await AppendSmall(ch, o); // 续期频道,新条目 TTL 从现在起算
var fresh = TestEnv.NewId();
await AppendSmall(ch, o, fresh); // 续期频道,新条目 TTL 从现在起算
_env.Clock.Advance(TimeSpan.FromHours(13)); // 第一个条目已过期,第二个还在
var beforeCleanup = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
Assert.Single(beforeCleanup.Records);
Assert.Equal(fresh.ToArray(), beforeCleanup.Records[0].Bytes[(9 + 2)..(9 + 2 + 16)]);
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, old, 0));
await AssertCode(ErrorCode.Gone, () => Svc.ReadItemFramesAsync(ch.Id, o, old, 0, 10));
Assert.Equal(1, Svc.Sweep());
var log = await Svc.ReadLogAsync(ch.Id, o, 0, 100);