feat(api): add account workspaces and signed content events
This commit is contained in:
1 parent
937b774a93
commit
7b8d61d1ba
63 files changed
+5407
-370
No files matched your search
@@ -12,11 +12,14 @@ namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<ServerFixture>
|
||||
{
|
||||
private sealed record TestSigner(string Username, string DeviceId, byte[] PrivateKey);
|
||||
private readonly Dictionary<string, TestSigner> _signers = new(StringComparer.Ordinal);
|
||||
|
||||
[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 ch = await server.CreateAsync(settings: new { name = "Key package space", seats = 8 });
|
||||
var username = "keys_" + Guid.NewGuid().ToString("N")[..8]; var token = await 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);
|
||||
@@ -25,8 +28,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 invite = await InviteAsync(ch, username, "opaque-member-package")) Assert.Equal(HttpStatusCode.NoContent, 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))
|
||||
@@ -46,6 +48,111 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
await AssertErrorAsync(await server.Http.SendAsync(anonymous), 401, "account_required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeviceSigningKeyRequiresAndStoresAnAccountRootCertificate()
|
||||
{
|
||||
const string username = "signing_device";
|
||||
var verifier = RandomNumberGenerator.GetBytes(32); var salt = RandomNumberGenerator.GetBytes(16);
|
||||
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var invitationPoint = invitation.ExportParameters(false).Q;
|
||||
var rootPoint = root.ExportParameters(false).Q;
|
||||
string invitePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitationPoint.X!), y = B64(invitationPoint.Y!) });
|
||||
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
|
||||
var vault = new byte[28];
|
||||
var accounts = server.App.Services.GetRequiredService<SqliteAccountStore>();
|
||||
Assert.True(accounts.Register(username, salt, verifier, vault, invitePublic, rootPublic));
|
||||
var deviceId = Guid.NewGuid().ToString("D");
|
||||
var token = accounts.Authenticate(username, verifier, deviceId, "Desktop")!;
|
||||
|
||||
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var devicePoint = device.ExportParameters(false).Q;
|
||||
byte[] x = devicePoint.X!; byte[] y = devicePoint.Y!;
|
||||
var certificate = SignatureCodec.DeviceCertificatePayload(username, deviceId, x, y);
|
||||
var signature = root.SignData(SignatureCodec.BuildMessage("device.certificate", certificate), HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
var requestBody = new { device_id = deviceId, public_key = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(x), y = B64(y) }), signature = Convert.ToBase64String(signature) };
|
||||
using var register = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key") { Content = JsonBody(requestBody) };
|
||||
register.Headers.Add("X-WD-Account", token);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(register)).StatusCode);
|
||||
|
||||
signature[0] ^= 1;
|
||||
using var forged = new HttpRequestMessage(HttpMethod.Put, "/api/v1/accounts/devices/signing-key")
|
||||
{ Content = JsonBody(requestBody with { signature = Convert.ToBase64String(signature) }) };
|
||||
forged.Headers.Add("X-WD-Account", token);
|
||||
await AssertErrorAsync(await server.Http.SendAsync(forged), 403, "forbidden");
|
||||
|
||||
using var publicKeyRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/users/{username}/key");
|
||||
publicKeyRequest.Headers.Add("X-WD-Account", token);
|
||||
var identity = await ReadJsonAsync(await server.Http.SendAsync(publicKeyRequest));
|
||||
Assert.Equal(rootPublic, identity.GetProperty("signing_public_key").GetString());
|
||||
|
||||
byte[] ownerToken = RandomNumberGenerator.GetBytes(32); byte[] endpoint = RandomNumberGenerator.GetBytes(32);
|
||||
var owner = new Who("owner", ownerToken, endpoint); string spaceId = NewId();
|
||||
using (var create = Request(HttpMethod.Post, "/api/v1/channels", owner, token,
|
||||
new { id = spaceId, mode = "raw", settings = new { name = "Signed chat", seats = 2 }, owner_hash = B64(TokenHash.Compute(ownerToken)) }))
|
||||
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(create)).StatusCode);
|
||||
string channelId;
|
||||
using (var structure = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/structure", owner, token))
|
||||
using (var response = await server.Http.SendAsync(structure))
|
||||
channelId = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
|
||||
|
||||
string itemId = NewId();
|
||||
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{spaceId}/crypto/objects", owner, token,
|
||||
new { itemId, channelId, domain = "chat", epoch = 0, keyEnvelope = "" }))
|
||||
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
|
||||
byte[] frame = Frame(itemId, 0, 1, 16);
|
||||
Codec.TryDecodeExact(spaceId, 16, out var spaceBytes); Codec.TryDecodeExact(itemId, 16, out var itemBytes);
|
||||
byte[] eventId = RandomNumberGenerator.GetBytes(16); byte[] manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
|
||||
byte[] eventPayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, eventId,
|
||||
"chat.create", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
|
||||
byte[] eventSignature = device.SignData(SignatureCodec.BuildMessage("content.event", eventPayload), HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
using (var unsigned = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
|
||||
{
|
||||
unsigned.Content = Bytes(frame);
|
||||
await AssertErrorAsync(await server.Http.SendAsync(unsigned), 403, "forbidden");
|
||||
}
|
||||
using (var upload = Request(HttpMethod.Post, $"/api/v1/channels/{spaceId}/frames", owner, token))
|
||||
{
|
||||
upload.Content = Bytes(frame);
|
||||
upload.Headers.Add("X-WD-Event-Id", B64(eventId)); upload.Headers.Add("X-WD-Event-Kind", "chat.create");
|
||||
upload.Headers.Add("X-WD-Event-Channel", channelId); upload.Headers.Add("X-WD-Event-Domain", "chat");
|
||||
upload.Headers.Add("X-WD-Event-Epoch", "0"); upload.Headers.Add("X-WD-Event-Frames", "1");
|
||||
upload.Headers.Add("X-WD-Event-Manifest", B64(manifest)); upload.Headers.Add("X-WD-Event-Signature", B64(eventSignature));
|
||||
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(upload)).StatusCode);
|
||||
}
|
||||
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
|
||||
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
|
||||
{
|
||||
var objects = (await ReadJsonAsync(cryptoResponse)).GetProperty("objects");
|
||||
var signedObject = Assert.Single(objects.EnumerateArray());
|
||||
Assert.Equal("chat.create", signedObject.GetProperty("event").GetProperty("kind").GetString());
|
||||
Assert.Equal(B64(manifest), signedObject.GetProperty("event").GetProperty("manifestHash").GetString());
|
||||
}
|
||||
using (var unsignedDelete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(unsignedDelete), 403, "forbidden");
|
||||
byte[] deleteEventId = RandomNumberGenerator.GetBytes(16);
|
||||
byte[] deletePayload = SignatureCodec.ContentEventPayload(spaceBytes, channelId, username, deviceId, deleteEventId,
|
||||
"chat.delete", CryptoDomain.Chat, 0, itemBytes, 1, manifest, []);
|
||||
byte[] deleteSignature = device.SignData(SignatureCodec.BuildMessage("content.event", deletePayload), HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
using (var delete = Request(HttpMethod.Delete, $"/api/v1/channels/{spaceId}/items/{itemId}", owner, token))
|
||||
{
|
||||
delete.Headers.Add("X-WD-Event-Id", B64(deleteEventId)); delete.Headers.Add("X-WD-Event-Kind", "chat.delete");
|
||||
delete.Headers.Add("X-WD-Event-Channel", channelId); delete.Headers.Add("X-WD-Event-Domain", "chat");
|
||||
delete.Headers.Add("X-WD-Event-Epoch", "0"); delete.Headers.Add("X-WD-Event-Frames", "1");
|
||||
delete.Headers.Add("X-WD-Event-Manifest", B64(manifest)); delete.Headers.Add("X-WD-Event-Signature", B64(deleteSignature));
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(delete)).StatusCode);
|
||||
}
|
||||
using (var cryptoRequest = Request(HttpMethod.Get, $"/api/v1/spaces/{spaceId}/crypto", owner, token))
|
||||
using (var cryptoResponse = await server.Http.SendAsync(cryptoRequest))
|
||||
{
|
||||
var signedObject = Assert.Single((await ReadJsonAsync(cryptoResponse)).GetProperty("objects").EnumerateArray());
|
||||
Assert.Equal("chat.delete", signedObject.GetProperty("deleteEvent").GetProperty("kind").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokingOneAccountDevicePreservesOtherDevicesAndChannelMembership()
|
||||
{
|
||||
@@ -56,15 +163,22 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 ch = await server.CreateAsync(settings: new { name = "Device admission space", seats = 2, join_policy = "open", allow_guests = true });
|
||||
using (var invite = await InviteAsync(ch, username, "opaque-key-package")) Assert.Equal(HttpStatusCode.NoContent, 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);
|
||||
using var response = await server.Http.SendAsync(joined);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(2, (await ReadJsonAsync(response)).GetProperty("seats_used").GetInt32());
|
||||
}
|
||||
using (var info = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner))
|
||||
Assert.Equal(2, (await ReadJsonAsync(await server.Http.SendAsync(info))).GetProperty("seats_used").GetInt32());
|
||||
var unrelated = await Register("full_" + Guid.NewGuid().ToString("N")[..8]);
|
||||
var third = new Who("read", ch.ReadToken, RandomNumberGenerator.GetBytes(32));
|
||||
using (var full = Request(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", third, unrelated))
|
||||
await AssertErrorAsync(await server.Http.SendAsync(full), 403, "seats_full");
|
||||
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))
|
||||
@@ -82,7 +196,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
public void PartialSettingsPreserveAdmissionDefaults()
|
||||
{
|
||||
var request = JsonSerializer.Deserialize("{\"settings\":{\"seats\":8}}", ApiJson.Default.CreateChannelRequest)!;
|
||||
Assert.Equal("open", request.Settings!.JoinPolicy);
|
||||
Assert.Equal(JoinPolicy.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)!;
|
||||
@@ -98,15 +212,28 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
await AssertErrorAsync(response, 400, "bad_request");
|
||||
}
|
||||
}
|
||||
private string Register(string username)
|
||||
private async Task<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)!;
|
||||
using var invitation = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var root = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var device = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var invitePoint = invitation.ExportParameters(false).Q; var rootPoint = root.ExportParameters(false).Q; var devicePoint = device.ExportParameters(false).Q;
|
||||
string invitationPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(invitePoint.X!), y = B64(invitePoint.Y!) });
|
||||
string rootPublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(rootPoint.X!), y = B64(rootPoint.Y!) });
|
||||
string devicePublic = JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(devicePoint.X!), y = B64(devicePoint.Y!) });
|
||||
Assert.True(accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], invitationPublic, rootPublic));
|
||||
string deviceId = Guid.NewGuid().ToString("D"); var token = accounts.Authenticate(username, verifier, deviceId, "Test device")!;
|
||||
var certPayload = SignatureCodec.DeviceCertificatePayload(username, deviceId, devicePoint.X!, devicePoint.Y!);
|
||||
var cert = root.SignData(SignatureCodec.BuildMessage("device.certificate", certPayload), 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 = devicePublic, signature = Convert.ToBase64String(cert) }) };
|
||||
request.Headers.Add("X-WD-Account", token);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(request)).StatusCode);
|
||||
_signers[token] = new TestSigner(username, deviceId, device.ExportPkcs8PrivateKey());
|
||||
return token;
|
||||
}
|
||||
private HttpRequestMessage Request(HttpMethod method, string path, Who who, string? account = null, object? body = null)
|
||||
{
|
||||
@@ -116,6 +243,29 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
return request;
|
||||
}
|
||||
|
||||
private Dictionary<string, string> SignedFrameHeaders(string token, string spaceId, string channelId, string itemId,
|
||||
CryptoDomain domain, int epoch, string kind, byte[] frame)
|
||||
{
|
||||
var signer = _signers[token];
|
||||
Assert.True(Codec.TryDecodeExact(spaceId, 16, out var space));
|
||||
Assert.True(Codec.TryDecodeExact(itemId, 16, out var item));
|
||||
const int frameCount = 1;
|
||||
var manifest = SignatureCodec.FrameManifestHash([SHA256.HashData(frame)]);
|
||||
var eventId = RandomNumberGenerator.GetBytes(16);
|
||||
var payload = SignatureCodec.ContentEventPayload(space, channelId, signer.Username, signer.DeviceId, eventId,
|
||||
kind, domain, epoch, item, frameCount, manifest, []);
|
||||
using var device = ECDsa.Create(); device.ImportPkcs8PrivateKey(signer.PrivateKey, out _);
|
||||
var signature = device.SignData(SignatureCodec.BuildMessage("content.event", payload), HashAlgorithmName.SHA256,
|
||||
DSASignatureFormat.IeeeP1363FixedFieldConcatenation);
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["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"] = epoch.ToString(), ["X-WD-Event-Frames"] = frameCount.ToString(),
|
||||
["X-WD-Event-Manifest"] = B64(manifest), ["X-WD-Event-Signature"] = B64(signature),
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreationAndOwnerActionsRequireAccountIdentity()
|
||||
{
|
||||
@@ -130,7 +280,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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]);
|
||||
var unrelated = await 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");
|
||||
}
|
||||
@@ -140,7 +290,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
[InlineData(ChannelMode.Raw)]
|
||||
public async Task PasswordInvitationsAndRevocationUseTheSameChannelRules(ChannelMode mode)
|
||||
{
|
||||
var ch = await server.CreateAsync(mode, new { seats = 8 });
|
||||
var ch = await server.CreateAsync(mode, new { name = "Password space", 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));
|
||||
@@ -157,20 +307,30 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
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 account = await 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 invite = await InviteAsync(ch, username, "client-encrypted-payload")) Assert.Equal(HttpStatusCode.NoContent, 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 };
|
||||
string channelId;
|
||||
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{ch.Id}/structure", ch.Owner))
|
||||
using (var structureResponse = await server.Http.SendAsync(structureRequest))
|
||||
channelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
|
||||
var itemId = NewId();
|
||||
using (var declare = Request(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/crypto/objects", writer, account,
|
||||
new { itemId, channelId, domain = "files", epoch = 1, keyEnvelope = "opaque-object-key" }))
|
||||
Assert.Equal(HttpStatusCode.Created, (await server.Http.SendAsync(declare)).StatusCode);
|
||||
byte[] frame = Frame(itemId, 0, (byte)(1 | 16 | (mode == ChannelMode.Enc ? 8 : 0)), mode == ChannelMode.Enc ? EncPayload : 16);
|
||||
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));
|
||||
upload.Content = Bytes(frame);
|
||||
foreach (var (name, value) in SignedFrameHeaders(account, ch.Id, channelId, itemId, CryptoDomain.Files, 1, "file.publish", frame))
|
||||
upload.Headers.Add(name, value);
|
||||
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))
|
||||
@@ -183,7 +343,7 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
public async Task StoredLoginDigestCannotBeUsedAsLoginCredentialAndVaultUpdatesAreConditional()
|
||||
{
|
||||
var username = "digest_" + Guid.NewGuid().ToString("N")[..8];
|
||||
var token = Register(username);
|
||||
var token = await 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());
|
||||
@@ -197,4 +357,34 @@ public sealed class AccountAdmissionTests(ServerFixture server) : IClassFixture<
|
||||
Assert.Equal(vault.Revision + 1, accounts.PutVault(username, new byte[28], vault.Revision));
|
||||
accounts.RevokeSession(token); Assert.False(accounts.HasSession(token, out _));
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> InviteAsync(Chan channel, string username, string payload)
|
||||
{
|
||||
string? defaultChannelId = null;
|
||||
using (var structureRequest = server.Req(HttpMethod.Get, $"/api/v1/spaces/{channel.Id}/structure", channel.Owner))
|
||||
using (var structureResponse = await server.Http.SendAsync(structureRequest))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, structureResponse.StatusCode);
|
||||
defaultChannelId = (await ReadJsonAsync(structureResponse)).GetProperty("structure").GetProperty("defaultChannelId").GetString();
|
||||
}
|
||||
if (channel.Mode == ChannelMode.Enc)
|
||||
{
|
||||
foreach (var domain in new[] { "chat", "files" })
|
||||
{
|
||||
using var bootstrap = server.Req(HttpMethod.Put, $"/api/v1/spaces/{channel.Id}/crypto/domains", channel.Owner,
|
||||
JsonBody(new { channelId = defaultChannelId, domain, epoch = 1, envelopes = new[] { new { channelId = defaultChannelId, domain, epoch = 1, username = "fixture_owner", payload = $"owner-{domain}" } } }));
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await server.Http.SendAsync(bootstrap)).StatusCode);
|
||||
}
|
||||
}
|
||||
using var planRequest = server.Req(HttpMethod.Get, $"/api/v1/channels/{channel.Id}/invitations/{username}/crypto-plan", channel.Owner);
|
||||
using var planResponse = await server.Http.SendAsync(planRequest); Assert.Equal(HttpStatusCode.OK, planResponse.StatusCode);
|
||||
var plan = await ReadJsonAsync(planResponse);
|
||||
var targetChannelId = plan.GetProperty("channelId").GetString();
|
||||
var envelopes = new List<object>();
|
||||
foreach (var batch in plan.GetProperty("batches").EnumerateArray())
|
||||
foreach (var recipient in batch.GetProperty("recipients").EnumerateArray())
|
||||
envelopes.Add(new { channelId = targetChannelId, domain = batch.GetProperty("domain").GetString(), epoch = batch.GetProperty("epoch").GetInt32(), username = recipient.GetString(), payload = "opaque-domain-key" });
|
||||
return await server.Http.SendAsync(server.Req(HttpMethod.Post, $"/api/v1/channels/{channel.Id}/invitations", channel.Owner,
|
||||
JsonBody(new { username, payload, domain_envelopes = envelopes })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json;
|
||||
using WebDrop.Api;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class ChannelAccessContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void GeneratedJsonDefaultsDomainEnvelopesForUnencryptedInvitations()
|
||||
{
|
||||
var request = JsonSerializer.Deserialize("""{"username":"member","payload":"opaque-grant"}""", ApiJson.Default.InvitationRequest)!;
|
||||
Assert.Null(request.DomainEnvelopes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using WebDrop.Api;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class PasskeyCeremonyTests
|
||||
{
|
||||
[Fact]
|
||||
public void CeremoniesAreOneTimeAndBoundToTheAuthenticatedAccountAndSession()
|
||||
{
|
||||
var ceremonies = new PasskeyCeremonies();
|
||||
PasskeyCeremony state = new("alice", "register", "server-state", "salt", PasskeyCeremonies.Hash("token-a"), DateTimeOffset.UtcNow.AddMinutes(1));
|
||||
var id = ceremonies.Put(state); Assert.Equal(state, ceremonies.Take(id, "alice", "register", "token-a"));
|
||||
Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
|
||||
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "bob", "register", "token-a"));
|
||||
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-b"));
|
||||
id = ceremonies.Put(state); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "login", "token-a"));
|
||||
id = ceremonies.Put(state with { Expires = DateTimeOffset.UtcNow.AddSeconds(-1) }); Assert.Throws<WebDropException>(() => ceremonies.Take(id, "alice", "register", "token-a"));
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = ch.Id, ["mode"] = "enc",
|
||||
["settings"] = new { name = "Duplicate" },
|
||||
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
|
||||
["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)),
|
||||
};
|
||||
@@ -69,6 +70,24 @@ public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
await AssertErrorAsync(await s.Http.SendAsync(badJson), 400, "bad_request");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task CreateRequiresSpaceName(string? name)
|
||||
{
|
||||
var ch = new Chan(NewId(), ChannelMode.Raw);
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = ch.Id,
|
||||
["mode"] = "raw",
|
||||
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
|
||||
};
|
||||
if (name is not null) body["settings"] = new { name };
|
||||
using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
|
||||
await AssertErrorAsync(await s.Http.SendAsync(req), 400, "space_name_required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorsUseStableCodes()
|
||||
{
|
||||
@@ -367,7 +386,7 @@ public class RateLimitTests
|
||||
{
|
||||
var ch = new Chan(NewId(), ChannelMode.Raw);
|
||||
using var req = server.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner,
|
||||
JsonBody(new { id = ch.Id, mode = "raw", owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
|
||||
JsonBody(new { id = ch.Id, mode = "raw", settings = new { name = "Rate limit space" }, owner_hash = B64(TokenHash.Compute(ch.OwnerToken)) }));
|
||||
using var r = await server.Http.SendAsync(req);
|
||||
statuses.Add((int)r.StatusCode);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,8 @@ public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
["write_hash"] = B64(TokenHash.Compute(ch.WriteToken)),
|
||||
};
|
||||
if (mode == ChannelMode.Enc) body["read_hash"] = B64(TokenHash.Compute(ch.ReadToken));
|
||||
if (settings is not null) body["settings"] = settings;
|
||||
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
|
||||
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
|
||||
|
||||
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
|
||||
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
|
||||
|
||||
@@ -105,14 +105,14 @@ public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixtu
|
||||
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", owner_hash = B64(TokenHash.Compute(channelOwnerToken)) }))))
|
||||
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", owner_hash = B64(TokenHash.Compute(secondOwnerToken)) }))))
|
||||
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));
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
using static WebDrop.Api.Tests.ServerFixture;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture<ServerFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task AccountProfileIsServerOwnedAndDoesNotChangeTheEncryptedVault()
|
||||
{
|
||||
var store = server.App.Services.GetRequiredService<SqliteAccountStore>();
|
||||
var revision = store.GetVault("fixture_owner")!.Value.Revision;
|
||||
using var patch = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "资料测试", avatar = "data:image/png;base64,AA==" }) };
|
||||
patch.Headers.Add("X-WD-Account", server.OwnerAccountToken);
|
||||
using var response = await server.Http.SendAsync(patch);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await ReadJsonAsync(response); Assert.Equal("资料测试", body.GetProperty("displayName").GetString());
|
||||
Assert.Equal(revision, store.GetVault("fixture_owner")!.Value.Revision);
|
||||
using var read = new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/profile"); read.Headers.Add("X-WD-Account", server.OwnerAccountToken);
|
||||
using var loaded = await server.Http.SendAsync(read);
|
||||
Assert.Equal("资料测试", (await ReadJsonAsync(loaded)).GetProperty("displayName").GetString());
|
||||
using var bad = new HttpRequestMessage(HttpMethod.Patch, "/api/v1/accounts/profile") { Content = JsonBody(new { displayName = "test", avatar = "https://tracker.test/avatar.png" }) };
|
||||
bad.Headers.Add("X-WD-Account", server.OwnerAccountToken);
|
||||
using var rejected = await server.Http.SendAsync(bad); Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
|
||||
using var anonymous = await server.Http.GetAsync("/api/v1/accounts/profile"); Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
|
||||
}
|
||||
[Fact]
|
||||
public async Task StructureEndpointsPersistAndRejectCrossSpaceGroups()
|
||||
{
|
||||
var a = await server.CreateAsync(); var b = await server.CreateAsync();
|
||||
using var createGroup = server.Req(HttpMethod.Post, $"/api/v1/spaces/{b.Id}/groups", b.Owner, JsonBody(new { name = "另一空间" }));
|
||||
using var groupResponse = await server.Http.SendAsync(createGroup); Assert.Equal(HttpStatusCode.Created, groupResponse.StatusCode);
|
||||
var groupBody = await ReadJsonAsync(groupResponse);
|
||||
var groupId = groupBody.GetProperty("group").GetProperty("id").GetString();
|
||||
using var wrong = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "频道", groupId }));
|
||||
using var wrongResponse = await server.Http.SendAsync(wrong); Assert.Equal(HttpStatusCode.BadRequest, wrongResponse.StatusCode);
|
||||
using var valid = server.Req(HttpMethod.Post, $"/api/v1/spaces/{a.Id}/channels", a.Owner, JsonBody(new { name = "讨论" }));
|
||||
using var response = await server.Http.SendAsync(valid); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var createdBody = await ReadJsonAsync(response);
|
||||
Assert.Equal("讨论", createdBody.GetProperty("channel").GetProperty("name").GetString());
|
||||
using var read = server.Req(HttpMethod.Get, $"/api/v1/spaces/{a.Id}/structure", a.Owner);
|
||||
using var loaded = await server.Http.SendAsync(read); Assert.Equal(2, (await ReadJsonAsync(loaded)).GetProperty("structure").GetProperty("channels").GetArrayLength());
|
||||
}
|
||||
[Fact]
|
||||
public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation()
|
||||
{
|
||||
await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxSeats"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" });
|
||||
await isolated.InitializeAsync();
|
||||
using var infoResponse = await isolated.Http.GetAsync("/api/v1/info"); var info = await ReadJsonAsync(infoResponse);
|
||||
var defaults = info.GetProperty("defaults").GetProperty("channel");
|
||||
Assert.Equal("enc", info.GetProperty("defaults").GetProperty("channel_mode").GetString());
|
||||
Assert.Equal(3, defaults.GetProperty("seats").GetInt32()); Assert.Equal(3600, defaults.GetProperty("ttl_seconds").GetInt32());
|
||||
var ch = new Chan(NewId(), ChannelMode.Enc);
|
||||
using var create = isolated.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(new { id = ch.Id, mode = "enc", settings = new { name = "Limited space", seats = 3, ttl_seconds = 3600 }, read_hash = B64(TokenHash.Compute(ch.ReadToken)), owner_hash = B64(TokenHash.Compute(ch.OwnerToken)), write_hash = B64(TokenHash.Compute(ch.WriteToken)) }));
|
||||
using var result = await isolated.Http.SendAsync(create); Assert.Equal(HttpStatusCode.Created, result.StatusCode);
|
||||
var settings = (await ReadJsonAsync(result)).GetProperty("settings");
|
||||
Assert.Equal("Limited space", settings.GetProperty("name").GetString());
|
||||
Assert.Equal(defaults.GetProperty("seats").GetInt32(), settings.GetProperty("seats").GetInt32());
|
||||
Assert.Equal(defaults.GetProperty("ttl_seconds").GetInt32(), settings.GetProperty("ttl_seconds").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
public sealed class ChannelCryptoTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
|
||||
{
|
||||
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
Bootstrap(env, space, owner, channelId);
|
||||
|
||||
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2,
|
||||
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, Username = "owner", Payload = "forbidden-rotation" }] }));
|
||||
Assert.Throws<WebDropException>(() => env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1,
|
||||
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "owner", Payload = "owner-key" },
|
||||
new DomainEnvelope { ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, Username = "member", Payload = "unauthorized-grant" }] }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerPlansAndAtomicallyCommitsNoHistoryRotationAndDefaultAccess()
|
||||
{
|
||||
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
Bootstrap(env, space, owner, channelId);
|
||||
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
|
||||
|
||||
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
||||
Assert.Equal(channelId, plan.ChannelId);
|
||||
Assert.Equal(new[] { CryptoDomain.Chat, CryptoDomain.Files }, plan.Batches.Select(b => b.Domain).Order());
|
||||
Assert.All(plan.Batches, batch => { Assert.Equal(2, batch.Epoch); Assert.Equal(new[] { "member", "owner" }, batch.Recipients.Order(StringComparer.Ordinal)); });
|
||||
|
||||
var incomplete = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
|
||||
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).Skip(1).ToList();
|
||||
Assert.Throws<WebDropException>(() => env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", incomplete));
|
||||
Assert.Empty(env.Service.Invitations(space.Id, owner));
|
||||
|
||||
var packages = plan.Batches.SelectMany(batch => batch.Recipients.Select(recipient => new DomainEnvelope
|
||||
{ ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = recipient, Payload = $"wrapped-{batch.Domain}-{recipient}" })).ToList();
|
||||
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
|
||||
var updatedChannel = env.Service.GetWorkspace(space.Id, owner).Channels.Single();
|
||||
Assert.Equal(2, updatedChannel.ChatEpoch); Assert.Equal(2, updatedChannel.FilesEpoch);
|
||||
|
||||
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
||||
var received = env.Service.ReadCrypto(space.Id, member);
|
||||
Assert.Equal(2, received.Envelopes.Count);
|
||||
Assert.All(received.Envelopes, e => Assert.Equal(2, e.Epoch));
|
||||
Assert.All(received.Access, a => { Assert.True(a.ChatRead); Assert.True(a.ChatWrite); Assert.True(a.FilesRead); Assert.True(a.FilesUpload); });
|
||||
|
||||
Assert.Throws<WebDropException>(() => env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = TestEnv.NewId().ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 1, KeyEnvelope = "old" }));
|
||||
var objectId = TestEnv.NewId().ToBase64Url();
|
||||
Assert.Equal("member", env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = objectId, ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 2, KeyEnvelope = "current" }).AuthorAccount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryEnabledPlanGrantsExistingEpochsOnlyToTheNewRecipient()
|
||||
{
|
||||
using var env = new TestEnv(); var ep = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var space = env.Create(ep); var owner = space.Owner(ep);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
Bootstrap(env, space, owner, channelId);
|
||||
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
||||
Assert.Equal(2, plan.Batches.Count);
|
||||
Assert.All(plan.Batches, batch => { Assert.Equal(1, batch.Epoch); Assert.Equal(new[] { "member" }, batch.Recipients); });
|
||||
var packages = plan.Batches.Select(batch => new DomainEnvelope { ChannelId = channelId, Domain = batch.Domain, Epoch = batch.Epoch, Username = "member", Payload = $"wrapped-{batch.Domain}" }).ToList();
|
||||
env.Service.InviteUser(space.Id, owner, "member", "opaque-account-grant", packages);
|
||||
var member = space.Member(memberEp) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
||||
|
||||
var state = env.Service.ReadCrypto(space.Id, member);
|
||||
Assert.Equal(2, state.Envelopes.Count);
|
||||
Assert.All(state.Envelopes, e => Assert.Equal("member", e.Username));
|
||||
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().ChatEpoch);
|
||||
Assert.Equal(1, env.Service.GetWorkspace(space.Id, owner).Channels.Single().FilesEpoch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnencryptedWorkspaceStillUsesServerHistoryCutoffsAndAccessRules()
|
||||
{
|
||||
using var env = new TestEnv(); var ownerEndpoint = TestEnv.Endpoint(); var memberEndpoint = TestEnv.Endpoint();
|
||||
var space = env.Create(ownerEndpoint, ChannelMode.Raw); var owner = space.Owner(ownerEndpoint);
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
var oldFile = TestEnv.NewId();
|
||||
env.Service.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = oldFile.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
await env.Service.AppendFrameAsync(space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16), null);
|
||||
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
|
||||
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
|
||||
|
||||
var plan = env.Service.InvitationPlan(space.Id, owner, "member");
|
||||
Assert.Empty(plan.Batches);
|
||||
env.Service.InviteUser(space.Id, owner, "member", "account-grant", []);
|
||||
var member = space.Member(memberEndpoint) with { AccountName = "member" }; env.Service.Join(space.Id, member);
|
||||
Assert.Empty(env.Service.GetWorkspace(space.Id, member).Locations);
|
||||
Assert.Empty((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
|
||||
|
||||
var newMessage = TestEnv.NewId();
|
||||
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = newMessage.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
|
||||
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(newMessage, 0, FrameFlags.Final, 16), null);
|
||||
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
|
||||
}
|
||||
|
||||
private static void Bootstrap(TestEnv env, TestChannel space, Credentials owner, string channelId)
|
||||
{
|
||||
foreach (var domain in new[] { CryptoDomain.Chat, CryptoDomain.Files }) env.Service.PublishDomain(space.Id, owner, new DomainPublishRequest
|
||||
{
|
||||
ChannelId = channelId, Domain = domain, Epoch = 1,
|
||||
Envelopes = [new DomainEnvelope { ChannelId = channelId, Domain = domain, Epoch = 1, Username = "owner", Payload = $"owner-{domain.ToString().ToLowerInvariant()}-key" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public class ChannelServiceTests : IDisposable
|
||||
var dup = new CreateChannelRequest
|
||||
{
|
||||
Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc,
|
||||
Settings = new ChannelSettings { Name = "Duplicate" },
|
||||
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
|
||||
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
|
||||
};
|
||||
@@ -51,9 +52,9 @@ public class ChannelServiceTests : IDisposable
|
||||
public void CreateEnforcesModeSpecificHashes()
|
||||
{
|
||||
string h() => Codec.Encode(TokenHash.Compute(TestEnv.Random(32)));
|
||||
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, ReadHash = h(), OwnerHash = h() };
|
||||
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, OwnerHash = h() };
|
||||
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw };
|
||||
var rawWithRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "Raw space" }, ReadHash = h(), OwnerHash = h() };
|
||||
var encNoRead = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Enc, Settings = new ChannelSettings { Name = "Enc space" }, OwnerHash = h() };
|
||||
var noOwner = new CreateChannelRequest { Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw, Settings = new ChannelSettings { Name = "No owner space" } };
|
||||
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp, "owner"));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp, "owner"));
|
||||
@@ -106,6 +107,32 @@ public class ChannelServiceTests : IDisposable
|
||||
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 2 });
|
||||
var memberOne = TestEnv.Endpoint();
|
||||
var memberTwo = TestEnv.Endpoint();
|
||||
var ownerTwo = TestEnv.Endpoint();
|
||||
Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device);
|
||||
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).SeatsUsed);
|
||||
Assert.Equal(4, Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Count); // 设备仍单独登记,但不计入成员人数
|
||||
|
||||
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob));
|
||||
|
||||
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), memberOne);
|
||||
Assert.Equal(2, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed); // 移除一台设备不会释放账号成员名额
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }));
|
||||
|
||||
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
|
||||
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).SeatsUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataOperationsRequireASeat()
|
||||
{
|
||||
@@ -114,6 +141,28 @@ public class ChannelServiceTests : IDisposable
|
||||
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CreateRequiresSpaceName(string? name)
|
||||
{
|
||||
var request = new CreateChannelRequest
|
||||
{
|
||||
Id = TestEnv.NewId().ToBase64Url(), Mode = ChannelMode.Raw,
|
||||
Settings = new ChannelSettings { Name = name },
|
||||
OwnerHash = Codec.Encode(TokenHash.Compute(TestEnv.Random(32))),
|
||||
};
|
||||
AssertCode(ErrorCode.SpaceNameRequired, () => Svc.CreateChannel(request, _ownerEp, "owner"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTrimsSpaceName()
|
||||
{
|
||||
var channel = _env.Create(_ownerEp, settings: new ChannelSettings { Name = " Team space " });
|
||||
Assert.Equal("Team space", Svc.GetChannel(channel.Id, channel.Owner(_ownerEp)).Settings.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ItemIdsAndFramesAreScopedToTheirChannel()
|
||||
{
|
||||
|
||||
@@ -68,6 +68,8 @@ public sealed class TestEnv : IDisposable
|
||||
var write = Random(32);
|
||||
var owner = Random(32);
|
||||
|
||||
settings ??= new ChannelSettings();
|
||||
settings.Name ??= "Test space";
|
||||
Service.CreateChannel(new CreateChannelRequest
|
||||
{
|
||||
Id = id.ToBase64Url(),
|
||||
|
||||
@@ -67,6 +67,37 @@ public class VectorTests
|
||||
Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignatureFramingAndEcdsaMatchTheVectors()
|
||||
{
|
||||
var vector = V.GetProperty("signatures");
|
||||
static byte[] B64Url(string value) => Convert.FromBase64String(value.Replace('-', '+').Replace('_', '/') + new string('=', (4 - value.Length % 4) % 4));
|
||||
var x = B64Url(vector.GetProperty("x").GetString()!);
|
||||
var y = B64Url(vector.GetProperty("y").GetString()!);
|
||||
var payload = H(vector.GetProperty("payload"));
|
||||
var kind = vector.GetProperty("kind").GetString()!;
|
||||
var signature = H(vector.GetProperty("signature"));
|
||||
Assert.Equal(H(vector.GetProperty("message")), SignatureCodec.BuildMessage(kind, payload));
|
||||
Assert.True(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
|
||||
Assert.False(SignatureCodec.VerifyP256(x, y, "chat.edit", payload, signature));
|
||||
payload[0] ^= 1;
|
||||
Assert.False(SignatureCodec.VerifyP256(x, y, kind, payload, signature));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentEventPayloadAndFrameManifestMatchTheVectors()
|
||||
{
|
||||
var vector = V.GetProperty("content_event");
|
||||
var digests = vector.GetProperty("frame_digests").EnumerateArray().Select(H).ToList();
|
||||
var manifest = SignatureCodec.FrameManifestHash(digests);
|
||||
Assert.Equal(H(vector.GetProperty("manifest_hash")), manifest);
|
||||
byte[] id(string property) => H(vector.GetProperty(property));
|
||||
Assert.Equal(H(vector.GetProperty("payload")), SignatureCodec.ContentEventPayload(
|
||||
id("space_id"), vector.GetProperty("channel_id").GetString()!, vector.GetProperty("username").GetString()!,
|
||||
vector.GetProperty("device_id").GetString()!, id("event_id"), vector.GetProperty("kind").GetString()!, CryptoDomain.Chat,
|
||||
vector.GetProperty("epoch").GetInt32(), id("item_id"), vector.GetProperty("frame_count").GetInt32(), manifest, id("target_item_id")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerTokenHashMatchesTheStoredHashes()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
public sealed class WorkspaceStructureTests
|
||||
{
|
||||
[Fact]
|
||||
public void NewSpaceHasAnAtomicInitialChannelAndChangesPersist()
|
||||
{
|
||||
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
|
||||
var state = env.Service.GetWorkspace(ch.Id, owner);
|
||||
Assert.Equal("常规", Assert.Single(state.Channels).Name);
|
||||
var group = env.Service.CreateWorkspaceGroup(ch.Id, owner, "项目").Groups.Single();
|
||||
var changed = env.Service.CreateWorkspaceChannel(ch.Id, owner, "讨论", group.Id);
|
||||
Assert.Equal(group.Id, changed.Channels.Last().GroupId);
|
||||
var reloaded = env.Service.GetWorkspace(ch.Id, owner);
|
||||
Assert.Equal(changed.Revision, reloaded.Revision); Assert.Equal(2, reloaded.Channels.Count);
|
||||
}
|
||||
[Fact]
|
||||
public void InvalidAndCrossSpaceAssociationsRollback()
|
||||
{
|
||||
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var a = env.Create(endpoint); var b = env.Create(endpoint);
|
||||
var group = env.Service.CreateWorkspaceGroup(b.Id, b.Owner(endpoint), "其他空间").Groups.Single();
|
||||
var previous = env.Service.GetWorkspace(a.Id, a.Owner(endpoint));
|
||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "讨论", group.Id));
|
||||
Assert.Equal(previous.Revision, env.Service.GetWorkspace(a.Id, a.Owner(endpoint)).Revision);
|
||||
var first = previous.Channels.Single().Id;
|
||||
var second = env.Service.CreateWorkspaceChannel(a.Id, a.Owner(endpoint), "频道2", null).Channels.Last().Id;
|
||||
var folder = env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), first, null, "目录").Folders.Single();
|
||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceFolder(a.Id, a.Owner(endpoint), second, folder.Id, "跨频道目录"));
|
||||
}
|
||||
[Fact]
|
||||
public async Task MembersCannotMoveAnotherAccountsItemsAndTheServerSuppliesTheAuthor()
|
||||
{
|
||||
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var memberEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
|
||||
var member = ch.Member(memberEp) with { AccountName = "member" };
|
||||
env.Service.Join(ch.Id, member);
|
||||
var item = TestEnv.NewId();
|
||||
await env.Service.AppendFrameAsync(ch.Id, ch.Owner(ownerEp), TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
|
||||
var thread = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
|
||||
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, member, [item.ToBase64Url()], thread, null));
|
||||
var moved = env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [item.ToBase64Url()], thread, null);
|
||||
Assert.Equal("owner", moved.Locations.Single().AuthorAccount);
|
||||
Assert.Throws<WebDropException>(() => env.Service.MoveWorkspaceItems(ch.Id, ch.Owner(ownerEp), [TestEnv.NewId().ToBase64Url()], thread, null));
|
||||
}
|
||||
[Fact]
|
||||
public void AnExistingSpaceWithoutBackendStructureIsNotAutomaticallyMigrated()
|
||||
{
|
||||
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint);
|
||||
using var connection = new Microsoft.Data.Sqlite.SqliteConnection($"Data Source={Path.Combine(env.Dir, "test.db")}");
|
||||
connection.Open();
|
||||
using var delete = connection.CreateCommand(); delete.CommandText = "DELETE FROM workspace_structures WHERE space_id=$id";
|
||||
delete.Parameters.AddWithValue("$id", ch.Id.ToArray()); delete.ExecuteNonQuery();
|
||||
var empty = env.Service.GetWorkspace(ch.Id, ch.Owner(endpoint));
|
||||
Assert.Empty(empty.Channels); Assert.Equal(0, empty.Revision);
|
||||
var added = env.Service.CreateWorkspaceChannel(ch.Id, ch.Owner(endpoint), "新频道", null);
|
||||
Assert.Equal("新频道", added.Channels.Single().Name); Assert.Equal(1, added.Revision);
|
||||
}
|
||||
[Fact]
|
||||
public async Task AllowedGuestsCanPlaceTheirUploadsButCannotCreateBusinessStructure()
|
||||
{
|
||||
using var env = new TestEnv(); var ownerEp = TestEnv.Endpoint(); var guestEp = TestEnv.Endpoint(); var ch = env.Create(ownerEp);
|
||||
var guest = ch.Member(guestEp); env.Service.Join(ch.Id, guest);
|
||||
var item = TestEnv.NewId();
|
||||
await env.Service.AppendFrameAsync(ch.Id, guest, TestEnv.Frame(item, 0, TestEnv.SmallEnc, TestEnv.EncPayload), null);
|
||||
var channelId = env.Service.GetWorkspace(ch.Id, ch.Owner(ownerEp)).Channels.Single().Id;
|
||||
Assert.Single(env.Service.MoveWorkspaceItems(ch.Id, guest, [item.ToBase64Url()], channelId, null).Locations);
|
||||
Assert.Equal(ErrorCode.AccountRequired, Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceGroup(ch.Id, guest, "访客分组")).Code);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user