feat(api): bind encrypted workspaces to accounts and devices

This commit is contained in:
justme committed 2026-10-13 01:38:19 +08:00
1 parent 019b15041a
commit c7d8ef15bc
49 files changed
+1904 -278

No files matched your search

@@ -0,0 +1,200 @@
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using WebDrop.Core;
using WebDrop.Storage;
using WebDrop.Api;
using Microsoft.Data.Sqlite;
using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<ServerFixture>
{
[Fact]
public async Task AccountKeyPackagesAreScopedToAcceptedMembersAndRemovedOnRevocation()
{
var ch = await server.CreateAsync(settings: new { seats = 8 });
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = Register(username);
var member = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
using (var ownerPackage = server.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, JsonBody(new { payload = "opaque-owner-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(ownerPackage)).StatusCode);
using (var ownerList = server.Req(HttpMethod.Get, "/api/v1/accounts/channels", ch.Owner))
{
var rows = await ReadJsonAsync(await server.Http.SendAsync(ownerList));
Assert.Contains(rows.EnumerateArray(), row => row.GetProperty("channel_id").GetString() == ch.Id && row.GetProperty("role").GetString() == "owner");
}
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-member-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
using (var pending = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(pending))).EnumerateArray());
using (var accept = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, token))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accept)).StatusCode);
using (var delivered = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
{
var rows = await ReadJsonAsync(await server.Http.SendAsync(delivered));
Assert.Equal("opaque-member-package", rows[0].GetProperty("payload").GetString()); Assert.Equal("member", rows[0].GetProperty("role").GetString());
}
using (var stealOwner = Request(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/key-package", ch.Owner, token, new { payload = "replaced" }))
await AssertErrorAsync(await server.Http.SendAsync(stealOwner), 401, "account_required");
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
using (var removed = Request(HttpMethod.Get, "/api/v1/accounts/channels", member, token))
Assert.Empty((await ReadJsonAsync(await server.Http.SendAsync(removed))).EnumerateArray());
using (var anonymous = Request(HttpMethod.Get, "/api/v1/accounts/channels", member))
await AssertErrorAsync(await server.Http.SendAsync(anonymous), 401, "account_required");
}
[Fact]
public async Task RevokingOneAccountDevicePreservesOtherDevicesAndChannelMembership()
{
var username = "devices_" + Guid.NewGuid().ToString("N")[..8];
var verifier = RandomNumberGenerator.GetBytes(32); var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); var point = key.ExportParameters(false).Q;
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })));
var firstId = Guid.NewGuid().ToString(); var secondId = Guid.NewGuid().ToString();
var firstToken = accounts.Authenticate(username, verifier, firstId, "Desktop")!;
var secondToken = accounts.Authenticate(username, verifier, secondId, "Phone")!;
var ch = await server.CreateAsync(settings: new { seats = 8 });
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "opaque-key-package" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
var first = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32)); var second = first with { EndpointSecret = RandomNumberGenerator.GetBytes(32) };
foreach (var (who, token) in new[] { (first, firstToken), (second, secondToken) })
{
using var joined = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who, token);
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(joined)).StatusCode);
}
Assert.Equal(2, accounts.Devices(username, firstId).Count);
Assert.Single(accounts.Devices(username, firstId), d => d.Current);
using (var impersonate = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", second, firstToken))
await AssertErrorAsync(await server.Http.SendAsync(impersonate), 401, "account_required");
using (var revoke = Request(HttpMethod.Delete, $"/api/v1/accounts/devices/{secondId}", first, firstToken))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
Assert.True(accounts.HasSession(firstToken, out _)); Assert.False(accounts.HasSession(secondToken, out _));
Assert.Single(accounts.Devices(username, firstId));
using (var read = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", first, firstToken))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(read)).StatusCode);
using (var packages = Request(HttpMethod.Get, "/api/v1/accounts/channels", first, firstToken))
Assert.Single((await ReadJsonAsync(await server.Http.SendAsync(packages))).EnumerateArray());
}
[Fact]
public void PartialSettingsPreserveAdmissionDefaults()
{
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
Assert.Equal("open", request.Settings!.JoinPolicy);
Assert.True(request.Settings.AllowGuests);
request.Settings.Validate(new ServerLimits());
var share = JsonSerializer.Deserialize("{\"settings\":{\"max_visitors\":1}}", ApiJson.Default.CreateShareRequest)!;
Assert.Equal(86400, share.Settings!.TtlSeconds); share.Settings.Validate(new ServerLimits());
}
[Fact]
public async Task NullCredentialsAreRejectedWith400InsteadOf500()
{
foreach (var path in new[] { "/api/v1/accounts/salt", "/api/v1/accounts/session" })
{
using var response = await server.Http.PostAsync(path, JsonBody(new { username = (string?)null, verifier = (string?)null }));
await AssertErrorAsync(response, 400, "bad_request");
}
}
private string Register(string username)
{
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
var verifier = RandomNumberGenerator.GetBytes(32);
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var point = key.ExportParameters(false).Q;
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28],
JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) })));
return accounts.Authenticate(username, verifier)!;
}
private HttpRequestMessage Request(HttpMethod method, string path, Who who, string? account = null, object? body = null)
{
var request = server.Req(method, path, who, body is null ? null : JsonBody(body));
request.Headers.Remove("X-WD-Account");
if (account is not null) request.Headers.Add("X-WD-Account", account);
return request;
}
[Fact]
public async Task CreationAndOwnerActionsRequireAccountIdentity()
{
var ch = await server.CreateAsync();
foreach (var path in new[] { "/api/v1/channels", "/api/v1/shares" })
{
using var request = Request(HttpMethod.Post, path, ch.Owner, body: new { });
await AssertErrorAsync(await server.Http.SendAsync(request), 401, "account_required");
}
using var infoResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner));
Assert.Equal("fixture_owner", (await ReadJsonAsync(infoResponse)).GetProperty("creator_account").GetString());
using var membersResponse = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/members", ch.Owner));
var members = await ReadJsonAsync(membersResponse);
Assert.Equal("owner", members[0].GetProperty("role").GetString());
var unrelated = Register("unrelated_" + Guid.NewGuid().ToString("N")[..8]);
using var copied = Request(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, unrelated, new { name = "stolen" });
await AssertErrorAsync(await server.Http.SendAsync(copied), 401, "account_required");
}
[Theory]
[InlineData(ChannelMode.Enc)]
[InlineData(ChannelMode.Raw)]
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
{
var ch = await server.CreateAsync(mode, new { seats = 8 });
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_password = "channel-secret" })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var guest = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var missing = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest))
await AssertErrorAsync(await server.Http.SendAsync(missing), 403, "channel_password_required");
using (var wrong = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "wrong" }))
await AssertErrorAsync(await server.Http.SendAsync(wrong), 403, "channel_password_invalid");
using (var correct = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest, body: new { password = "channel-secret" }))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(correct)).StatusCode);
using (var closed = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { allow_guests = false })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(closed)).StatusCode);
using (var stranger = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", guest with { EndpointSecret = RandomNumberGenerator.GetBytes(32) }, body: new { password = "channel-secret" }))
await AssertErrorAsync(await server.Http.SendAsync(stranger), 401, "account_required");
using (var patch = server.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { join_policy = "invite", allow_guests = false })))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(patch)).StatusCode);
var username = "invited_" + Guid.NewGuid().ToString("N")[..8];
var account = Register(username);
var member = new Who("read", mode == ChannelMode.Enc ? ch.ReadToken : null, RandomNumberGenerator.GetBytes(32));
using (var uninvited = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
await AssertErrorAsync(await server.Http.SendAsync(uninvited), 403, "invitation_required");
using (var invite = server.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/invitations", ch.Owner, JsonBody(new { username, payload = "client-encrypted-payload" })))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(invite)).StatusCode);
using (var accepted = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
Assert.Equal(HttpStatusCode.OK, (await server.Http.SendAsync(accepted)).StatusCode);
using (var impersonated = Request(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", member))
await AssertErrorAsync(await server.Http.SendAsync(impersonated), 401, "account_required");
var writer = member with { Role = "write", Token = ch.WriteToken };
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", writer, account))
{
upload.Content = Bytes(Frame(NewId(), 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16));
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
}
using (var revoke = server.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/invitations/{username}", ch.Owner))
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(revoke)).StatusCode);
using (var rejoin = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", member, account))
await AssertErrorAsync(await server.Http.SendAsync(rejoin), 403, "forbidden");
}
[Fact]
public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional()
{
var username = "digest_" + Guid.NewGuid().ToString("N")[..8];
var token = Register(username);
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
Assert.Null(accounts.Authenticate(username, new byte[32]));
using var database = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = Path.Combine(server.App.Services.GetRequiredService<WebDropOptions>().DataDir, "v2", "webdrop.db") }.ToString());
database.Open();
using var query = database.CreateCommand(); query.CommandText = "SELECT verifier FROM accounts WHERE username=$u"; query.Parameters.AddWithValue("$u", username);
var storedDigest = (byte[])query.ExecuteScalar()!;
Assert.Null(accounts.Authenticate(username, storedDigest));
Assert.True(accounts.HasSession(token, out var actual)); Assert.Equal(username, actual);
var vault = accounts.GetVault(username)!.Value;
Assert.Null(accounts.PutVault(username, new byte[28], vault.Revision + 1));
Assert.Equal(vault.Revision + 1, accounts.PutVault(username, new byte[28], vault.Revision));
accounts.RevokeSession(token); Assert.False(accounts.HasSession(token, out _));
}
}
@@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WebDrop.Api;
using WebDrop.Core;
using WebDrop.Storage;
namespace WebDrop.Api.Tests;
@@ -21,6 +22,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
public WebApplication App { get; private set; } = null!;
public HttpClient Http { get; private set; } = null!;
public Uri BaseUri { get; private set; } = null!;
public string OwnerAccountToken { get; private set; } = null!;
public ServerFixture() : this([]) { }
@@ -45,6 +47,12 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
BaseUri = new Uri(address);
Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) };
var accounts = App.Services.GetRequiredService<SqliteAccountStore>();
var verifier = RandomNumberGenerator.GetBytes(32);
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var point = key.ExportParameters(false).Q;
accounts.Register("fixture_owner", RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }));
OwnerAccountToken = accounts.Authenticate("fixture_owner", verifier)!;
}
public async Task DisposeAsync()
@@ -52,7 +60,6 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
Http.Dispose();
await App.StopAsync();
await App.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
}
@@ -86,6 +93,7 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
req.Headers.Add("X-WD-Role", who.Role);
if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token));
req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret));
if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
return req;
}
@@ -20,6 +20,7 @@ public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
ws.Options.AddSubProtocol($"wd.role.{who.Role}");
if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}");
ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}");
if (who.Role == "owner") ws.Options.AddSubProtocol($"wd.account.{B64(Convert.FromBase64String(s.OwnerAccountToken))}");
var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri;
using var cts = new CancellationTokenSource(Timeout);
@@ -20,8 +20,8 @@ public class ChannelServiceTests : IDisposable
private static void AssertCode(ErrorCode code, Action act) =>
Assert.Equal(code, Assert.Throws<WebDropException>(act).Code);
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null, string? label = null) =>
Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads, label);
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null) =>
Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads);
private Task<AppendResult> AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) =>
Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill));
@@ -44,7 +44,7 @@ public class ChannelServiceTests : IDisposable
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
};
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp));
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp, "owner"));
}
[Fact]
@@ -55,9 +55,9 @@ public class ChannelServiceTests : IDisposable
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp, "owner"));
}
[Fact]
@@ -60,7 +60,7 @@ public class EnvelopeTests
public void RejectsReservedFlagBits()
{
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
bytes[1] |= 0x10;
bytes[1] |= 0x20;
Assert.False(Envelope.TryParse(bytes, out _));
}
@@ -110,8 +110,17 @@ public class AuthTests
public class PolicyTests
{
[Fact]
public void AuthenticatedAuthorsCanManageOwnItemsAcrossDevices()
{
var first = TestEnv.Endpoint(); var second = TestEnv.Endpoint();
var item = ItemBy(first) with { AuthorAccount = "member" };
var channel = Chan(new ChannelSettings { DeletePolicy = DeletePolicy.Own });
Assert.True(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, second, "member")));
Assert.False(ChannelPolicy.CanDeleteItem(channel, item, new Principal(Role.Read, first, "other-account")));
}
private static Channel Chan(ChannelSettings s, bool writeHash = true) => new(
TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0);
TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0, "owner");
private static Item ItemBy(byte[] author) => new(
TestEnv.NewId(), TestEnv.NewId(), author, FrameFlags.None, 0, 0, 0, -1, null, 0, 0, 0, null, null);
+6 -6
View File
@@ -22,7 +22,7 @@ public sealed class RecordingEvents : IChannelEvents
}
/// <summary>一个频道的全部凭据,模拟客户端从 K / A 派生出的令牌。</summary>
public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner)
public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner, byte[] ownerEndpoint)
{
public Id16 Id { get; } = id;
public ChannelMode Mode { get; } = mode;
@@ -30,9 +30,9 @@ public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] w
public byte[] WriteToken { get; } = write;
public byte[] OwnerToken { get; } = owner;
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint);
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint);
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint);
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint, "owner");
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null);
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint, endpoint.AsSpan().SequenceEqual(ownerEndpoint) ? "owner" : null);
}
public sealed class TestEnv : IDisposable
@@ -76,9 +76,9 @@ public sealed class TestEnv : IDisposable
ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null,
WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null,
OwnerHash = Codec.Encode(TokenHash.Compute(owner)),
}, ownerEndpoint);
}, ownerEndpoint, "owner");
return new TestChannel(id, mode, read, write, owner);
return new TestChannel(id, mode, read, write, owner, ownerEndpoint);
}
/// <summary>构造一个封装。enc 模式的负载长度必须是 档位+16。</summary>
@@ -0,0 +1,24 @@
using WebDrop.Core;
namespace WebDrop.Core.Tests;
public sealed class WorkspaceControlTests
{
[Theory]
[InlineData(ChannelMode.Enc)]
[InlineData(ChannelMode.Raw)]
public async Task ThreadStructureSurvivesContentExpiryAndEviction(ChannelMode mode)
{
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint();
var channel = env.Create(endpoint, mode, new ChannelSettings { TtlSeconds = 60, MaxItems = 1 });
var id = TestEnv.NewId(); var control = FrameFlags.Final | FrameFlags.Control | (mode == ChannelMode.Enc ? FrameFlags.Enc : 0);
var normal = FrameFlags.Final | (mode == ChannelMode.Enc ? FrameFlags.Enc : 0);
int payload = mode == ChannelMode.Enc ? TestEnv.EncPayload : 16;
await env.Service.AppendFrameAsync(channel.Id, channel.Owner(endpoint), TestEnv.Frame(id, 0, control, payload), null);
env.Clock.Advance(TimeSpan.FromSeconds(59));
for (int i = 0; i < 3; i++) await env.Service.AppendFrameAsync(channel.Id, channel.Owner(endpoint), TestEnv.Frame(TestEnv.NewId(), 0, normal, payload), null);
env.Clock.Advance(TimeSpan.FromSeconds(2)); env.Service.Sweep();
Assert.NotNull(env.Store.Read(tx => tx.GetItem(channel.Id, id)));
Assert.Equal(1, env.Store.Read(tx => tx.CountControlItems(channel.Id)));
Assert.Equal(1, env.Store.Read(tx => tx.CountItems(channel.Id)));
}
}