feat: integrate client and server sharing workflows
This commit is contained in:
1 parent
42b93ae2d8
commit
7406e2fe37
101 files changed
+3153
-253
No files matched your search
@@ -0,0 +1,28 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends clang zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY server/ ./server/
|
||||
RUN dotnet publish server/src/WebDrop.Api/WebDrop.Api.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained true \
|
||||
--output /out
|
||||
RUN find /root/.nuget/packages/sqlitepclraw.lib.e_sqlite3 -path '*/runtimes/linux-x64/native/libe_sqlite3.so' \
|
||||
-exec cp {} /out/libe_sqlite3.so \; \
|
||||
&& test -f /out/libe_sqlite3.so
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/webdrop /app/webdrop
|
||||
COPY --from=build /out/libe_sqlite3.so /app/libe_sqlite3.so
|
||||
RUN mkdir -p /data \
|
||||
&& chown 10001:10001 /data \
|
||||
&& chmod 0750 /data
|
||||
ENV ASPNETCORE_URLS=http://+:5000 \
|
||||
WebDrop__DataDir=/data \
|
||||
DOTNET_EnableDiagnostics=0
|
||||
USER 10001:10001
|
||||
EXPOSE 5000
|
||||
ENTRYPOINT ["/app/webdrop"]
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace WebDrop.Api;
|
||||
|
||||
public static class ApiContract
|
||||
{
|
||||
// Keep this aligned with contracts/api-contract.json and docs/openapi.yaml.
|
||||
public const string Version = "1.0.0";
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public sealed class GoneLogBody
|
||||
public sealed class ServerInfoBody
|
||||
{
|
||||
[JsonPropertyName("protocol")] public int Protocol { get; init; } = 1;
|
||||
[JsonPropertyName("contract_version")] public string ContractVersion { get; init; } = ApiContract.Version;
|
||||
[JsonPropertyName("captcha")] public required CaptchaBody Captcha { get; init; }
|
||||
[JsonPropertyName("transports")] public required TransportsBody Transports { get; init; }
|
||||
[JsonPropertyName("limits")] public required LimitsBody Limits { get; init; }
|
||||
|
||||
@@ -82,6 +82,8 @@ public interface IResourceTx
|
||||
void InsertShareItem(ShareItem item);
|
||||
void UpdateShareItem(ShareItem item);
|
||||
void DeleteShareItemRows(Id16 shareId, Id16 itemId);
|
||||
bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
|
||||
void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash);
|
||||
ShareFrame? GetShareFrame(Id16 shareId, Id16 itemId, uint index);
|
||||
void InsertShareFrame(ShareFrame frame);
|
||||
IReadOnlyList<ShareFrame> ListShareFrames(Id16 shareId, Id16 itemId, uint fromIndex, int limit);
|
||||
|
||||
@@ -324,6 +324,9 @@ public sealed class ChannelService(
|
||||
if (!store.Read(tx => tx.BlobKeyExists(blobKey))) blobs.Delete(blobKey);
|
||||
}
|
||||
|
||||
private static bool IsItemReadable(Item item, long now) =>
|
||||
item.ExpiresAt > now && (item.CloseDeadline is null || item.CloseDeadline > now);
|
||||
|
||||
// ───────────────────────── 读取 ─────────────────────────
|
||||
|
||||
public async Task<LogPage> ReadLogAsync(Id16 id, Credentials creds, long from, int limit, CancellationToken ct = default)
|
||||
@@ -338,16 +341,19 @@ public sealed class ChannelService(
|
||||
if (from > 0 && from < channel.CompactedThrough + 1)
|
||||
throw new WebDropException(ErrorCode.Gone, channel.CompactedThrough + 1);
|
||||
|
||||
long now = clock.NowMs;
|
||||
var list = new List<(long Seq, byte[]? Bytes, StoredFrame? Frame)>();
|
||||
foreach (var e in tx.ReadLog(id, from, limit))
|
||||
{
|
||||
switch (e.Kind)
|
||||
{
|
||||
case LogKind.Frame:
|
||||
if (tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f) list.Add((e.Seq, null, f));
|
||||
if (tx.GetItem(id, e.ItemId) is { } frameItem && IsItemReadable(frameItem, now)
|
||||
&& tx.GetFrame(id, e.ItemId, e.FrameIndex) is { } f)
|
||||
list.Add((e.Seq, null, f));
|
||||
break;
|
||||
case LogKind.Announce:
|
||||
if (tx.GetItem(id, e.ItemId) is { } it)
|
||||
if (tx.GetItem(id, e.ItemId) is { } it && IsItemReadable(it, now))
|
||||
list.Add((e.Seq, LogRecord.EncodeAnnounce(e.Seq, e.ItemId, it.Flags, it.CreatedAt), null));
|
||||
break;
|
||||
case LogKind.Tombstone:
|
||||
@@ -378,7 +384,10 @@ public sealed class ChannelService(
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
|
||||
long now = clock.NowMs;
|
||||
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now > deadline))
|
||||
if (item.ExpiresAt <= now) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.IsGated && fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
|
||||
if (fromIndex == 0 && item.IsGated)
|
||||
@@ -391,6 +400,7 @@ public sealed class ChannelService(
|
||||
? now + limits.DownloadGraceSeconds * 1000L
|
||||
: item.CloseDeadline;
|
||||
tx.UpdateItem(item with { Downloads = downloads, CloseDeadline = closeAt });
|
||||
tx.AddDownloadGrant(id, itemId, principal.EndpointHash);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
@@ -406,7 +416,10 @@ public sealed class ChannelService(
|
||||
var (_, principal) = Authenticate(tx, id, creds, SeatRule.Required);
|
||||
if (!ChannelPolicy.CanRead(principal)) throw new WebDropException(ErrorCode.Forbidden);
|
||||
var item = tx.GetItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (item.CloseDeadline is { } d && clock.NowMs > d) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.ExpiresAt <= clock.NowMs) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, principal.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } d && clock.NowMs >= d) throw new WebDropException(ErrorCode.Gone);
|
||||
|
||||
var taken = new List<StoredFrame>();
|
||||
uint expected = fromIndex;
|
||||
|
||||
@@ -210,11 +210,14 @@ public sealed class ShareService(
|
||||
store.Write(tx =>
|
||||
{
|
||||
var share = SealedForRead(tx.GetShare(id), creds);
|
||||
RegisterVisitor(tx, share, creds.EndpointHash);
|
||||
var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (!item.IsGated) throw new WebDropException(ErrorCode.BadRequest);
|
||||
if (fromIndex > 0 && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
long now = clock.NowMs;
|
||||
if (item.CloseDeadline is { } d && (fromIndex == 0 || now > d)) throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } deadline && (fromIndex == 0 || now >= deadline))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
RegisterVisitor(tx, share, creds.EndpointHash);
|
||||
if (fromIndex == 0)
|
||||
{
|
||||
if (item.MaxDownloads > 0 && item.Downloads >= item.MaxDownloads) throw new WebDropException(ErrorCode.Gone);
|
||||
@@ -223,6 +226,7 @@ public sealed class ShareService(
|
||||
? now + limits.DownloadGraceSeconds * 1000L
|
||||
: item.CloseDeadline;
|
||||
tx.UpdateShareItem(item with { Downloads = count, CloseDeadline = closeAt });
|
||||
tx.AddDownloadGrant(id, itemId, creds.EndpointHash);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
@@ -234,6 +238,10 @@ public sealed class ShareService(
|
||||
{
|
||||
_ = SealedForRead(tx.GetShare(id), creds);
|
||||
var item = tx.GetShareItem(id, itemId) ?? throw new WebDropException(ErrorCode.NotFound);
|
||||
if (item.IsGated && !tx.HasDownloadGrant(id, itemId, creds.EndpointHash))
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
if (item.CloseDeadline is { } deadline && clock.NowMs >= deadline)
|
||||
throw new WebDropException(ErrorCode.Gone);
|
||||
var list = tx.ListShareFrames(id, itemId, fromIndex, 32);
|
||||
uint expected = fromIndex;
|
||||
foreach (var frame in list)
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace WebDrop.Storage;
|
||||
/// </summary>
|
||||
public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
{
|
||||
private const int SchemaVersion = 2;
|
||||
private const int SchemaVersion = 3;
|
||||
|
||||
private readonly string _connectionString;
|
||||
private readonly SqliteConnection _writeConn;
|
||||
@@ -93,6 +93,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
using var tx = _writeConn.BeginTransaction();
|
||||
if (version == 0) Exec(_writeConn, SchemaV1);
|
||||
if (version < 2) Exec(_writeConn, SchemaV2);
|
||||
if (version < 3) Exec(_writeConn, SchemaV3);
|
||||
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
|
||||
tx.Commit();
|
||||
}
|
||||
@@ -222,6 +223,16 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
|
||||
""";
|
||||
|
||||
private const string SchemaV3 = """
|
||||
CREATE TABLE download_grants (
|
||||
resource_id BLOB NOT NULL,
|
||||
item_id BLOB NOT NULL,
|
||||
endpoint_hash BLOB NOT NULL,
|
||||
PRIMARY KEY (resource_id, item_id, endpoint_hash)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX ix_download_grants_resource ON download_grants(resource_id);
|
||||
""";
|
||||
|
||||
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
|
||||
{
|
||||
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
|
||||
@@ -319,6 +330,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void DeleteShareRows(Id16 id)
|
||||
{
|
||||
byte[] key = id.ToArray();
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@id", ("@id", key));
|
||||
Run("DELETE FROM share_frames WHERE share_id=@id", ("@id", key));
|
||||
Run("DELETE FROM share_items WHERE share_id=@id", ("@id", key));
|
||||
Run("DELETE FROM share_visitors WHERE share_id=@id", ("@id", key));
|
||||
@@ -351,10 +363,19 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()));
|
||||
public void DeleteShareItemRows(Id16 shareId, Id16 itemId)
|
||||
{
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
||||
Run("DELETE FROM share_frames WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
||||
Run("DELETE FROM share_items WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
|
||||
}
|
||||
|
||||
public bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Scalar(
|
||||
"SELECT 1 FROM download_grants WHERE resource_id=@r AND item_id=@i AND endpoint_hash=@e",
|
||||
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray())) is not null;
|
||||
|
||||
public void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan<byte> endpointHash) => Run(
|
||||
"INSERT OR IGNORE INTO download_grants (resource_id,item_id,endpoint_hash) VALUES (@r,@i,@e)",
|
||||
("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray()));
|
||||
|
||||
private static ShareFrame MapShareFrame(SqliteDataReader r) => new(
|
||||
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), Blob(r, 5), BlobOrNull(r, 6),
|
||||
r.IsDBNull(7) ? null : r.GetString(7));
|
||||
@@ -411,6 +432,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void DeleteChannelRows(Id16 id)
|
||||
{
|
||||
byte[] key = id.ToArray();
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@c", ("@c", key));
|
||||
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
|
||||
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
|
||||
@@ -476,6 +498,7 @@ public sealed class SqliteChannelStore : IResourceStore, IDisposable
|
||||
public void DeleteItemRows(Id16 channelId, Id16 itemId)
|
||||
{
|
||||
byte[] c = channelId.ToArray(), i = itemId.ToArray();
|
||||
Run("DELETE FROM download_grants WHERE resource_id=@c AND item_id=@i", ("@c", c), ("@i", i));
|
||||
Run("DELETE FROM frames WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
|
||||
// 墓碑保留,其余记录(帧、announce)随条目一起删除
|
||||
Run("DELETE FROM log WHERE channel_id=@c AND item_id=@i AND kind<>@t", ("@c", c), ("@i", i), ("@t", (int)LogKind.Tombstone));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user