refactor(host): single-source ChannelPolicy, space-signed file grants, whole-file registration
- all authorization rules (owner/creator, per-channel read/write grants, who may remove whom, delete rules, structure management) live in ChannelPolicy; a test fails if they are reimplemented elsewhere - ChannelInfo carries what the caller may do (can_*), member and invitation lists say who can be removed; clients display it and never derive it - the host no longer stores or calls the file service: it only signs short-lived tickets from space permissions (create/put/get/delete/domain), and keeps an opaque fileDomain reference on the channel - a whole file is registered in one request (last frame header + per-frame length and digest) instead of one request per 1 MiB frame; the signed manifest still has to match every digest - drop file quota/threshold, receipts, reconcile and delete-queue code from the host - contract generator emits constants for enums, error codes, token roles, permissions and share states
This commit is contained in:
1 parent
ccb2c074d6
commit
17638b6e20
36 files changed
+926
-589
No files matched your search
@@ -44,7 +44,6 @@ public sealed class FileApiTests : IAsyncLifetime
|
||||
{
|
||||
["WebDrop:Files:Enabled"] = "true",
|
||||
["WebDrop:Files:PublicUrl"] = _fileUrl,
|
||||
["WebDrop:Files:InternalUrl"] = _fileUrl,
|
||||
["WebDrop:Files:Secret"] = Secret,
|
||||
});
|
||||
await _main.InitializeAsync();
|
||||
@@ -116,7 +115,7 @@ public sealed class FileApiTests : IAsyncLifetime
|
||||
return ch;
|
||||
}
|
||||
|
||||
/// <summary>空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),之后才能声明对象</summary>
|
||||
/// <summary>空间里的默认频道:发布聊天和文件两个域的密钥(客户端创建空间时做的事),并为它创建一个文件域(同样是客户端做的事),之后才能声明对象</summary>
|
||||
private async Task<string> PrepareAsync(Chan ch)
|
||||
{
|
||||
string channelId;
|
||||
@@ -129,6 +128,7 @@ public sealed class FileApiTests : IAsyncLifetime
|
||||
JsonBody(new { channelId, domain, epoch = 1, envelopes = new[] { new { channelId, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } }));
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode);
|
||||
}
|
||||
await CreateDomainAsync(ch, channelId);
|
||||
return channelId;
|
||||
}
|
||||
|
||||
@@ -139,58 +139,79 @@ public sealed class FileApiTests : IAsyncLifetime
|
||||
Assert.Equal(HttpStatusCode.Created, (await _main.Http.SendAsync(declare)).StatusCode);
|
||||
}
|
||||
|
||||
// ───── 文件服务和主后端的请求 ─────
|
||||
// ───── 主后端发票据,文件服务存字节 ─────
|
||||
|
||||
private async Task<HttpResponseMessage> TicketRequestAsync(Chan ch, string item, string op, uint from = 0) =>
|
||||
await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files/ticket", Owner(ch),
|
||||
new StringContent(JsonSerializer.Serialize(new { item, op, from }), Encoding.UTF8, "application/json")));
|
||||
private readonly Dictionary<string, string> _domains = [];
|
||||
|
||||
private async Task<(string Mode, string? Url, string? Ticket)> TicketAsync(Chan ch, string item, string op, uint from = 0)
|
||||
private async Task<HttpResponseMessage> GrantRequestAsync(Chan ch, string op, string? item = null, uint from = 0) =>
|
||||
await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", Owner(ch),
|
||||
new StringContent(JsonSerializer.Serialize(new { op, item, from }), Encoding.UTF8, "application/json")));
|
||||
|
||||
private async Task<(string Url, string Domain, string Ticket)> GrantAsync(Chan ch, string op, string? item = null, uint from = 0)
|
||||
{
|
||||
using var response = await TicketRequestAsync(ch, item, op, from);
|
||||
if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException($"ticket {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||
using var response = await GrantRequestAsync(ch, op, item, from);
|
||||
if (response.StatusCode != HttpStatusCode.OK) throw new HttpRequestException($"grant {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var root = doc.RootElement;
|
||||
return (root.GetProperty("mode").GetString()!, root.TryGetProperty("url", out var u) ? u.GetString() : null, root.TryGetProperty("ticket", out var t) ? t.GetString() : null);
|
||||
return (root.GetProperty("url").GetString()!, root.GetProperty("domain").GetString()!, root.GetProperty("ticket").GetString()!);
|
||||
}
|
||||
|
||||
/// <summary>前端创建频道时做的事:向空间要「创建」票据,去文件服务建文件域,再把域 id 记到频道上</summary>
|
||||
private async Task<string> CreateDomainAsync(Chan ch, string channelId)
|
||||
{
|
||||
var grant = await GrantAsync(ch, "create");
|
||||
using var create = new HttpRequestMessage(HttpMethod.Post, "/file/v1/domains") { Content = new StringContent("{}", Encoding.UTF8, "application/json") };
|
||||
create.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||
using var created = await _fileHttp.SendAsync(create);
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await created.Content.ReadAsStringAsync());
|
||||
string domain = doc.RootElement.GetProperty("id").GetString()!;
|
||||
using var record = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/channels/{channelId}/file-domain", Owner(ch), JsonBody(new { fileDomain = domain }));
|
||||
Assert.Equal(HttpStatusCode.OK, (await _main.Http.SendAsync(record)).StatusCode);
|
||||
return _domains[ch.Id] = domain;
|
||||
}
|
||||
|
||||
/// <summary>把一帧存进文件域,返回文件服务算的摘要</summary>
|
||||
private async Task<string> StoreAsync(Chan ch, string item, byte[] frame, uint index)
|
||||
{
|
||||
var (_, _, ticket) = await TicketAsync(ch, item, "put");
|
||||
using var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/{index}") { Content = new ByteArrayContent(frame) };
|
||||
put.Headers.Add("X-WD-Ticket", ticket);
|
||||
var grant = await GrantAsync(ch, "put", item);
|
||||
using var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/{index}") { Content = new ByteArrayContent(frame) };
|
||||
put.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||
using var stored = await _fileHttp.SendAsync(put);
|
||||
Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
|
||||
using var doc = JsonDocument.Parse(await stored.Content.ReadAsStringAsync());
|
||||
return doc.RootElement.GetProperty("receipt").GetString()!;
|
||||
string digest = doc.RootElement.GetProperty("digest").GetString()!;
|
||||
Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(frame)), digest);
|
||||
return digest;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CommitAsync(Chan ch, byte[] frame, string receipt, string? maxDownloads = null, Dictionary<string, string>? signed = null)
|
||||
/// <summary>到主后端一次登记整个文件:末帧的 26 字节头部 + 每一帧的(负载长度,整帧摘要),不带任何字节</summary>
|
||||
private async Task<HttpResponseMessage> RegisterAsync(Chan ch, IReadOnlyList<byte[]> frames, string? maxDownloads = null, Dictionary<string, string>? signed = null, IReadOnlyList<string>? digests = null)
|
||||
{
|
||||
var commit = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), new ByteArrayContent(frame[..26]));
|
||||
commit.Headers.Add("X-WD-Receipt", receipt);
|
||||
if (maxDownloads is not null) commit.Headers.Add("X-WD-Max-Downloads", maxDownloads);
|
||||
foreach (var (name, value) in signed ?? []) commit.Headers.Add(name, value);
|
||||
return await _main.Http.SendAsync(commit);
|
||||
}
|
||||
|
||||
/// <summary>上传一个文件条目的帧:每帧先传给文件服务拿回执,再把头部和回执交给主后端登记;最后一帧带签名事件</summary>
|
||||
private async Task<List<HttpStatusCode>> UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList<byte[]> frames, string? maxDownloads = null, bool final = true)
|
||||
{
|
||||
var statuses = new List<HttpStatusCode>();
|
||||
var body = new byte[26 + frames.Count * 36];
|
||||
frames[^1][..26].CopyTo(body, 0);
|
||||
for (int i = 0; i < frames.Count; i++)
|
||||
{
|
||||
string receipt = await StoreAsync(ch, item, frames[i], (uint)i);
|
||||
var signed = final && i == frames.Count - 1 ? SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", frames) : null;
|
||||
using var response = await CommitAsync(ch, frames[i], receipt, i == 0 ? maxDownloads : null, signed);
|
||||
statuses.Add(response.StatusCode);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(body.AsSpan(26 + i * 36), (uint)(frames[i].Length - 26));
|
||||
Convert.FromHexString(digests?[i] ?? Convert.ToHexStringLower(SHA256.HashData(frames[i]))).CopyTo(body, 26 + i * 36 + 4);
|
||||
}
|
||||
return statuses;
|
||||
var register = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(body));
|
||||
if (maxDownloads is not null) register.Headers.Add("X-WD-Max-Downloads", maxDownloads);
|
||||
foreach (var (name, value) in signed ?? []) register.Headers.Add(name, value);
|
||||
return await _main.Http.SendAsync(register);
|
||||
}
|
||||
|
||||
private async Task<(HttpResponseMessage Response, byte[] Body)> ReadFromFilesAsync(Chan ch, string item, string ticket, uint from)
|
||||
/// <summary>上传一个文件:每一帧先存进文件域,全部存好后一次到主后端登记(末帧的签名事件盖着所有帧的摘要)</summary>
|
||||
private async Task<HttpStatusCode> UploadFileAsync(Chan ch, string channelId, string item, IReadOnlyList<byte[]> frames, string? maxDownloads = null)
|
||||
{
|
||||
using var get = new HttpRequestMessage(HttpMethod.Get, $"/f/{Hex(ch.Id)}/{Hex(item)}?from={from}");
|
||||
for (int i = 0; i < frames.Count; i++) await StoreAsync(ch, item, frames[i], (uint)i);
|
||||
using var response = await RegisterAsync(ch, frames, maxDownloads, SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", frames));
|
||||
return response.StatusCode;
|
||||
}
|
||||
|
||||
private async Task<(HttpResponseMessage Response, byte[] Body)> ReadFromFilesAsync(string domain, string item, string ticket, uint from)
|
||||
{
|
||||
using var get = new HttpRequestMessage(HttpMethod.Get, $"/file/v1/domains/{domain}/items/{Hex(item)}/frames?from={from}");
|
||||
get.Headers.Add("X-WD-Ticket", ticket);
|
||||
var response = await _fileHttp.SendAsync(get);
|
||||
return (response, await response.Content.ReadAsByteArrayAsync());
|
||||
@@ -207,18 +228,20 @@ public sealed class FileApiTests : IAsyncLifetime
|
||||
// ───── 测试 ─────
|
||||
|
||||
[Fact]
|
||||
public async Task FileContentOfAnySizeGoesToTheFileServiceWhileChatStaysOnTheMainBackend()
|
||||
public async Task FileBytesLiveInTheFileDomainWhileChatStaysOnTheHost()
|
||||
{
|
||||
Assert.Contains("\"files\":{\"enabled\":true", await _main.Http.GetStringAsync("/api/v1/info"));
|
||||
var info = await _main.Http.GetStringAsync("/api/v1/info");
|
||||
Assert.Contains("\"files\":{\"enabled\":true", info);
|
||||
Assert.DoesNotContain("threshold", info);
|
||||
|
||||
var ch = await CreateSpaceAsync();
|
||||
string channelId = await PrepareAsync(ch);
|
||||
string item = NewId(), tiny = NewId(), chat = NewId();
|
||||
await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, tiny); await DeclareAsync(ch, channelId, chat, "chat");
|
||||
var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) };
|
||||
var tinyFrame = Small(tiny, 7); // 单帧、不限次的小文件,同样放文件服务
|
||||
var tinyFrame = Small(tiny, 7);
|
||||
|
||||
// 文件的内容不能直接传给主后端,不管是多帧受限的还是单帧的小文件
|
||||
// 文件域的对象不能把字节直接交给主后端,不管多大
|
||||
using (var direct = await _main.AppendAsync(ch, Owner(ch), frames[0], maxDownloads: "1")) Assert.Equal(HttpStatusCode.Conflict, direct.StatusCode);
|
||||
using (var directSmall = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(tinyFrame)))
|
||||
{
|
||||
@@ -227,8 +250,8 @@ public sealed class FileApiTests : IAsyncLifetime
|
||||
Assert.Equal(HttpStatusCode.Conflict, rejected.StatusCode);
|
||||
}
|
||||
|
||||
Assert.All(await UploadFileAsync(ch, channelId, item, frames, "1"), s => Assert.Equal(HttpStatusCode.Created, s));
|
||||
Assert.All(await UploadFileAsync(ch, channelId, tiny, [tinyFrame]), s => Assert.Equal(HttpStatusCode.Created, s));
|
||||
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "1"));
|
||||
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, tiny, [tinyFrame]));
|
||||
|
||||
// 聊天(聊天域的对象)照旧存在主后端,日志里带着内容
|
||||
var chatFrame = Small(chat, 5);
|
||||
@@ -245,124 +268,181 @@ public sealed class FileApiTests : IAsyncLifetime
|
||||
await AssertErrorAsync(await _main.Http.SendAsync(_main.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/items/{item}/frames", Owner(ch))), 409, "use_file_api");
|
||||
|
||||
// 受限条目:要票据,按原来的规则计次(上限 1 次)
|
||||
var grant = await TicketAsync(ch, item, "get");
|
||||
Assert.Equal("remote", grant.Mode);
|
||||
var (read, body) = await ReadFromFilesAsync(ch, item, grant.Ticket!, 0);
|
||||
var grant = await GrantAsync(ch, "get", item);
|
||||
Assert.Equal(_domains[ch.Id], grant.Domain);
|
||||
var (read, body) = await ReadFromFilesAsync(grant.Domain, item, grant.Ticket, 0);
|
||||
Assert.Equal(HttpStatusCode.OK, read.StatusCode);
|
||||
Assert.Equal(frames.SelectMany(f => f).ToArray(), body);
|
||||
using (var second = await TicketRequestAsync(ch, item, "get")) Assert.Equal(HttpStatusCode.Gone, second.StatusCode);
|
||||
var resume = await TicketAsync(ch, item, "get", 2);
|
||||
Assert.Equal(frames[2], (await ReadFromFilesAsync(ch, item, resume.Ticket!, 2)).Body);
|
||||
using (var second = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.Gone, second.StatusCode);
|
||||
var resume = await GrantAsync(ch, "get", item, 2);
|
||||
Assert.Equal(frames[2], (await ReadFromFilesAsync(resume.Domain, item, resume.Ticket, 2)).Body);
|
||||
|
||||
// 单帧小文件不受限:想读几次都行
|
||||
// 单帧不限次的文件:想读几次都行
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var t = await TicketAsync(ch, tiny, "get");
|
||||
Assert.Equal("remote", t.Mode);
|
||||
Assert.Equal(tinyFrame, (await ReadFromFilesAsync(ch, tiny, t.Ticket!, 0)).Body);
|
||||
var t = await GrantAsync(ch, "get", tiny);
|
||||
Assert.Equal(tinyFrame, (await ReadFromFilesAsync(t.Domain, tiny, t.Ticket, 0)).Body);
|
||||
}
|
||||
|
||||
// 聊天条目请求下载票据得到 local:客户端继续走原来的接口
|
||||
Assert.Equal("local", (await TicketAsync(ch, chat, "get")).Mode);
|
||||
// 聊天条目没有文件域,不发文件票据
|
||||
using (var chatGrant = await GrantRequestAsync(ch, "get", chat)) Assert.Equal(HttpStatusCode.NotFound, chatGrant.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TicketsAndReceiptsAreBoundToTheirItemAndFrame()
|
||||
public async Task TicketsFollowTheSpacePermissionsAndAreBoundToTheirItem()
|
||||
{
|
||||
var ch = await CreateSpaceAsync();
|
||||
string channelId = await PrepareAsync(ch);
|
||||
string item = NewId(), other = NewId();
|
||||
await DeclareAsync(ch, channelId, item);
|
||||
await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, other);
|
||||
var f0 = Frame(item, 0, GatedEnc, EncPayload, 1);
|
||||
var f1 = Frame(item, 1, GatedFinalEnc, EncPayload, 2);
|
||||
|
||||
// 别的条目的票据传不进这个条目;没有票据也不行
|
||||
var (_, _, otherTicket) = await TicketAsync(ch, other, "put");
|
||||
using (var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/0") { Content = new ByteArrayContent(f0) })
|
||||
{
|
||||
put.Headers.Add("X-WD-Ticket", otherTicket);
|
||||
using var denied = await _fileHttp.SendAsync(put);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
|
||||
}
|
||||
using (var noTicket = await _fileHttp.PutAsync($"/f/{Hex(ch.Id)}/{Hex(item)}/0", new ByteArrayContent(f0))) Assert.Equal(HttpStatusCode.Unauthorized, noTicket.StatusCode);
|
||||
// 没有空间里的身份,什么票据都拿不到
|
||||
var stranger = ch.Owner with { Token = new byte[] { 1, 2, 3 }, AccountToken = null };
|
||||
using (var denied = await _main.Http.SendAsync(_main.Req(HttpMethod.Post, $"/api/v1/spaces/{ch.Id}/file-grants", stranger,
|
||||
new StringContent(JsonSerializer.Serialize(new { op = "create" }), Encoding.UTF8, "application/json"))))
|
||||
Assert.False(denied.IsSuccessStatusCode);
|
||||
|
||||
// 回执被篡改、或拿第 0 帧的回执去登记第 1 帧,都会被拒绝
|
||||
string receipt0 = await StoreAsync(ch, item, f0, 0);
|
||||
using (var forged = await CommitAsync(ch, f0, "x.y", "1")) Assert.Equal(HttpStatusCode.Forbidden, forged.StatusCode);
|
||||
using (var committed0 = await CommitAsync(ch, f0, receipt0, "1")) Assert.Equal(HttpStatusCode.Created, committed0.StatusCode);
|
||||
await StoreAsync(ch, item, f1, 1);
|
||||
using (var swapped = await CommitAsync(ch, f1, receipt0, null, SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1])))
|
||||
Assert.Equal(HttpStatusCode.Forbidden, swapped.StatusCode);
|
||||
// 别的条目的票据传不进这个条目;没有票据也不行
|
||||
var otherGrant = await GrantAsync(ch, "put", other);
|
||||
using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(f0) })
|
||||
{
|
||||
put.Headers.Add("X-WD-Ticket", otherGrant.Ticket);
|
||||
using var wrongItem = await _fileHttp.SendAsync(put);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, wrongItem.StatusCode);
|
||||
}
|
||||
using (var noTicket = await _fileHttp.PutAsync($"/file/v1/domains/{otherGrant.Domain}/items/{Hex(item)}/frames/0", new ByteArrayContent(f0))) Assert.Equal(HttpStatusCode.Unauthorized, noTicket.StatusCode);
|
||||
|
||||
// 同一个序号不能存不同的内容;同样的内容重传是幂等的
|
||||
var (_, _, ticketAgain) = await TicketAsync(ch, item, "put");
|
||||
using (var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 9)) })
|
||||
string digest0 = await StoreAsync(ch, item, f0, 0);
|
||||
var grant = await GrantAsync(ch, "put", item);
|
||||
using (var put = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}/frames/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 9)) })
|
||||
{
|
||||
put.Headers.Add("X-WD-Ticket", ticketAgain);
|
||||
put.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||
using var conflict = await _fileHttp.SendAsync(put);
|
||||
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
|
||||
}
|
||||
using (var again = await CommitAsync(ch, f0, await StoreAsync(ch, item, f0, 0), "1")) Assert.True(again.IsSuccessStatusCode);
|
||||
Assert.Equal(digest0, await StoreAsync(ch, item, f0, 0));
|
||||
|
||||
// 没传完的条目不能下载;不是文件的条目不能走文件服务登记
|
||||
using (var notReady = await TicketRequestAsync(ch, item, "get")) Assert.Equal(HttpStatusCode.Conflict, notReady.StatusCode);
|
||||
// 登记整个文件(一次请求):先把第二帧也存进文件域
|
||||
string digest1 = await StoreAsync(ch, item, f1, 1);
|
||||
// 末帧的签名清单盖着所有帧的摘要:登记的摘要和清单对不上,整个文件就不被接受
|
||||
using (var wrongDigest = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, digest0]))
|
||||
Assert.Equal(HttpStatusCode.Conflict, wrongDigest.StatusCode);
|
||||
using (var good = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1])))
|
||||
Assert.Equal(HttpStatusCode.Created, good.StatusCode);
|
||||
// 同一个文件重复登记幂等;摘要不同(同一个条目)就是冲突
|
||||
using (var again = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]))) Assert.True(again.IsSuccessStatusCode);
|
||||
using (var different = await RegisterAsync(ch, [f0, f1], "1", SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.publish", [f0, f1]), [digest0, new string('a', 64)]))
|
||||
Assert.Equal(HttpStatusCode.Conflict, different.StatusCode);
|
||||
// 请求体格式不对
|
||||
using (var malformed = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/files", Owner(ch), new ByteArrayContent(new byte[10])))
|
||||
using (var bad = await _main.Http.SendAsync(malformed)) Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
// 不是文件域的条目不能走登记
|
||||
string chat = NewId();
|
||||
await DeclareAsync(ch, channelId, chat, "chat");
|
||||
var chatFrame = Small(chat);
|
||||
string chatReceipt = await StoreAsync(ch, chat, chatFrame, 0);
|
||||
using (var notAFile = await CommitAsync(ch, chatFrame, chatReceipt, null, SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame])))
|
||||
using (var notAFile = await RegisterAsync(ch, [chatFrame], null, SignedHeaders(ch.Id, channelId, chat, CryptoDomain.Chat, "chat.create", [chatFrame])))
|
||||
Assert.Equal(HttpStatusCode.BadRequest, notAFile.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingAnItemOrChannelAlsoClearsTheFileService()
|
||||
public async Task ItemsInAChannelWithoutAFileDomainGetNoTicketsAndNeverRegisteredFilesCannotBeDownloaded()
|
||||
{
|
||||
var ch = await CreateSpaceAsync();
|
||||
string channelId = await PrepareAsync(ch);
|
||||
string item = NewId();
|
||||
await DeclareAsync(ch, channelId, item);
|
||||
// 字节存进了文件域、但没有到主后端登记的文件(上传到一半放弃):主后端不认识它,不发下载票据
|
||||
await StoreAsync(ch, item, Frame(item, 0, GatedEnc, EncPayload, 1), 0);
|
||||
using (var notRegistered = await GrantRequestAsync(ch, "get", item)) Assert.Equal(HttpStatusCode.NotFound, notRegistered.StatusCode);
|
||||
|
||||
// 第二个空间里的默认频道没有文件域:声明了文件对象也要不到上传票据
|
||||
var bare = await CreateSpaceAsync();
|
||||
string bareChannel;
|
||||
using (var request = _main.Req(HttpMethod.Get, $"/api/v1/spaces/{bare.Id}/structure", Owner(bare)))
|
||||
using (var response = await _main.Http.SendAsync(request))
|
||||
bareChannel = (await ReadJsonAsync(response)).GetProperty("structure").GetProperty("defaultChannelId").GetString()!;
|
||||
foreach (var domain in new[] { "chat", "files" })
|
||||
{
|
||||
using var bootstrap = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{bare.Id}/crypto/domains", Owner(bare),
|
||||
JsonBody(new { channelId = bareChannel, domain, epoch = 1, envelopes = new[] { new { channelId = bareChannel, domain, epoch = 1, username = _owner.Username, payload = $"owner-{domain}" } } }));
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await _main.Http.SendAsync(bootstrap)).StatusCode);
|
||||
}
|
||||
string orphan = NewId();
|
||||
await DeclareAsync(bare, bareChannel, orphan);
|
||||
using (var noDomain = await GrantRequestAsync(bare, "put", orphan)) Assert.Equal(HttpStatusCode.Conflict, noDomain.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeletingAFileTakesAGrantThenDeletesOnTheHostAndInTheFileDomain()
|
||||
{
|
||||
var ch = await CreateSpaceAsync();
|
||||
string channelId = await PrepareAsync(ch);
|
||||
var store = _files.Services.GetRequiredService<FrameStore>();
|
||||
|
||||
// 没传完的条目可以直接删(取消上传):文件服务上的帧跟着清掉
|
||||
string unfinished = NewId();
|
||||
await DeclareAsync(ch, channelId, unfinished);
|
||||
Assert.All(await UploadFileAsync(ch, channelId, unfinished, [Frame(unfinished, 0, GatedEnc, EncPayload, 1)], "0", final: false), s => Assert.Equal(HttpStatusCode.Created, s));
|
||||
Assert.True(store.UsedBytes > 0);
|
||||
using (var delete = await _main.Http.SendAsync(_main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{unfinished}", Owner(ch)))) Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode);
|
||||
await WaitAsync(() => store.UsedBytes == 0);
|
||||
|
||||
// 删除整个空间:里面所有文件一起清掉
|
||||
string finished = NewId();
|
||||
await DeclareAsync(ch, channelId, finished);
|
||||
Assert.All(await UploadFileAsync(ch, channelId, finished, [Small(finished, 3)]), s => Assert.Equal(HttpStatusCode.Created, s));
|
||||
Assert.True(store.UsedBytes > 0);
|
||||
using (var del = await _main.Http.SendAsync(_main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", Owner(ch)))) Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
await WaitAsync(() => store.UsedBytes == 0);
|
||||
var finishedFrame = Small(finished, 3);
|
||||
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, finished, [finishedFrame]));
|
||||
string domain = _domains[ch.Id];
|
||||
Assert.True(store.UsedBy(domain) > 0);
|
||||
|
||||
foreach (var item in new[] { finished })
|
||||
{
|
||||
// 先向空间要「删除」票据(主后端确认能删),再删主后端的条目,最后用票据清文件域里的字节
|
||||
var grant = await GrantAsync(ch, "delete", item);
|
||||
using (var hostDelete = _main.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", Owner(ch)))
|
||||
{
|
||||
// 删除文件要带作者设备签名的 file.delete 事件(清单和发布时一致)
|
||||
foreach (var (name, value) in SignedHeaders(ch.Id, channelId, item, CryptoDomain.Files, "file.delete", [finishedFrame])) hostDelete.Headers.Add(name, value);
|
||||
using var host = await _main.Http.SendAsync(hostDelete);
|
||||
Assert.Equal(HttpStatusCode.NoContent, host.StatusCode);
|
||||
}
|
||||
using var remove = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{grant.Domain}/items/{Hex(item)}");
|
||||
remove.Headers.Add("X-WD-Ticket", grant.Ticket);
|
||||
using var removed = await _fileHttp.SendAsync(remove);
|
||||
Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
|
||||
}
|
||||
Assert.Equal(0, store.UsedBy(domain));
|
||||
|
||||
// 取消上传:主后端那边还没有这个条目(整个文件传完才登记),用上传这个条目的票据就能清掉自己传的字节
|
||||
string cancelled = NewId();
|
||||
await DeclareAsync(ch, channelId, cancelled);
|
||||
await StoreAsync(ch, cancelled, Frame(cancelled, 0, GatedEnc, EncPayload, 5), 0);
|
||||
Assert.True(store.UsedBy(domain) > 0);
|
||||
var put = await GrantAsync(ch, "put", cancelled);
|
||||
using var abandon = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{put.Domain}/items/{Hex(cancelled)}");
|
||||
abandon.Headers.Add("X-WD-Ticket", put.Ticket);
|
||||
using var abandoned = await _fileHttp.SendAsync(abandon);
|
||||
Assert.Equal(HttpStatusCode.NoContent, abandoned.StatusCode);
|
||||
Assert.Equal(0, store.UsedBy(domain));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphansLeftByAbandonedUploadsAreFoundByReconciliation()
|
||||
public async Task AFileUploadAsTheClientDoesItCompletesWithCatalogAndLocation()
|
||||
{
|
||||
var ch = await CreateSpaceAsync();
|
||||
string item = NewId();
|
||||
// 只传给文件服务、从没在主后端登记过(上传到一半放弃)
|
||||
var (_, _, ticket) = await TicketAsync(ch, item, "put");
|
||||
using (var put = new HttpRequestMessage(HttpMethod.Put, $"/f/{Hex(ch.Id)}/{Hex(item)}/0") { Content = new ByteArrayContent(Frame(item, 0, GatedEnc, EncPayload, 1)) })
|
||||
string channelId = await PrepareAsync(ch);
|
||||
string item = NewId(), catalog = NewId();
|
||||
await DeclareAsync(ch, channelId, item); await DeclareAsync(ch, channelId, catalog);
|
||||
var frames = new[] { Frame(item, 0, GatedEnc, EncPayload, 1), Frame(item, 1, GatedEnc, EncPayload, 2), Frame(item, 2, GatedFinalEnc, EncPayload, 3) };
|
||||
|
||||
// 1. 字节进文件域,整个文件一次登记
|
||||
Assert.Equal(HttpStatusCode.Created, await UploadFileAsync(ch, channelId, item, frames, "0"));
|
||||
// 2. 目录条目:一个控制帧(小,不受限),走主后端,带 file.catalog 签名事件
|
||||
var control = Frame(catalog, 0, 16 | 8 | 1, EncPayload, 7);
|
||||
using (var request = _main.Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", Owner(ch), Bytes(control)))
|
||||
{
|
||||
put.Headers.Add("X-WD-Ticket", ticket);
|
||||
using var stored = await _fileHttp.SendAsync(put);
|
||||
Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
|
||||
foreach (var (name, value) in SignedHeaders(ch.Id, channelId, catalog, CryptoDomain.Files, "file.catalog", [control])) request.Headers.Add(name, value);
|
||||
using var response = await _main.Http.SendAsync(request);
|
||||
Assert.True(response.IsSuccessStatusCode, $"目录条目被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||
}
|
||||
var store = _files.Services.GetRequiredService<FrameStore>();
|
||||
Assert.Contains($"{Hex(ch.Id)}/{Hex(item)}", store.ItemsOlderThan(TimeSpan.Zero));
|
||||
|
||||
var worker = _main.App.Services.GetServices<Microsoft.Extensions.Hosting.IHostedService>().OfType<WebDrop.Api.FileReconcileWorker>().Single();
|
||||
var gateway = (WebDrop.Api.RemoteFileGateway)_main.App.Services.GetRequiredService<IFileGateway>();
|
||||
Assert.True(await worker.ReconcileAsync(gateway, CancellationToken.None, TimeSpan.Zero) >= 1);
|
||||
await WaitAsync(() => store.UsedBytes == 0);
|
||||
}
|
||||
|
||||
private static async Task WaitAsync(Func<bool> condition)
|
||||
{
|
||||
for (int i = 0; i < 100 && !condition(); i++) await Task.Delay(100);
|
||||
Assert.True(condition());
|
||||
// 3. 位置登记:文件放进频道
|
||||
using (var location = _main.Req(HttpMethod.Put, $"/api/v1/spaces/{ch.Id}/locations", Owner(ch), JsonBody(new { itemIds = new[] { item }, threadId = channelId })))
|
||||
using (var response = await _main.Http.SendAsync(location))
|
||||
Assert.True(response.IsSuccessStatusCode, $"位置登记被拒绝:{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,13 @@ namespace WebDrop.Core.Tests;
|
||||
|
||||
public sealed class ChannelCryptoTests
|
||||
{
|
||||
/// <summary>文件域的对象主后端只登记头部和摘要,字节不在这里:一个单帧文件</summary>
|
||||
private static Task Register(ChannelService svc, Id16 id, Credentials creds, byte[] frame)
|
||||
{
|
||||
svc.RegisterFileItem(id, creds, frame.AsMemory(0, Envelope.HeaderSize), [new ChannelService.FrameEntry(frame.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(frame))], null);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientMayBootstrapButCannotChooseAChannelEpochOrGrantAccess()
|
||||
{
|
||||
@@ -81,7 +88,7 @@ public sealed class ChannelCryptoTests
|
||||
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);
|
||||
await Register(env.Service, space.Id, owner, TestEnv.Frame(oldFile, 0, FrameFlags.Final, 16));
|
||||
env.Service.MoveWorkspaceItems(space.Id, owner, [oldFile.ToBase64Url()], channelId, null);
|
||||
env.Service.SetChannelHistory(space.Id, owner, channelId, chat: false, files: false);
|
||||
|
||||
@@ -98,22 +105,6 @@ public sealed class ChannelCryptoTests
|
||||
Assert.Single((await env.Service.ReadLogAsync(space.Id, member, 0, 100)).Records);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnfinishedUploadCanBeDeletedWithoutASignedEvent()
|
||||
{
|
||||
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) with { RequireSignedContent = true };
|
||||
var channelId = env.Service.GetWorkspace(space.Id, owner).Channels.Single().Id;
|
||||
var member = space.Member(memberEndpoint) with { AccountName = "member", RequireSignedContent = true }; env.Service.Join(space.Id, member);
|
||||
var file = TestEnv.NewId();
|
||||
env.Service.DeclareObject(space.Id, member, new ObjectEnvelope { ItemId = file.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
// 只传了第 0 帧,没有末帧:上传被打断
|
||||
await env.Service.AppendFrameAsync(space.Id, member, TestEnv.Frame(file, 0, FrameFlags.Gated, 16), null);
|
||||
Assert.Throws<WebDropException>(() => env.Service.DeleteItem(space.Id, space.Member(TestEnv.Endpoint()) with { AccountName = "stranger", RequireSignedContent = true }, file));
|
||||
env.Service.DeleteItem(space.Id, owner, file);
|
||||
Assert.Null(env.Service.ReadCrypto(space.Id, owner).Objects.Find(o => o.ItemId == file.ToBase64Url())?.Event);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MemberOnlySeesChannelsItWasGrantedAndNeverTheirKeysObjectsOrWrites()
|
||||
{
|
||||
|
||||
@@ -159,25 +159,36 @@ public class ChannelServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileGroupQuotaIsPerWorkspaceChannelAndFreedOnDelete()
|
||||
public async Task FileObjectBytesAreNeverStoredByTheHostAWholeFileIsRegisteredByDigestsInOneStep()
|
||||
{
|
||||
_env.Limits.MaxWorkspaceChannelBytes = 200;
|
||||
var space = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
var owner = space.Owner(_ownerEp);
|
||||
var channelId = Svc.GetWorkspace(space.Id, owner).DefaultChannelId!;
|
||||
var first = TestEnv.NewId(); var second = TestEnv.NewId();
|
||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = first.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = second.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
var item = TestEnv.NewId();
|
||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = item.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Files, Epoch = 0, KeyEnvelope = "" });
|
||||
|
||||
byte[] firstFrame = TestEnv.Frame(first, 0, FrameFlags.Final, 120);
|
||||
await Svc.AppendFrameAsync(space.Id, owner, firstFrame, null);
|
||||
Assert.Equal(firstFrame.Length, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
||||
await AssertCode(ErrorCode.TooLarge, () => Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null));
|
||||
// 文件域的对象不能把字节直接交给主后端
|
||||
byte[] first = TestEnv.Frame(item, 0, FrameFlags.Gated, 120);
|
||||
await AssertCode(ErrorCode.UseFileApi, () => Svc.AppendFrameAsync(space.Id, owner, first, null));
|
||||
|
||||
Svc.DeleteItem(space.Id, owner, first);
|
||||
Assert.Equal(0, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
||||
await Svc.AppendFrameAsync(space.Id, owner, TestEnv.Frame(second, 0, FrameFlags.Final, 60), null);
|
||||
Assert.Equal(86, Svc.GetWorkspace(space.Id, owner).Channels.Single().FileBytes);
|
||||
// 整个文件一次登记:末帧头部 + 每一帧的(负载长度,整帧摘要)
|
||||
byte[][] frames = [first, TestEnv.Frame(item, 1, FrameFlags.Gated, 80), TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Final, 40)];
|
||||
ChannelService.FrameEntry[] entries = frames.Select(f => new ChannelService.FrameEntry(f.Length - Envelope.HeaderSize, System.Security.Cryptography.SHA256.HashData(f))).ToArray();
|
||||
var header = frames[^1].AsMemory(0, Envelope.HeaderSize);
|
||||
Assert.True(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created);
|
||||
Assert.False(Svc.RegisterFileItem(space.Id, owner, header, entries, 3).Created); // 同一个文件重复登记幂等
|
||||
var other = entries.ToArray(); other[1] = new ChannelService.FrameEntry(other[1].PayloadLength, new byte[32]);
|
||||
Assert.Equal(ErrorCode.Conflict, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, header, other, 3)).Code); // 摘要不同就是冲突
|
||||
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, frames[0].AsMemory(0, Envelope.HeaderSize), entries, 3)).Code); // 头部必须是末帧
|
||||
|
||||
// 聊天域的对象不能这样登记(它们的字节就存在主后端)
|
||||
var chatItem = TestEnv.NewId();
|
||||
Svc.DeclareObject(space.Id, owner, new ObjectEnvelope { ItemId = chatItem.ToBase64Url(), ChannelId = channelId, Domain = CryptoDomain.Chat, Epoch = 0, KeyEnvelope = "" });
|
||||
var chatFrame = TestEnv.Frame(chatItem, 0, FrameFlags.Final, 40);
|
||||
Assert.Equal(ErrorCode.BadRequest, Assert.Throws<WebDropException>(() => Svc.RegisterFileItem(space.Id, owner, chatFrame.AsMemory(0, Envelope.HeaderSize),
|
||||
[new ChannelService.FrameEntry(40, System.Security.Cryptography.SHA256.HashData(chatFrame))], null)).Code);
|
||||
|
||||
Svc.DeleteItem(space.Id, owner, item);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 权限规则只写在 ChannelPolicy 一处:别的地方只许调用,不许再各写一遍。
|
||||
/// 这个测试扫描源码,发现在 ChannelPolicy 之外重新判断角色、读写授权就失败,防止重复实现长回来。
|
||||
/// </summary>
|
||||
public sealed class PolicySingleSourceTests
|
||||
{
|
||||
private static string SourceRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "src", "WebDrop.Core"))) dir = dir.Parent;
|
||||
return Path.Combine(dir?.FullName ?? throw new InvalidOperationException("找不到 server/src"), "src");
|
||||
}
|
||||
|
||||
// 这些写法只能出现在 ChannelPolicy.cs(数据类的定义和令牌校验除外)
|
||||
private static readonly (string Name, Regex Pattern)[] Forbidden =
|
||||
[
|
||||
("直接读频道访问授权的读写位", new Regex(@"\.(ChatRead|ChatWrite|FilesRead|FilesUpload)\b")),
|
||||
("直接比较是不是所有者", new Regex(@"Role\s*[!=]=\s*Role\.Owner")),
|
||||
("自己拼「所有者或创建者」", new Regex(@"AccountName\s*==\s*\w+\.CreatorAccount")),
|
||||
];
|
||||
|
||||
// 令牌校验(凭据层,不是授权规则)和数据定义,允许出现
|
||||
private static readonly string[] Allowed =
|
||||
["ChannelPolicy.cs", "WorkspaceCrypto.cs", "RequestAuth.cs", "ShareService.cs"];
|
||||
|
||||
[Fact]
|
||||
public void AuthorizationRulesAreWrittenOnlyInChannelPolicy()
|
||||
{
|
||||
var offenders = new List<string>();
|
||||
foreach (var file in Directory.EnumerateFiles(SourceRoot(), "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
if (file.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) || file.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar)) continue;
|
||||
if (Allowed.Contains(Path.GetFileName(file))) continue;
|
||||
var lines = File.ReadAllLines(file);
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i].TrimStart().StartsWith("//") || lines[i].TrimStart().StartsWith("///")) continue;
|
||||
foreach (var (name, pattern) in Forbidden)
|
||||
if (pattern.IsMatch(lines[i])) offenders.Add($"{Path.GetFileName(file)}:{i + 1} {name}: {lines[i].Trim()}");
|
||||
}
|
||||
}
|
||||
Assert.True(offenders.Count == 0, "这些地方在 ChannelPolicy 之外重新判断了权限,请改成调用 ChannelPolicy:\n" + string.Join("\n", offenders));
|
||||
}
|
||||
}
|
||||
@@ -148,4 +148,54 @@ public sealed class WorkspaceStructureTests
|
||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannel(ch.Id, alice, "新频道", null)); // 新建频道涉及密钥,只有空间主
|
||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceRoleResult(ch.Id, alice, "自封", null, null, null, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChannelFileDomainIsAnOpaqueReferenceSetOnceAndNeverInterpreted()
|
||||
{
|
||||
using var env = new TestEnv(); var endpoint = TestEnv.Endpoint(); var ch = env.Create(endpoint); var owner = ch.Owner(endpoint);
|
||||
Assert.Null(env.Service.GetWorkspace(ch.Id, owner).Channels.Single().FileDomain);
|
||||
var created = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "文件", null, "ab12-CD_34").Channel;
|
||||
Assert.Equal("ab12-CD_34", created.FileDomain);
|
||||
Assert.Throws<WebDropException>(() => env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "乱", null, "bad domain!"));
|
||||
|
||||
// 创建时没带的,以后可以补一次;补了就不能换成别的(文件已经存在那里了)
|
||||
var later = env.Service.CreateWorkspaceChannelResult(ch.Id, owner, "稍后", null).Channel;
|
||||
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
|
||||
env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-1");
|
||||
Assert.Throws<WebDropException>(() => env.Service.SetWorkspaceChannelFileDomain(ch.Id, owner, later.Id, "dom-2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChannelInfoStatesWhatTheCallerMayDoSoClientsNeverDeriveIt()
|
||||
{
|
||||
var (env, ch, owner, alice, bob, carol) = RoleSpace(); using var _env = env;
|
||||
var admins = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "管理组", null, null, null, [WorkspacePermission.RemoveMembers]).Role;
|
||||
var movers = env.Service.CreateWorkspaceRoleResult(ch.Id, owner, "结构组", null, null, null, [WorkspacePermission.ManageStructure]).Role;
|
||||
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, admins.Id, ["alice", "bob"]);
|
||||
env.Service.SetWorkspaceRoleMembers(ch.Id, owner, movers.Id, ["carol"]);
|
||||
|
||||
// 所有者:全部能力
|
||||
var asOwner = env.Service.GetChannel(ch.Id, owner);
|
||||
Assert.True(asOwner.IsOwner); Assert.True(asOwner.CanManageStructure); Assert.True(asOwner.CanDeleteAny); Assert.True(asOwner.CanDeleteOwn);
|
||||
Assert.True(asOwner.CanCreateChannel && asOwner.CanManageFolders && asOwner.CanInvite && asOwner.CanEditSettings && asOwner.CanRemoveMembers);
|
||||
|
||||
// 身份组授予的权限:被授予「管理频道和分组」的不是所有者,却能管理结构;其他所有者专属的操作它仍然做不了
|
||||
var asCarol = env.Service.GetChannel(ch.Id, carol);
|
||||
Assert.False(asCarol.IsOwner); Assert.True(asCarol.CanManageStructure); Assert.False(asCarol.CanRemoveMembers);
|
||||
Assert.False(asCarol.CanCreateChannel || asCarol.CanManageFolders || asCarol.CanInvite || asCarol.CanEditSettings);
|
||||
var asAlice = env.Service.GetChannel(ch.Id, alice);
|
||||
Assert.False(asAlice.IsOwner); Assert.False(asAlice.CanManageStructure); Assert.True(asAlice.CanRemoveMembers);
|
||||
// 默认删除策略:成员只能删自己的
|
||||
Assert.False(asAlice.CanDeleteAny); Assert.True(asAlice.CanDeleteOwn);
|
||||
|
||||
// 谁能移除谁:所有者能移除除自己外的人;被授权的不能移除自己、所有者、同样被授权的人
|
||||
var byOwner = env.Service.MembersView(ch.Id, owner).ToDictionary(v => v.Member.Username, v => v.CanRemove);
|
||||
Assert.True(byOwner["alice"]); Assert.True(byOwner["carol"]);
|
||||
Assert.False(byOwner[env.Service.GetChannel(ch.Id, owner).CreatorAccount!]);
|
||||
var byAlice = env.Service.MembersView(ch.Id, alice).ToDictionary(v => v.Member.Username, v => v.CanRemove);
|
||||
Assert.False(byAlice["alice"]); Assert.False(byAlice["bob"]); Assert.True(byAlice["carol"]);
|
||||
Assert.All(env.Service.MembersView(ch.Id, carol), view => Assert.False(view.CanRemove)); // 没有移除权限
|
||||
// 展示和执行用同一条规则:展示说不能移除的,真去移除也会被拒绝
|
||||
Assert.Throws<WebDropException>(() => env.Service.RevokeInvitation(ch.Id, alice, "bob"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user