feat(server): implement sealed share lifecycle
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
1 parent
e02e024605
commit
42b93ae2d8
45 files changed
+6327
No files matched your search
@@ -0,0 +1,189 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebDrop.Api;
|
||||
using WebDrop.Core;
|
||||
using static WebDrop.Api.Tests.ServerFixture;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 只实现 Cap Standalone 的 POST /{site_key}/siteverify:请求体 {secret, response},响应 {success}。
|
||||
/// 规则:secret 必须是 "s3cret",token 必须以 "good-" 开头,且每个 token 只能用一次。
|
||||
/// </summary>
|
||||
public sealed class FakeCap : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
private readonly HashSet<string> _used = [];
|
||||
public List<(string Secret, string Response)> Calls { get; } = [];
|
||||
public bool Broken { get; set; }
|
||||
public Uri BaseUri { get; private set; } = null!;
|
||||
|
||||
public const string Secret = "s3cret";
|
||||
public string SiteverifyUrl => new Uri(BaseUri, "/site1/siteverify").ToString();
|
||||
|
||||
public FakeCap()
|
||||
{
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.Configuration["urls"] = "http://127.0.0.1:0";
|
||||
builder.Logging.ClearProviders();
|
||||
_app = builder.Build();
|
||||
|
||||
_app.MapPost("/{site}/siteverify", async (HttpContext ctx) =>
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(ctx.Request.Body);
|
||||
string secret = doc.RootElement.GetProperty("secret").GetString()!;
|
||||
string response = doc.RootElement.GetProperty("response").GetString()!;
|
||||
lock (Calls) Calls.Add((secret, response));
|
||||
|
||||
if (Broken) return Results.StatusCode(500);
|
||||
bool ok;
|
||||
lock (_used) ok = secret == Secret && response.StartsWith("good-", StringComparison.Ordinal) && _used.Add(response);
|
||||
return Results.Json(new { success = ok });
|
||||
});
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
await _app.StartAsync();
|
||||
BaseUri = new Uri(_app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First());
|
||||
}
|
||||
|
||||
public async Task StopAsync() => await _app.StopAsync();
|
||||
|
||||
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
||||
}
|
||||
|
||||
public class CapTests : IAsyncLifetime
|
||||
{
|
||||
private const string WidgetEndpoint = "https://cap.example.test/site1/";
|
||||
private readonly FakeCap _cap = new();
|
||||
private ServerFixture _server = null!;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _cap.StartAsync();
|
||||
_server = ServerFixture.WithSettings(new()
|
||||
{
|
||||
["WebDrop:Captcha:Provider"] = "cap",
|
||||
["WebDrop:Captcha:ApiEndpoint"] = WidgetEndpoint,
|
||||
["WebDrop:Captcha:SiteverifyUrl"] = _cap.SiteverifyUrl,
|
||||
["WebDrop:Captcha:Secret"] = FakeCap.Secret,
|
||||
["WebDrop:Captcha:TimeoutSeconds"] = "2",
|
||||
});
|
||||
await _server.InitializeAsync();
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
await _server.DisposeAsync();
|
||||
await _cap.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InfoAdvertisesTheWidgetEndpointButNeverTheSecret()
|
||||
{
|
||||
using var resp = await _server.Http.GetAsync("/api/v1/info");
|
||||
string text = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
var captcha = JsonDocument.Parse(text).RootElement.GetProperty("captcha");
|
||||
Assert.Equal("cap", captcha.GetProperty("provider").GetString());
|
||||
Assert.True(captcha.GetProperty("enabled").GetBoolean());
|
||||
Assert.Equal(WidgetEndpoint, captcha.GetProperty("api_endpoint").GetString());
|
||||
|
||||
Assert.DoesNotContain(FakeCap.Secret, text);
|
||||
Assert.DoesNotContain("siteverify", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWithoutATokenIsRefusedAndCapIsNotCalled()
|
||||
{
|
||||
var (_, resp) = await _server.TryCreateAsync();
|
||||
using (resp) await AssertErrorAsync(resp, 428, "captcha_required");
|
||||
Assert.Empty(_cap.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWithAnInvalidTokenIsRefused()
|
||||
{
|
||||
var (_, resp) = await _server.TryCreateAsync(capToken: "bad-token");
|
||||
using (resp) await AssertErrorAsync(resp, 428, "captcha_required");
|
||||
Assert.Single(_cap.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWithAValidTokenSucceedsAndForwardsTheSecretOnlyToCap()
|
||||
{
|
||||
var (ch, resp) = await _server.TryCreateAsync(capToken: "good-1");
|
||||
using (resp) Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||
|
||||
var call = Assert.Single(_cap.Calls);
|
||||
Assert.Equal(FakeCap.Secret, call.Secret);
|
||||
Assert.Equal("good-1", call.Response);
|
||||
|
||||
// 创建之后的普通操作不需要验证码
|
||||
using var get = await _server.Http.SendAsync(_server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
|
||||
Assert.Single(_cap.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATokenCannotBeUsedTwice()
|
||||
{
|
||||
var (_, first) = await _server.TryCreateAsync(capToken: "good-once");
|
||||
using (first) Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
|
||||
var (_, second) = await _server.TryCreateAsync(capToken: "good-once");
|
||||
using (second) await AssertErrorAsync(second, 428, "captcha_required");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedTokensAreRejectedWithoutBeingForwarded()
|
||||
{
|
||||
var (_, resp) = await _server.TryCreateAsync(capToken: "good-" + new string('x', 5000));
|
||||
using (resp) await AssertErrorAsync(resp, 428, "captcha_required");
|
||||
Assert.Empty(_cap.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapMalfunctionIsReportedAsUnavailableNotAsAUserFailure()
|
||||
{
|
||||
_cap.Broken = true;
|
||||
var (_, resp) = await _server.TryCreateAsync(capToken: "good-while-broken");
|
||||
using (resp) await AssertErrorAsync(resp, 503, "captcha_unavailable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapBeingDownIsReportedAsUnavailable()
|
||||
{
|
||||
await _cap.StopAsync();
|
||||
var (_, resp) = await _server.TryCreateAsync(capToken: "good-while-down");
|
||||
using (resp) await AssertErrorAsync(resp, 503, "captcha_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
public class CaptchaConfigurationTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("cap", "", "https://cap.example.test/s/", "s3cret")] // 缺 SiteverifyUrl
|
||||
[InlineData("cap", "https://cap.example.test/s/siteverify", "", "s3cret")] // 缺 ApiEndpoint
|
||||
[InlineData("cap", "https://cap.example.test/s/siteverify", "https://cap.example.test/s/", "")] // 缺 Secret
|
||||
[InlineData("cap", "ftp://cap.example.test/s/siteverify", "https://cap.example.test/s/", "s3cret")] // 非 http(s)
|
||||
[InlineData("bogus", "", "", "")]
|
||||
public async Task IncompleteOrUnknownConfigurationFailsAtStartup(string provider, string verifyUrl, string apiEndpoint, string secret)
|
||||
{
|
||||
var server = ServerFixture.WithSettings(new()
|
||||
{
|
||||
["WebDrop:Captcha:Provider"] = provider,
|
||||
["WebDrop:Captcha:SiteverifyUrl"] = verifyUrl,
|
||||
["WebDrop:Captcha:ApiEndpoint"] = apiEndpoint,
|
||||
["WebDrop:Captcha:Secret"] = secret,
|
||||
});
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => server.InitializeAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using WebDrop.Core;
|
||||
using static WebDrop.Api.Tests.ServerFixture;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public class RestTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
{
|
||||
private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill);
|
||||
|
||||
[Fact]
|
||||
public async Task InfoIsPublicAndResponsesCarrySecurityHeaders()
|
||||
{
|
||||
using var resp = await s.Http.GetAsync("/api/v1/info");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var json = await ReadJsonAsync(resp);
|
||||
Assert.Equal(1, json.GetProperty("protocol").GetInt32());
|
||||
Assert.True(json.GetProperty("transports").GetProperty("websocket").GetBoolean());
|
||||
Assert.Equal(1048592, json.GetProperty("limits").GetProperty("max_payload_bytes").GetInt32());
|
||||
// 默认不启用验证码,不下发 Cap 的地址
|
||||
var captcha = json.GetProperty("captcha");
|
||||
Assert.Equal("none", captcha.GetProperty("provider").GetString());
|
||||
Assert.False(captcha.GetProperty("enabled").GetBoolean());
|
||||
Assert.False(captcha.TryGetProperty("api_endpoint", out _));
|
||||
|
||||
Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single());
|
||||
Assert.Equal("nosniff", resp.Headers.GetValues("X-Content-Type-Options").Single());
|
||||
Assert.Equal("no-store", resp.Headers.GetValues("Cache-Control").Single());
|
||||
Assert.Contains("frame-ancestors 'none'", resp.Headers.GetValues("Content-Security-Policy").Single());
|
||||
Assert.False(resp.Headers.Contains("Server"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateReturnsChannelInfoAndRejectsDuplicates()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
|
||||
using var get = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner));
|
||||
var info = await ReadJsonAsync(get);
|
||||
Assert.Equal(ch.Id, info.GetProperty("id").GetString());
|
||||
Assert.Equal("enc", info.GetProperty("mode").GetString());
|
||||
Assert.Equal(1, info.GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, info.GetProperty("settings").GetProperty("seats").GetInt32());
|
||||
Assert.Equal("own", info.GetProperty("settings").GetProperty("delete_policy").GetString());
|
||||
Assert.Equal(1, info.GetProperty("oldest_seq").GetInt64());
|
||||
|
||||
// 同一个 id 再创建一次
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = ch.Id, ["mode"] = "enc",
|
||||
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
|
||||
["read_hash"] = B64(TokenHash.Compute(ch.ReadToken)),
|
||||
};
|
||||
using var req = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
|
||||
await AssertErrorAsync(await s.Http.SendAsync(req), 409, "conflict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWithBadJsonIsRejected()
|
||||
{
|
||||
var ch = new Chan(NewId(), ChannelMode.Raw);
|
||||
using var badJson = s.Req(HttpMethod.Post, "/api/v1/channels", ch.Owner,
|
||||
new StringContent("{not json", System.Text.Encoding.UTF8, "application/json"));
|
||||
await AssertErrorAsync(await s.Http.SendAsync(badJson), 400, "bad_request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorsUseStableCodes()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
|
||||
// 令牌错误
|
||||
var wrong = new Who("owner", new byte[32], ch.OwnerSecret);
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", wrong)), 401, "unauthorized");
|
||||
// 频道不存在
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", ch.Owner)), 404, "not_found");
|
||||
// id 格式错误
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, "/api/v1/channels/not-an-id", ch.Owner)), 400, "bad_request");
|
||||
// 缺少角色头
|
||||
using var noRole = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/channels/{ch.Id}");
|
||||
await AssertErrorAsync(await s.Http.SendAsync(noRole), 400, "bad_request");
|
||||
// 令牌格式错误
|
||||
using var badToken = s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner);
|
||||
badToken.Headers.Authorization = new("Bearer", "###");
|
||||
await AssertErrorAsync(await s.Http.SendAsync(badToken), 400, "bad_request");
|
||||
// 未入席
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendIsIdempotentOverHttp()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
string item = NewId();
|
||||
var frame = Small(item);
|
||||
|
||||
using var first = await s.AppendAsync(ch, ch.Owner, frame);
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
Assert.Equal(1, (await ReadJsonAsync(first)).GetProperty("seq").GetInt64());
|
||||
|
||||
using var again = await s.AppendAsync(ch, ch.Owner, frame);
|
||||
Assert.Equal(HttpStatusCode.OK, again.StatusCode);
|
||||
Assert.Equal(1, (await ReadJsonAsync(again)).GetProperty("seq").GetInt64());
|
||||
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Small(item, 0x43)), 409, "conflict");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LogReturnsBinaryRecordsWithLastSeqHeader()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
string a = NewId(), b = NewId();
|
||||
await s.AppendAsync(ch, ch.Owner, Small(a, 1));
|
||||
await s.AppendAsync(ch, ch.Owner, Small(b, 2));
|
||||
|
||||
var (resp, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=0", ch.Owner);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
Assert.Equal("application/octet-stream", resp.Content.Headers.ContentType!.MediaType);
|
||||
Assert.Equal("2", resp.Headers.GetValues("X-WD-Last-Seq").Single());
|
||||
|
||||
var records = ParseLog(body);
|
||||
Assert.Equal([1L, 2L], records.Select(r => r.Seq));
|
||||
Assert.All(records, r => Assert.Equal(LogKind.Frame, r.Kind));
|
||||
Assert.Equal(Small(a, 1), records[0].Body);
|
||||
|
||||
var (_, tail) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=2", ch.Owner);
|
||||
Assert.Equal(2, ParseOne(tail).Seq);
|
||||
var (_, none) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=3", ch.Owner);
|
||||
Assert.Empty(none);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LongPollWakesWhenAFrameArrives()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
|
||||
|
||||
var started = DateTime.UtcNow;
|
||||
var poll = s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=2&wait=20", ch.Owner);
|
||||
|
||||
await Task.Delay(300);
|
||||
Assert.False(poll.IsCompleted); // 还在等
|
||||
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
|
||||
|
||||
var (_, body) = await poll.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(2, ParseOne(body).Seq);
|
||||
Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LongPollReturnsEmptyAfterTheWaitExpires()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
var (resp, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log?from=0&wait=1", ch.Owner);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
Assert.Empty(body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OversizedBodiesAreRejected()
|
||||
{
|
||||
var ch = await s.CreateAsync(ChannelMode.Raw);
|
||||
// 超过 1 MiB 负载上限但小于 Kestrel 的 2 MiB:服务自己拒绝
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 1_500_000)), 413, "too_large");
|
||||
// 超过 Kestrel 上限:框架拒绝,仍映射为同一错误码
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Owner, Frame(NewId(), 0, 1, 3_000_000)), 413, "too_large");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawChannelCarriesArbitraryBytesAndIsReadableWithoutAToken()
|
||||
{
|
||||
var ch = await s.CreateAsync(ChannelMode.Raw);
|
||||
string item = NewId();
|
||||
using var put = await s.AppendAsync(ch, ch.Owner, Frame(item, 0, 1, 300, 0x7E)); // 非 enc,任意长度
|
||||
Assert.Equal(HttpStatusCode.Created, put.StatusCode);
|
||||
|
||||
var anonymous = new Who("read", null, ch.MemberSecret);
|
||||
using var join = await s.JoinAsync(ch, anonymous);
|
||||
Assert.Equal(HttpStatusCode.OK, join.StatusCode);
|
||||
|
||||
var (_, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", anonymous);
|
||||
Assert.Equal(Frame(item, 0, 1, 300, 0x7E), ParseOne(body).Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeatsJoinListAndRelease()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
|
||||
using var joined = await s.JoinAsync(ch, ch.Reader);
|
||||
var j = await ReadJsonAsync(joined);
|
||||
Assert.Equal(2, j.GetProperty("seats_used").GetInt32());
|
||||
Assert.Equal(2, j.GetProperty("seats_max").GetInt32());
|
||||
string memberHash = j.GetProperty("endpoint_hash").GetString()!;
|
||||
|
||||
// 第三个终端被拒绝
|
||||
var third = new Who("read", ch.ReadToken, System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
|
||||
await AssertErrorAsync(await s.JoinAsync(ch, third), 403, "seats_full");
|
||||
|
||||
// 成员不能看席位列表,所有者可以
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Reader)), 403, "forbidden");
|
||||
using var list = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner));
|
||||
Assert.Equal(2, (await ReadJsonAsync(list)).GetArrayLength());
|
||||
|
||||
// 成员上传加密设备名
|
||||
using var label = await s.Http.SendAsync(s.Req(HttpMethod.Put, $"/api/v1/channels/{ch.Id}/seats/me", ch.Reader, Bytes([9, 8, 7])));
|
||||
Assert.Equal(HttpStatusCode.NoContent, label.StatusCode);
|
||||
using var list2 = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/seats", ch.Owner));
|
||||
Assert.Contains((await ReadJsonAsync(list2)).EnumerateArray(), e => e.TryGetProperty("label", out var l) && l.GetString() == B64([9, 8, 7]));
|
||||
|
||||
// 所有者释放后,成员失去访问
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "not_joined");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsCanOnlyBeChangedByTheOwner()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
await s.JoinAsync(ch, ch.Member);
|
||||
|
||||
var patch = () => JsonBody(new { seats = 4, members_can_upload = false });
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Member, patch())), 403, "forbidden");
|
||||
|
||||
using var ok = await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, patch()));
|
||||
var settings = (await ReadJsonAsync(ok)).GetProperty("settings");
|
||||
Assert.Equal(4, settings.GetProperty("seats").GetInt32());
|
||||
Assert.False(settings.GetProperty("members_can_upload").GetBoolean());
|
||||
|
||||
await AssertErrorAsync(await s.AppendAsync(ch, ch.Member, Small(NewId())), 403, "forbidden");
|
||||
await AssertErrorAsync(
|
||||
await s.Http.SendAsync(s.Req(HttpMethod.Patch, $"/api/v1/channels/{ch.Id}/settings", ch.Owner, JsonBody(new { seats = 0 }))),
|
||||
400, "bad_request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteItemLeavesATombstone()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
string item = NewId();
|
||||
await s.AppendAsync(ch, ch.Owner, Small(item));
|
||||
|
||||
using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
|
||||
var (_, body) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", ch.Owner);
|
||||
var tombstone = ParseOne(body);
|
||||
Assert.Equal(LogKind.Tombstone, tombstone.Kind);
|
||||
Assert.Equal((byte)TombstoneReason.Deleted, tombstone.Body[16]);
|
||||
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/items/{item}", ch.Owner)), 404, "not_found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedItemIsAnnouncedAndDownloadedWithACountedLimit()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
string item = NewId();
|
||||
var frames = new[]
|
||||
{
|
||||
Frame(item, 0, GatedEnc, EncPayload, 1),
|
||||
Frame(item, 1, GatedEnc, EncPayload, 2),
|
||||
Frame(item, 2, GatedFinalEnc, EncPayload, 3),
|
||||
};
|
||||
foreach (var f in frames) Assert.True((await s.AppendAsync(ch, ch.Owner, f, maxDownloads: "1")).IsSuccessStatusCode);
|
||||
|
||||
// 日志里只有一条 announce
|
||||
var (_, logBody) = await s.GetBytesAsync($"/api/v1/channels/{ch.Id}/log", ch.Owner);
|
||||
Assert.Equal(LogKind.Announce, ParseOne(logBody).Kind);
|
||||
|
||||
// 第一次下载拿到全部三帧,按顺序
|
||||
string path = $"/api/v1/channels/{ch.Id}/items/{item}/frames";
|
||||
var (resp, body) = await s.GetBytesAsync(path, ch.Owner);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
Assert.Equal(frames.SelectMany(f => f).ToArray(), body);
|
||||
|
||||
// 上限为 1:第二次从头下载被拒绝,续传(from>0)在宽限期内仍可用
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, path, ch.Owner)), 410, "gone");
|
||||
var (_, resume) = await s.GetBytesAsync(path + "?from=2", ch.Owner);
|
||||
Assert.Equal(frames[2], resume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteChannelRemovesIt()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
await s.AppendAsync(ch, ch.Owner, Small(NewId()));
|
||||
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Member)), 403, "forbidden");
|
||||
using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}", ch.Owner)), 404, "not_found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParallelClientsGetUniqueSeqsOverHttp()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
var seqs = await Task.WhenAll(Enumerable.Range(0, 40).Select(async _ =>
|
||||
{
|
||||
using var r = await s.AppendAsync(ch, ch.Owner, Small(NewId()));
|
||||
Assert.Equal(HttpStatusCode.Created, r.StatusCode);
|
||||
return (await ReadJsonAsync(r)).GetProperty("seq").GetInt64();
|
||||
}));
|
||||
Assert.Equal(Enumerable.Range(1, 40).Select(i => (long)i), seqs.Order());
|
||||
}
|
||||
}
|
||||
|
||||
public class RateLimitTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExceedingTheLimitReturns429WithTheStableErrorBody()
|
||||
{
|
||||
await using var server = await StartAsync(new() { ["WebDrop:RequestsPerMinute"] = "5" });
|
||||
|
||||
var codes = new List<int>();
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
using var r = await server.Http.GetAsync("/api/v1/info");
|
||||
codes.Add((int)r.StatusCode);
|
||||
if (r.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
Assert.Equal("rate_limited", (await ReadJsonAsync(r)).GetProperty("error").GetString());
|
||||
}
|
||||
Assert.Equal(5, codes.Count(c => c == 200));
|
||||
Assert.Equal(3, codes.Count(c => c == 429));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelCreationHasItsOwnStricterLimit()
|
||||
{
|
||||
await using var server = await StartAsync(new() { ["WebDrop:ChannelCreatesPerMinute"] = "2" });
|
||||
|
||||
var statuses = new List<int>();
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
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)) }));
|
||||
using var r = await server.Http.SendAsync(req);
|
||||
statuses.Add((int)r.StatusCode);
|
||||
}
|
||||
Assert.Equal([201, 201, 429, 429], statuses);
|
||||
}
|
||||
|
||||
private static async Task<ServerFixture> StartAsync(Dictionary<string, string> overrides)
|
||||
{
|
||||
var f = ServerFixture.WithSettings(overrides);
|
||||
await f.InitializeAsync();
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
public class ErrorResponseHeaderTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task ErrorResponsesStillCarrySecurityHeaders()
|
||||
{
|
||||
using var resp = await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", new Who("read", null, new byte[32])));
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single());
|
||||
Assert.Equal("nosniff", resp.Headers.GetValues("X-Content-Type-Options").Single());
|
||||
Assert.Equal("no-store", resp.Headers.GetValues("Cache-Control").Single());
|
||||
}
|
||||
}
|
||||
|
||||
public class CorsTests
|
||||
{
|
||||
private const string Origin = "https://app.example.test";
|
||||
|
||||
private static async Task<ServerFixture> StartAsync()
|
||||
{
|
||||
var f = ServerFixture.WithSettings(new() { ["WebDrop:CorsOrigins:0"] = Origin });
|
||||
await f.InitializeAsync();
|
||||
return f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreflightAllowsTheProtocolHeadersFromTheConfiguredOrigin()
|
||||
{
|
||||
await using var server = await StartAsync();
|
||||
using var req = new HttpRequestMessage(HttpMethod.Options, $"/api/v1/channels/{NewId()}/frames");
|
||||
req.Headers.Add("Origin", Origin);
|
||||
req.Headers.Add("Access-Control-Request-Method", "POST");
|
||||
req.Headers.Add("Access-Control-Request-Headers", "authorization,x-wd-role,x-wd-endpoint,x-wd-max-downloads,x-wd-cap,content-type");
|
||||
|
||||
using var resp = await server.Http.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NoContent, resp.StatusCode);
|
||||
Assert.Equal(Origin, resp.Headers.GetValues("Access-Control-Allow-Origin").Single());
|
||||
string allowed = resp.Headers.GetValues("Access-Control-Allow-Headers").Single().ToLowerInvariant();
|
||||
Assert.Contains("x-wd-role", allowed);
|
||||
Assert.Contains("authorization", allowed);
|
||||
Assert.Contains("x-wd-cap", allowed); // 创建频道要带验证码 token
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ErrorResponsesAreReadableFromTheConfiguredOrigin()
|
||||
{
|
||||
await using var server = await StartAsync();
|
||||
using var req = server.Req(HttpMethod.Get, $"/api/v1/channels/{NewId()}", new Who("read", null, new byte[32]));
|
||||
req.Headers.Add("Origin", Origin);
|
||||
|
||||
using var resp = await server.Http.SendAsync(req);
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
// 浏览器端要能读到 4xx 的错误体,响应必须带 CORS 头
|
||||
Assert.Equal(Origin, resp.Headers.GetValues("Access-Control-Allow-Origin").Single());
|
||||
Assert.Equal("no-referrer", resp.Headers.GetValues("Referrer-Policy").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OtherOriginsGetNoCorsHeaders()
|
||||
{
|
||||
await using var server = await StartAsync();
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/info");
|
||||
req.Headers.Add("Origin", "https://evil.example.test");
|
||||
using var resp = await server.Http.SendAsync(req);
|
||||
Assert.False(resp.Headers.Contains("Access-Control-Allow-Origin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LastSeqHeaderIsExposedToBrowsers()
|
||||
{
|
||||
await using var server = await StartAsync();
|
||||
var ch = await server.CreateAsync();
|
||||
using var req = server.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/log", ch.Owner);
|
||||
req.Headers.Add("Origin", Origin);
|
||||
using var resp = await server.Http.SendAsync(req);
|
||||
Assert.Contains("X-WD-Last-Seq", resp.Headers.GetValues("Access-Control-Expose-Headers").Single());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WebDrop.Api;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
/// <summary>在真实 Kestrel 上启动服务(随机端口、临时数据目录),测试走真实的 HTTP / WebSocket / SSE。</summary>
|
||||
public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-api-" + Guid.NewGuid().ToString("N"));
|
||||
private readonly Dictionary<string, string> _overrides;
|
||||
|
||||
public WebApplication App { get; private set; } = null!;
|
||||
public HttpClient Http { get; private set; } = null!;
|
||||
public Uri BaseUri { get; private set; } = null!;
|
||||
|
||||
public ServerFixture() : this([]) { }
|
||||
|
||||
private ServerFixture(Dictionary<string, string> overrides) => _overrides = overrides;
|
||||
|
||||
/// <summary>xunit 要求 class fixture 只有一个公开构造函数,带配置覆盖的版本走工厂方法。</summary>
|
||||
public static ServerFixture WithSettings(Dictionary<string, string> overrides) => new(overrides);
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
App = WebDropApp.Build([], b =>
|
||||
{
|
||||
b.Configuration["WebDrop:DataDir"] = _dir;
|
||||
b.Configuration["WebDrop:RequestsPerMinute"] = "1000000";
|
||||
b.Configuration["WebDrop:ChannelCreatesPerMinute"] = "1000000";
|
||||
foreach (var (key, value) in _overrides) b.Configuration[key] = value;
|
||||
b.Configuration["urls"] = "http://127.0.0.1:0";
|
||||
b.Logging.ClearProviders();
|
||||
});
|
||||
await App.StartAsync();
|
||||
|
||||
string address = App.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First();
|
||||
BaseUri = new Uri(address);
|
||||
Http = new HttpClient { BaseAddress = BaseUri, Timeout = TimeSpan.FromSeconds(30) };
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
Http.Dispose();
|
||||
await App.StopAsync();
|
||||
await App.DisposeAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
ValueTask IAsyncDisposable.DisposeAsync() => new(DisposeAsync());
|
||||
|
||||
// ───── 客户端侧的凭据与请求构造(模拟浏览器从 K / A 派生出的令牌) ─────
|
||||
|
||||
public sealed class Chan(string id, ChannelMode mode)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
public ChannelMode Mode { get; } = mode;
|
||||
public byte[] ReadToken { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] WriteToken { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] OwnerToken { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] OwnerSecret { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] MemberSecret { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
public Who Owner => new("owner", OwnerToken, OwnerSecret);
|
||||
public Who Member => new("write", WriteToken, MemberSecret);
|
||||
public Who Reader => new("read", ReadToken, MemberSecret);
|
||||
}
|
||||
|
||||
public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret);
|
||||
|
||||
public static string B64(byte[] bytes) => Codec.Encode(bytes);
|
||||
public static string NewId() => B64(RandomNumberGenerator.GetBytes(16));
|
||||
|
||||
public HttpRequestMessage Req(HttpMethod method, string path, Who who, HttpContent? content = null)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path) { Content = content };
|
||||
req.Headers.Add("X-WD-Role", who.Role);
|
||||
if (who.Token is not null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", B64(who.Token));
|
||||
req.Headers.Add("X-WD-Endpoint", B64(who.EndpointSecret));
|
||||
return req;
|
||||
}
|
||||
|
||||
public async Task<Chan> CreateAsync(ChannelMode mode = ChannelMode.Enc, object? settings = null)
|
||||
{
|
||||
var (ch, resp) = await TryCreateAsync(mode, settings);
|
||||
using (resp) Assert.Equal(System.Net.HttpStatusCode.Created, resp.StatusCode);
|
||||
return ch;
|
||||
}
|
||||
|
||||
/// <summary>创建频道并返回原始响应,用于测试验证码等失败路径。</summary>
|
||||
public async Task<(Chan Chan, HttpResponseMessage Resp)> TryCreateAsync(
|
||||
ChannelMode mode = ChannelMode.Enc, object? settings = null, string? capToken = null)
|
||||
{
|
||||
var ch = new Chan(NewId(), mode);
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = ch.Id,
|
||||
["mode"] = mode == ChannelMode.Enc ? "enc" : "raw",
|
||||
["owner_hash"] = B64(TokenHash.Compute(ch.OwnerToken)),
|
||||
["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;
|
||||
|
||||
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);
|
||||
return (ch, await Http.SendAsync(req));
|
||||
}
|
||||
|
||||
public static StringContent JsonBody(object value) =>
|
||||
new(JsonSerializer.Serialize(value), System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
public static ByteArrayContent Bytes(byte[] bytes)
|
||||
{
|
||||
var c = new ByteArrayContent(bytes);
|
||||
c.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
return c;
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> JoinAsync(Chan ch, Who who) =>
|
||||
await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who));
|
||||
|
||||
public async Task<HttpResponseMessage> AppendAsync(Chan ch, Who who, byte[] frame, string? maxDownloads = null)
|
||||
{
|
||||
var req = Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/frames", who, Bytes(frame));
|
||||
if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads);
|
||||
return await Http.SendAsync(req);
|
||||
}
|
||||
|
||||
public async Task<(HttpResponseMessage Resp, byte[] Body)> GetBytesAsync(string path, Who who)
|
||||
{
|
||||
var resp = await Http.SendAsync(Req(HttpMethod.Get, path, who));
|
||||
return (resp, await resp.Content.ReadAsByteArrayAsync());
|
||||
}
|
||||
|
||||
public static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage resp) =>
|
||||
JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
|
||||
public static async Task AssertErrorAsync(HttpResponseMessage resp, int status, string code)
|
||||
{
|
||||
Assert.Equal(status, (int)resp.StatusCode);
|
||||
Assert.Equal(code, (await ReadJsonAsync(resp)).GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
// ───── 帧与日志的线格式 ─────
|
||||
|
||||
public const int EncPayload = 1024 + 16;
|
||||
public const byte FinalEnc = 1 | 8;
|
||||
public const byte GatedEnc = 4 | 8;
|
||||
public const byte GatedFinalEnc = 4 | 8 | 1;
|
||||
|
||||
public static byte[] Frame(string itemId, uint index, byte flags, int payloadLength, byte fill = 0x42)
|
||||
{
|
||||
Codec.TryDecodeExact(itemId, 16, out var item);
|
||||
var bytes = new byte[26 + payloadLength];
|
||||
bytes[0] = 1;
|
||||
bytes[1] = flags;
|
||||
item.CopyTo(bytes, 2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(18), index);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), (uint)payloadLength);
|
||||
bytes.AsSpan(26).Fill(fill);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public sealed record Rec(long Seq, LogKind Kind, byte[] Body);
|
||||
|
||||
/// <summary>解析 log 接口返回的记录拼接:seq(8) ‖ kind(1) ‖ body。frame 的 body 自带 payload_len,其余定长。</summary>
|
||||
public static List<Rec> ParseLog(byte[] data)
|
||||
{
|
||||
var list = new List<Rec>();
|
||||
int p = 0;
|
||||
while (p < data.Length)
|
||||
{
|
||||
long seq = BinaryPrimitives.ReadInt64BigEndian(data.AsSpan(p));
|
||||
var kind = (LogKind)data[p + 8];
|
||||
int bodyLen = kind switch
|
||||
{
|
||||
LogKind.Frame => 26 + (int)BinaryPrimitives.ReadUInt32BigEndian(data.AsSpan(p + 9 + 22)),
|
||||
LogKind.Tombstone => 17,
|
||||
LogKind.Announce => 25,
|
||||
_ => throw new InvalidOperationException("未知记录类型"),
|
||||
};
|
||||
list.Add(new Rec(seq, kind, data[(p + 9)..(p + 9 + bodyLen)]));
|
||||
p += 9 + bodyLen;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static Rec ParseOne(byte[] data) => Assert.Single(ParseLog(data));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using WebDrop.Core;
|
||||
using static WebDrop.Api.Tests.ServerFixture;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public sealed class ShareTests(ServerFixture server) : IClassFixture<ServerFixture>
|
||||
{
|
||||
private sealed class ShareClient(string id)
|
||||
{
|
||||
public string Id { get; } = id;
|
||||
public byte[] ReadToken { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] OwnerToken { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] OwnerEndpoint { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] VisitorEndpoint { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
public byte[] OtherEndpoint { get; } = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
public Who Owner => new("owner", OwnerToken, OwnerEndpoint);
|
||||
public Who CreatorReader => new("read", ReadToken, OwnerEndpoint);
|
||||
public Who Visitor => new("read", ReadToken, VisitorEndpoint);
|
||||
public Who OtherVisitor => new("read", ReadToken, OtherEndpoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftSealsIntoReadOnlyShareWithVisitorAndDownloadLimits()
|
||||
{
|
||||
var share = new ShareClient(NewId());
|
||||
using (var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new
|
||||
{
|
||||
id = share.Id,
|
||||
read_hash = B64(TokenHash.Compute(share.ReadToken)),
|
||||
owner_hash = B64(TokenHash.Compute(share.OwnerToken)),
|
||||
settings = new { ttl_seconds = 3600, max_visitors = 1, file_max_downloads = 1 },
|
||||
})))
|
||||
using (var created = await server.Http.SendAsync(req))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
Assert.Equal("draft", (await ReadJsonAsync(created)).GetProperty("state").GetString());
|
||||
}
|
||||
|
||||
using (var draftRead = await server.Http.SendAsync(server.Req(HttpMethod.Get, $"/api/v1/shares/{share.Id}", share.Owner)))
|
||||
Assert.Equal(HttpStatusCode.OK, draftRead.StatusCode);
|
||||
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get,
|
||||
$"/api/v1/shares/{share.Id}", share.CreatorReader)), 404, "not_found");
|
||||
|
||||
string manifestId = NewId();
|
||||
byte[] manifest = Frame(manifestId, 0, FinalEnc, EncPayload, 0x11);
|
||||
using (var putManifest = await server.Http.SendAsync(server.Req(HttpMethod.Post,
|
||||
$"/api/v1/shares/{share.Id}/frames", share.Owner, Bytes(manifest))))
|
||||
Assert.Equal(HttpStatusCode.Created, putManifest.StatusCode);
|
||||
|
||||
string fileId = NewId();
|
||||
byte[] first = Frame(fileId, 0, GatedEnc, EncPayload, 0x21);
|
||||
byte[] last = Frame(fileId, 1, GatedFinalEnc, EncPayload, 0x22);
|
||||
using (var putFirst = await SendFrame(share, share.Owner, first, "1"))
|
||||
Assert.Equal(HttpStatusCode.Created, putFirst.StatusCode);
|
||||
using (var putLast = await SendFrame(share, share.Owner, last))
|
||||
Assert.Equal(HttpStatusCode.Created, putLast.StatusCode);
|
||||
|
||||
using (var finish = await server.Http.SendAsync(server.Req(HttpMethod.Post,
|
||||
$"/api/v1/shares/{share.Id}/finish", share.Owner, JsonBody(new { manifest_item_id = manifestId }))))
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.OK, finish.StatusCode);
|
||||
var info = await ReadJsonAsync(finish);
|
||||
Assert.Equal("sealed", info.GetProperty("state").GetString());
|
||||
Assert.Equal(manifestId, info.GetProperty("manifest_item_id").GetString());
|
||||
Assert.Single(info.GetProperty("items").EnumerateArray()); // manifest is not a downloadable file
|
||||
}
|
||||
|
||||
await AssertErrorAsync(await SendFrame(share, share.Owner, Frame(NewId(), 0, FinalEnc, EncPayload)), 409, "sealed");
|
||||
using (var invalidRole = await server.Http.SendAsync(server.Req(HttpMethod.Get,
|
||||
$"/api/v1/shares/{share.Id}", new Who("write", share.ReadToken, share.OtherEndpoint))))
|
||||
await AssertErrorAsync(invalidRole, 400, "bad_request");
|
||||
|
||||
var (manifestResponse, manifestBody) = await server.GetBytesAsync(
|
||||
$"/api/v1/shares/{share.Id}/manifest", share.CreatorReader);
|
||||
using (manifestResponse) Assert.Equal(manifest, manifestBody);
|
||||
|
||||
var (visitorManifestResponse, visitorManifest) = await server.GetBytesAsync(
|
||||
$"/api/v1/shares/{share.Id}/manifest", share.Visitor);
|
||||
using (visitorManifestResponse) Assert.Equal(manifest, visitorManifest);
|
||||
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get,
|
||||
$"/api/v1/shares/{share.Id}/manifest", share.OtherVisitor)), 403, "visitors_full");
|
||||
|
||||
string downloadPath = $"/api/v1/shares/{share.Id}/items/{fileId}/frames";
|
||||
var (downloadResponse, downloaded) = await server.GetBytesAsync(downloadPath, share.Visitor);
|
||||
using (downloadResponse) Assert.Equal(first.Concat(last), downloaded);
|
||||
await AssertErrorAsync(await server.Http.SendAsync(server.Req(HttpMethod.Get, downloadPath, share.Visitor)), 410, "gone");
|
||||
var (resumeResponse, resumed) = await server.GetBytesAsync(downloadPath + "?from=1", share.Visitor);
|
||||
using (resumeResponse) Assert.Equal(last, resumed);
|
||||
|
||||
using var finalInfo = await server.Http.SendAsync(server.Req(HttpMethod.Get,
|
||||
$"/api/v1/shares/{share.Id}", share.CreatorReader));
|
||||
var file = (await ReadJsonAsync(finalInfo)).GetProperty("items").EnumerateArray().Single();
|
||||
Assert.Equal(0, file.GetProperty("downloads_left").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShareAndChannelIdsShareOneUniqueNamespace()
|
||||
{
|
||||
var share = new ShareClient(NewId());
|
||||
using (var created = await CreateShare(share)) Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
|
||||
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)) }))))
|
||||
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)) }))))
|
||||
Assert.Equal(HttpStatusCode.Created, channel.StatusCode);
|
||||
|
||||
using var shareCollision = await CreateShare(new ShareClient(channelId));
|
||||
await AssertErrorAsync(shareCollision, 409, "conflict");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> CreateShare(ShareClient share)
|
||||
{
|
||||
using var req = server.Req(HttpMethod.Post, "/api/v1/shares", share.Owner, JsonBody(new
|
||||
{
|
||||
id = share.Id,
|
||||
read_hash = B64(TokenHash.Compute(share.ReadToken)),
|
||||
owner_hash = B64(TokenHash.Compute(share.OwnerToken)),
|
||||
}));
|
||||
return await server.Http.SendAsync(req);
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendFrame(ShareClient share, Who who, byte[] bytes, string? maxDownloads = null)
|
||||
{
|
||||
using var req = server.Req(HttpMethod.Post, $"/api/v1/shares/{share.Id}/frames", who, Bytes(bytes));
|
||||
if (maxDownloads is not null) req.Headers.Add("X-WD-Max-Downloads", maxDownloads);
|
||||
return await server.Http.SendAsync(req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using WebDrop.Core;
|
||||
using static WebDrop.Api.Tests.ServerFixture;
|
||||
|
||||
namespace WebDrop.Api.Tests;
|
||||
|
||||
public class StreamingTests(ServerFixture s) : IClassFixture<ServerFixture>
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
private static byte[] Small(string item, byte fill = 0x42) => Frame(item, 0, FinalEnc, EncPayload, fill);
|
||||
|
||||
private async Task<ClientWebSocket> ConnectWs(Chan ch, Who who, long from = 0)
|
||||
{
|
||||
var ws = new ClientWebSocket();
|
||||
ws.Options.AddSubProtocol("wd.v1");
|
||||
ws.Options.AddSubProtocol($"wd.role.{who.Role}");
|
||||
if (who.Token is not null) ws.Options.AddSubProtocol($"wd.auth.{B64(who.Token)}");
|
||||
ws.Options.AddSubProtocol($"wd.ep.{B64(who.EndpointSecret)}");
|
||||
|
||||
var uri = new UriBuilder(s.BaseUri) { Scheme = "ws", Path = $"/api/v1/channels/{ch.Id}/ws", Query = $"from={from}" }.Uri;
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
await ws.ConnectAsync(uri, cts.Token);
|
||||
return ws;
|
||||
}
|
||||
|
||||
private static async Task<(WebSocketMessageType Type, byte[] Data)> Receive(ClientWebSocket ws)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
var buffer = new byte[2 * 1024 * 1024];
|
||||
int total = 0;
|
||||
while (true)
|
||||
{
|
||||
var r = await ws.ReceiveAsync(buffer.AsMemory(total), cts.Token);
|
||||
if (r.MessageType == WebSocketMessageType.Close) return (WebSocketMessageType.Close, []);
|
||||
total += r.Count;
|
||||
if (r.EndOfMessage) return (r.MessageType, buffer[..total]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketReplaysHistoryThenPushesNewFrames()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
string a = NewId(), b = NewId();
|
||||
await s.AppendAsync(ch, ch.Owner, Small(a, 1));
|
||||
|
||||
using var ws = await ConnectWs(ch, ch.Owner);
|
||||
Assert.Equal("wd.v1", ws.SubProtocol);
|
||||
|
||||
var (type, data) = await Receive(ws);
|
||||
Assert.Equal(WebSocketMessageType.Binary, type);
|
||||
var first = ParseOne(data);
|
||||
Assert.Equal(1, first.Seq);
|
||||
Assert.Equal(Small(a, 1), first.Body);
|
||||
|
||||
// 连接建立之后追加的帧会被实时推送
|
||||
await s.AppendAsync(ch, ch.Owner, Small(b, 2));
|
||||
var (_, pushed) = await Receive(ws);
|
||||
Assert.Equal(2, ParseOne(pushed).Seq);
|
||||
Assert.Equal(Small(b, 2), ParseOne(pushed).Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketResumesFromTheRequestedSeq()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
for (int i = 0; i < 4; i++) await s.AppendAsync(ch, ch.Owner, Small(NewId(), (byte)i));
|
||||
|
||||
using var ws = await ConnectWs(ch, ch.Owner, from: 3);
|
||||
Assert.Equal(3, ParseOne((await Receive(ws)).Data).Seq);
|
||||
Assert.Equal(4, ParseOne((await Receive(ws)).Data).Seq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketDeliversUploadsFromAnotherMember()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
await s.JoinAsync(ch, ch.Member);
|
||||
|
||||
using var owner = await ConnectWs(ch, ch.Owner);
|
||||
using var member = await ConnectWs(ch, ch.Member);
|
||||
|
||||
string item = NewId();
|
||||
using var put = await s.AppendAsync(ch, ch.Member, Small(item, 9));
|
||||
Assert.Equal(HttpStatusCode.Created, put.StatusCode);
|
||||
|
||||
foreach (var ws in new[] { owner, member })
|
||||
{
|
||||
var (_, data) = await Receive(ws);
|
||||
Assert.Equal(Small(item, 9), ParseOne(data).Body);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketUpgradeIsRefusedWithoutValidCredentials()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
|
||||
// 令牌错误:在升级之前就以 HTTP 401 拒绝,而不是先建立连接再断开
|
||||
var bad = new Who("owner", new byte[32], ch.OwnerSecret);
|
||||
var ex = await Assert.ThrowsAsync<WebSocketException>(() => ConnectWs(ch, bad));
|
||||
Assert.Contains("401", ex.Message);
|
||||
|
||||
// 未入席
|
||||
var stranger = new Who("write", ch.WriteToken, new byte[32]);
|
||||
var ex2 = await Assert.ThrowsAsync<WebSocketException>(() => ConnectWs(ch, stranger));
|
||||
Assert.Contains("403", ex2.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketNotifiesSeatChangesAndClosesWhenRevoked()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
var joined = await s.JoinAsync(ch, ch.Member);
|
||||
string memberHash = (await ReadJsonAsync(joined)).GetProperty("endpoint_hash").GetString()!;
|
||||
|
||||
using var member = await ConnectWs(ch, ch.Member);
|
||||
|
||||
// 所有者释放席位:订阅者先收到 seats 控制消息,随后被以 not_joined 关闭
|
||||
using var release = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}/seats/{memberHash}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, release.StatusCode);
|
||||
|
||||
bool closed = false;
|
||||
for (int i = 0; i < 5 && !closed; i++)
|
||||
{
|
||||
var (type, data) = await Receive(member);
|
||||
if (type == WebSocketMessageType.Close)
|
||||
{
|
||||
closed = true;
|
||||
Assert.Equal(WebSocketCloseStatus.PolicyViolation, member.CloseStatus);
|
||||
Assert.Equal("not_joined", member.CloseStatusDescription);
|
||||
}
|
||||
else if (type == WebSocketMessageType.Text)
|
||||
{
|
||||
Assert.Equal("seats", JsonDocument.Parse(data).RootElement.GetProperty("t").GetString());
|
||||
}
|
||||
}
|
||||
Assert.True(closed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketEndsWhenTheChannelIsDeleted()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
using var ws = await ConnectWs(ch, ch.Owner);
|
||||
|
||||
using var del = await s.Http.SendAsync(s.Req(HttpMethod.Delete, $"/api/v1/channels/{ch.Id}", ch.Owner));
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
|
||||
var (type, _) = await Receive(ws);
|
||||
Assert.Equal(WebSocketMessageType.Close, type);
|
||||
Assert.Equal("not_found", ws.CloseStatusDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WebSocketClientCanCloseCleanly()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
using var ws = await ConnectWs(ch, ch.Owner);
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, cts.Token);
|
||||
Assert.Equal(WebSocketState.Closed, ws.State);
|
||||
}
|
||||
|
||||
// ───── SSE ─────
|
||||
|
||||
private sealed record SseEvent(string Event, string? Id, string Data);
|
||||
|
||||
private async IAsyncEnumerable<SseEvent> ReadSse(string path, Who who, Dictionary<string, string>? extraHeaders = null)
|
||||
{
|
||||
using var req = s.Req(HttpMethod.Get, path, who);
|
||||
foreach (var (k, v) in extraHeaders ?? []) req.Headers.Add(k, v);
|
||||
using var cts = new CancellationTokenSource(Timeout);
|
||||
using var resp = await s.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
Assert.Equal("text/event-stream", resp.Content.Headers.ContentType!.MediaType);
|
||||
|
||||
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
|
||||
string ev = "message", data = "";
|
||||
string? id = null;
|
||||
while (await reader.ReadLineAsync(cts.Token) is { } line)
|
||||
{
|
||||
if (line.Length == 0)
|
||||
{
|
||||
if (data.Length > 0) yield return new SseEvent(ev, id, data);
|
||||
ev = "message"; data = ""; id = null;
|
||||
}
|
||||
else if (line.StartsWith("event: ")) ev = line[7..];
|
||||
else if (line.StartsWith("id: ")) id = line[4..];
|
||||
else if (line.StartsWith("data: ")) data = line[6..];
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] DecodeB64(string text) => System.Buffers.Text.Base64Url.DecodeFromChars(text);
|
||||
|
||||
[Fact]
|
||||
public async Task SseStreamsHistoryThenNewFrames()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
string a = NewId(), b = NewId();
|
||||
await s.AppendAsync(ch, ch.Owner, Small(a, 1));
|
||||
|
||||
await using var events = ReadSse($"/api/v1/channels/{ch.Id}/events", ch.Owner).GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await events.MoveNextAsync());
|
||||
Assert.Equal("log", events.Current.Event);
|
||||
Assert.Equal("1", events.Current.Id);
|
||||
Assert.Equal(Small(a, 1), ParseOne(DecodeB64(events.Current.Data)).Body);
|
||||
|
||||
await s.AppendAsync(ch, ch.Owner, Small(b, 2));
|
||||
Assert.True(await events.MoveNextAsync());
|
||||
Assert.Equal("2", events.Current.Id);
|
||||
Assert.Equal(Small(b, 2), ParseOne(DecodeB64(events.Current.Data)).Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SseResumesFromLastEventId()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
for (int i = 0; i < 3; i++) await s.AppendAsync(ch, ch.Owner, Small(NewId(), (byte)i));
|
||||
|
||||
await using var events = ReadSse($"/api/v1/channels/{ch.Id}/events", ch.Owner,
|
||||
new() { ["Last-Event-ID"] = "2" }).GetAsyncEnumerator();
|
||||
|
||||
Assert.True(await events.MoveNextAsync());
|
||||
Assert.Equal("3", events.Current.Id); // 断线前处理到 2,续传从 3 开始
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SseRefusesBadCredentialsWithAJsonError()
|
||||
{
|
||||
var ch = await s.CreateAsync();
|
||||
var bad = new Who("owner", new byte[32], ch.OwnerSecret);
|
||||
await AssertErrorAsync(await s.Http.SendAsync(s.Req(HttpMethod.Get, $"/api/v1/channels/{ch.Id}/events", bad)), 401, "unauthorized");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\WebDrop.Api\WebDrop.Api.csproj" />
|
||||
<ProjectReference Include="..\..\src\WebDrop.Core\WebDrop.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\WebDrop.Storage\WebDrop.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,654 @@
|
||||
using System.Buffers.Binary;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
public class ChannelServiceTests : IDisposable
|
||||
{
|
||||
private readonly TestEnv _env = new();
|
||||
private ChannelService Svc => _env.Service;
|
||||
private readonly byte[] _ownerEp = TestEnv.Endpoint();
|
||||
private readonly byte[] _memberEp = TestEnv.Endpoint();
|
||||
|
||||
public void Dispose() => _env.Dispose();
|
||||
|
||||
// ───── 辅助 ─────
|
||||
|
||||
private static async Task AssertCode(ErrorCode code, Func<Task> act) =>
|
||||
Assert.Equal(code, (await Assert.ThrowsAsync<WebDropException>(act)).Code);
|
||||
|
||||
private static void AssertCode(ErrorCode code, Action act) =>
|
||||
Assert.Equal(code, Assert.Throws<WebDropException>(act).Code);
|
||||
|
||||
private Task<AppendResult> Append(TestChannel ch, Credentials who, byte[] frame, long? maxDownloads = null, string? label = null) =>
|
||||
Svc.AppendFrameAsync(ch.Id, who, frame, maxDownloads, label);
|
||||
|
||||
private Task<AppendResult> AppendSmall(TestChannel ch, Credentials who, Id16? item = null, byte fill = 0x42, FrameFlags extra = FrameFlags.None) =>
|
||||
Append(ch, who, TestEnv.Frame(item ?? TestEnv.NewId(), 0, TestEnv.SmallEnc | extra, TestEnv.EncPayload, fill));
|
||||
|
||||
private static (long Seq, LogKind Kind) Head(LogRecord r) =>
|
||||
(BinaryPrimitives.ReadInt64BigEndian(r.Bytes), (LogKind)r.Bytes[8]);
|
||||
|
||||
private async Task<List<(long Seq, LogKind Kind)>> LogOf(TestChannel ch, Credentials who, long from = 0) =>
|
||||
(await Svc.ReadLogAsync(ch.Id, who, from, 1000)).Records.Select(Head).ToList();
|
||||
|
||||
// ───── 创建 ─────
|
||||
|
||||
[Fact]
|
||||
public void CreateRejectsDuplicateId()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var dup = new CreateChannelRequest
|
||||
{
|
||||
Id = ch.Id.ToBase64Url(), Mode = ChannelMode.Enc,
|
||||
ReadHash = Codec.Encode(TokenHash.Compute(ch.ReadToken)),
|
||||
OwnerHash = Codec.Encode(TokenHash.Compute(ch.OwnerToken)),
|
||||
};
|
||||
AssertCode(ErrorCode.Conflict, () => Svc.CreateChannel(dup, _ownerEp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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 };
|
||||
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(rawWithRead, _ownerEp));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(encNoRead, _ownerEp));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.CreateChannel(noOwner, _ownerEp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSeatsTheOwnerAndReportsDefaults()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
Assert.Equal(1, info.SeatsUsed);
|
||||
Assert.Equal(2, info.Settings.Seats);
|
||||
Assert.Equal(1, info.OldestSeq);
|
||||
Assert.Equal(0, info.LastSeq);
|
||||
}
|
||||
|
||||
// ───── 鉴权与席位 ─────
|
||||
|
||||
[Fact]
|
||||
public void WrongTokenIsUnauthorized()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, TestEnv.Random(32), _ownerEp)));
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Read, null, _ownerEp)));
|
||||
// 用读令牌冒充所有者
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.GetChannel(ch.Id, new Credentials(Role.Owner, ch.ReadToken, _ownerEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownOrExpiredChannelIsNotFound()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(TestEnv.NewId(), ch.Owner(_ownerEp)));
|
||||
_env.Clock.Advance(TimeSpan.FromHours(25));
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JoinIsIdempotentAndEnforcesSeatLimit()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var a = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
var again = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
Assert.Equal(a.EndpointHash, again.EndpointHash);
|
||||
Assert.Equal(2, again.SeatsUsed);
|
||||
Assert.Equal(2, again.SeatsMax);
|
||||
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
|
||||
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataOperationsRequireASeat()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
|
||||
await AssertCode(ErrorCode.NotJoined, () => LogOf(ch, ch.Reader(_memberEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleasedSeatLosesAccess()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var joined = Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
await AppendSmall(ch, ch.Member(_memberEp));
|
||||
|
||||
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash));
|
||||
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
|
||||
}
|
||||
|
||||
private static byte[] Codec_Decode(string b64) =>
|
||||
Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException();
|
||||
|
||||
[Fact]
|
||||
public void OnlyTheOwnerManagesSeatsAndSettings()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 }));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp));
|
||||
|
||||
var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp));
|
||||
Assert.Equal(2, seats.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatLabelIsStoredAndBounded()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]);
|
||||
var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single();
|
||||
Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label);
|
||||
|
||||
AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257]));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSettingsAppliesPatchAndRecomputesExpiry()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 3, TtlSeconds = 3600 });
|
||||
|
||||
Assert.Equal(3, after.Settings.Seats);
|
||||
Assert.Equal(3600, after.Settings.TtlSeconds);
|
||||
Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt);
|
||||
Assert.True(after.ExpiresAt < before.ExpiresAt);
|
||||
Assert.Equal(1, _env.Events.Settings);
|
||||
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 0 }));
|
||||
}
|
||||
|
||||
// ───── 追加:幂等、形状、权限 ─────
|
||||
|
||||
[Fact]
|
||||
public async Task AppendIsIdempotentAndDetectsConflicts()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var item = TestEnv.NewId();
|
||||
|
||||
var first = await AppendSmall(ch, ch.Owner(_ownerEp), item);
|
||||
var same = await AppendSmall(ch, ch.Owner(_ownerEp), item);
|
||||
Assert.True(first.Created);
|
||||
Assert.False(same.Created);
|
||||
Assert.Equal(first.Seq, same.Seq);
|
||||
|
||||
await AssertCode(ErrorCode.Conflict, () => AppendSmall(ch, ch.Owner(_ownerEp), item, fill: 0x43));
|
||||
Assert.Single(await LogOf(ch, ch.Owner(_ownerEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendRejectsMalformedShapes()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var item = TestEnv.NewId();
|
||||
|
||||
// 非 GATED 条目必须是单帧且 FINAL
|
||||
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, TestEnv.SmallEnc, TestEnv.EncPayload)));
|
||||
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Enc, TestEnv.EncPayload)));
|
||||
// 快照不能是 GATED
|
||||
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Snapshot | FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)));
|
||||
// enc 负载必须是 档位+16
|
||||
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 0, TestEnv.SmallEnc, 1000)));
|
||||
// 不是合法封装
|
||||
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, new byte[10]));
|
||||
// 超过负载上限
|
||||
await AssertCode(ErrorCode.TooLarge, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final | FrameFlags.Enc, _env.Limits.MaxPayloadBytes + 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EncFlagMustMatchChannelMode()
|
||||
{
|
||||
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
|
||||
var raw = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
|
||||
await AssertCode(ErrorCode.ModeMismatch, () => Append(enc, enc.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100)));
|
||||
await AssertCode(ErrorCode.ModeMismatch, () => Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload)));
|
||||
|
||||
// raw 模式负载任意长度
|
||||
var ok = await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100));
|
||||
Assert.True(ok.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WritePermissionsAreEnforced()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
|
||||
// 读令牌不能写
|
||||
await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Reader(_memberEp)));
|
||||
// 写令牌可以写
|
||||
Assert.True((await AppendSmall(ch, ch.Member(_memberEp))).Created);
|
||||
|
||||
// 关闭成员上传后,写令牌失效,所有者仍可写
|
||||
Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MembersCanUpload = false });
|
||||
await AssertCode(ErrorCode.Forbidden, () => AppendSmall(ch, ch.Member(_memberEp)));
|
||||
Assert.True((await AppendSmall(ch, ch.Owner(_ownerEp))).Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChannelWithoutWriteHashRejectsWriteRole()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, withWriteHash: false);
|
||||
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
await AssertCode(ErrorCode.Unauthorized, () => AppendSmall(ch, ch.Member(_memberEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RawChannelIsReadableByIdAloneButEncIsNot()
|
||||
{
|
||||
var raw = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
var enc = _env.Create(_ownerEp, ChannelMode.Enc);
|
||||
await Append(raw, raw.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 10));
|
||||
|
||||
var anonymous = new Credentials(Role.Read, null, _memberEp);
|
||||
Svc.Join(raw.Id, anonymous);
|
||||
Assert.Single((await Svc.ReadLogAsync(raw.Id, anonymous, 0, 10)).Records);
|
||||
|
||||
AssertCode(ErrorCode.Unauthorized, () => Svc.Join(enc.Id, anonymous));
|
||||
}
|
||||
|
||||
// ───── 日志 ─────
|
||||
|
||||
[Fact]
|
||||
public async Task LogReturnsRecordsInOrderAndSupportsResume()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var frames = new List<byte[]>();
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
var f = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload, (byte)(i + 1));
|
||||
frames.Add(f);
|
||||
await Append(ch, ch.Owner(_ownerEp), f);
|
||||
}
|
||||
|
||||
var page = await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 100);
|
||||
Assert.Equal(3, page.LastSeq);
|
||||
Assert.Equal([1L, 2L, 3L], page.Records.Select(r => Head(r).Seq));
|
||||
// 线格式 = seq(8) ‖ kind(1) ‖ 原封装
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.Equal(LogKind.Frame, Head(page.Records[i]).Kind);
|
||||
Assert.Equal(frames[i], page.Records[i].Bytes[9..]);
|
||||
}
|
||||
|
||||
Assert.Equal(2, (await LogOf(ch, ch.Owner(_ownerEp), from: 2)).Count);
|
||||
Assert.Empty(await LogOf(ch, ch.Owner(_ownerEp), from: 4));
|
||||
Assert.Single((await Svc.ReadLogAsync(ch.Id, ch.Owner(_ownerEp), 0, 1)).Records); // limit
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResumingBeforeCompactionWatermarkIsGone()
|
||||
{
|
||||
_env.Limits.TombstoneRetentionSeconds = 1;
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
|
||||
var a = TestEnv.NewId();
|
||||
await AppendSmall(ch, o, a); // seq 1
|
||||
Svc.DeleteItem(ch.Id, o, a); // 墓碑 seq 2
|
||||
_env.Clock.Advance(TimeSpan.FromSeconds(5));
|
||||
await AppendSmall(ch, o); // seq 3,顺带清理过期墓碑
|
||||
|
||||
var info = Svc.GetChannel(ch.Id, o);
|
||||
Assert.Equal(3, info.OldestSeq);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WebDropException>(() => Svc.ReadLogAsync(ch.Id, o, 2, 10));
|
||||
Assert.Equal(ErrorCode.Gone, ex.Code);
|
||||
Assert.Equal(3, ex.OldestSeq);
|
||||
|
||||
Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 3, 10)).Records); // 水位之后可续传
|
||||
Assert.Single((await Svc.ReadLogAsync(ch.Id, o, 0, 10)).Records); // 全量同步永远允许
|
||||
}
|
||||
|
||||
// ───── GATED 条目与下载 ─────
|
||||
|
||||
private async Task<Id16> UploadGated(TestChannel ch, Credentials who, int frames, long? maxDownloads = null)
|
||||
{
|
||||
var item = TestEnv.NewId();
|
||||
for (uint i = 0; i < frames; i++)
|
||||
{
|
||||
var flags = FrameFlags.Gated | FrameFlags.Enc | (i == frames - 1 ? FrameFlags.Final : 0);
|
||||
await Append(ch, who, TestEnv.Frame(item, i, flags, TestEnv.EncPayload, (byte)(i + 1)), maxDownloads);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedItemIsAnnouncedNotPushedAndFetchedInOrder()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var item = await UploadGated(ch, o, 3);
|
||||
|
||||
// 日志里只有一条 announce
|
||||
var log = await LogOf(ch, o);
|
||||
Assert.Equal([LogKind.Announce], log.Select(l => l.Kind));
|
||||
|
||||
Svc.StartItemDownload(ch.Id, o, item, 0);
|
||||
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
|
||||
Assert.Equal(3, chunk.Frames.Count);
|
||||
Assert.True(chunk.Complete);
|
||||
Assert.Equal(3u, chunk.NextIndex);
|
||||
for (int i = 0; i < 3; i++) Assert.Equal((byte)(i + 1), chunk.Frames[i][^1]);
|
||||
|
||||
var tail = await Svc.ReadItemFramesAsync(ch.Id, o, item, 2, 10);
|
||||
Assert.Single(tail.Frames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IncompleteGatedItemStopsAtTheGapAndIsNotComplete()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var item = TestEnv.NewId();
|
||||
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
|
||||
await Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)); // 乱序到达,缺 1
|
||||
|
||||
var chunk = await Svc.ReadItemFramesAsync(ch.Id, o, item, 0, 10);
|
||||
Assert.Single(chunk.Frames);
|
||||
Assert.Equal(1u, chunk.NextIndex);
|
||||
Assert.False(chunk.Complete);
|
||||
|
||||
// FINAL 不能早于已存在的更大序号
|
||||
await AssertCode(ErrorCode.BadRequest, () =>
|
||||
Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatedFrameFlagsMustStayConsistentWithinAnItem()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var item = TestEnv.NewId();
|
||||
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload));
|
||||
|
||||
// 后续帧改变 GATED 属性会被 ValidateShape 拒绝(非 GATED 必须是单帧 FINAL)
|
||||
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload)));
|
||||
// 帧序号超出已声明的 FINAL
|
||||
await Append(ch, o, TestEnv.Frame(item, 1, FrameFlags.Gated | FrameFlags.Enc | FrameFlags.Final, TestEnv.EncPayload));
|
||||
await AssertCode(ErrorCode.BadRequest, () => Append(ch, o, TestEnv.Frame(item, 2, FrameFlags.Gated | FrameFlags.Enc, TestEnv.EncPayload)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadLimitIsEnforcedWithAGraceWindowForResume()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { FileMaxDownloads = 2 });
|
||||
var o = ch.Owner(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
var item = await UploadGated(ch, o, 2);
|
||||
|
||||
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0);
|
||||
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0); // 第 2 次达到上限,进入宽限期
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 0));
|
||||
|
||||
// 宽限期内可以续传已开始的下载
|
||||
Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1);
|
||||
Assert.Single((await Svc.ReadItemFramesAsync(ch.Id, ch.Reader(_memberEp), item, 1, 10)).Frames);
|
||||
|
||||
// 宽限期过后拒绝,清理任务删除密文并留下墓碑
|
||||
_env.Clock.Advance(TimeSpan.FromSeconds(_env.Limits.DownloadGraceSeconds + 1));
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, ch.Reader(_memberEp), item, 1));
|
||||
|
||||
Assert.Equal(1, Svc.Sweep());
|
||||
var tombstone = (await Svc.ReadLogAsync(ch.Id, o, 0, 100)).Records.Single(r => Head(r).Kind == LogKind.Tombstone);
|
||||
Assert.Equal((byte)TombstoneReason.DownloadLimit, tombstone.Bytes[^1]);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.StartItemDownload(ch.Id, o, item, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ZeroMaxDownloadsMeansUnlimitedAndHeaderOverridesDefault()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var unlimited = await UploadGated(ch, o, 1, maxDownloads: 0);
|
||||
for (int i = 0; i < 25; i++) Svc.StartItemDownload(ch.Id, o, unlimited, 0);
|
||||
|
||||
var one = await UploadGated(ch, o, 1, maxDownloads: 1);
|
||||
Svc.StartItemDownload(ch.Id, o, one, 0);
|
||||
AssertCode(ErrorCode.Gone, () => Svc.StartItemDownload(ch.Id, o, one, 0));
|
||||
}
|
||||
|
||||
// ───── 删除、快照、淘汰、过期 ─────
|
||||
|
||||
[Theory]
|
||||
[InlineData(DeletePolicy.Own, true, true)]
|
||||
[InlineData(DeletePolicy.Own, false, false)]
|
||||
[InlineData(DeletePolicy.OwnerOnly, true, false)]
|
||||
[InlineData(DeletePolicy.Anyone, false, true)]
|
||||
public async Task MemberDeletePermissionFollowsPolicy(DeletePolicy policy, bool ownItem, bool allowed)
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = policy });
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
|
||||
var item = TestEnv.NewId();
|
||||
await AppendSmall(ch, ownItem ? ch.Member(_memberEp) : ch.Owner(_ownerEp), item);
|
||||
|
||||
if (allowed) Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item);
|
||||
else AssertCode(ErrorCode.Forbidden, () => Svc.DeleteItem(ch.Id, ch.Member(_memberEp), item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OwnerCanAlwaysDeleteAndDeletionLeavesATombstone()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { DeletePolicy = DeletePolicy.OwnerOnly });
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
var item = TestEnv.NewId();
|
||||
await AppendSmall(ch, ch.Member(_memberEp), item);
|
||||
|
||||
Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item);
|
||||
|
||||
var log = await LogOf(ch, ch.Owner(_ownerEp));
|
||||
Assert.Equal([LogKind.Tombstone], log.Select(l => l.Kind)); // 帧记录已随条目删除
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.DeleteItem(ch.Id, ch.Owner(_ownerEp), item));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewSnapshotSupersedesTheOldOne()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 1
|
||||
var second = await AppendSmall(ch, o, extra: FrameFlags.Snapshot); // seq 2,墓碑 seq 3
|
||||
await AppendSmall(ch, o); // 普通条目不受影响,seq 4
|
||||
|
||||
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
||||
Assert.Equal([(2L, LogKind.Frame), (3L, LogKind.Tombstone), (4L, LogKind.Frame)], page.Records.Select(Head));
|
||||
Assert.Equal((byte)TombstoneReason.Superseded, page.Records[1].Bytes[^1]);
|
||||
Assert.Equal(2, second.Seq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExceedingMaxItemsEvictsTheOldest()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxItems = 3 });
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var ids = new List<Id16>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ids.Add(TestEnv.NewId());
|
||||
await AppendSmall(ch, o, ids[i]);
|
||||
_env.Clock.Advance(TimeSpan.FromMilliseconds(10));
|
||||
}
|
||||
|
||||
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
||||
var frames = page.Records.Where(r => Head(r).Kind == LogKind.Frame).ToList();
|
||||
var tombstones = page.Records.Where(r => Head(r).Kind == LogKind.Tombstone).ToList();
|
||||
Assert.Equal(3, frames.Count);
|
||||
Assert.Equal(2, tombstones.Count);
|
||||
Assert.All(tombstones, t => Assert.Equal((byte)TombstoneReason.Evicted, t.Bytes[^1]));
|
||||
// 保留的是最新的三个
|
||||
Assert.Equal(ids.Skip(2).Select(i => i.ToArray()), frames.Select(f => f.Bytes[(9 + 2)..(9 + 2 + 16)]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SweepRemovesExpiredItemsButKeepsLiveOnes()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var old = TestEnv.NewId();
|
||||
await AppendSmall(ch, o, old);
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromHours(12));
|
||||
await AppendSmall(ch, o); // 续期频道,新条目 TTL 从现在起算
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromHours(13)); // 第一个条目已过期,第二个还在
|
||||
Assert.Equal(1, Svc.Sweep());
|
||||
|
||||
var log = await Svc.ReadLogAsync(ch.Id, o, 0, 100);
|
||||
Assert.Equal([LogKind.Frame, LogKind.Tombstone], log.Records.Select(r => Head(r).Kind));
|
||||
Assert.Equal((byte)TombstoneReason.Expired, log.Records[1].Bytes[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SweepDeletesExpiredChannelsAndTheirBlobs()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 100_000)); // 走 blob
|
||||
Assert.True(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex())));
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromHours(25));
|
||||
Svc.Sweep();
|
||||
|
||||
Assert.False(Directory.Exists(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex())));
|
||||
Assert.Equal(1, _env.Events.Deleted);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteChannelRemovesEverything()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
await AppendSmall(ch, ch.Owner(_ownerEp));
|
||||
Svc.DeleteChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
||||
Assert.Equal(1, _env.Events.Deleted);
|
||||
}
|
||||
|
||||
// ───── blob 路径 ─────
|
||||
|
||||
[Fact]
|
||||
public async Task LargeFramesGoToBlobStoreAndRoundTrip()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var item = TestEnv.NewId();
|
||||
var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x7A);
|
||||
await Append(ch, o, frame);
|
||||
|
||||
string itemDir = Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex());
|
||||
Assert.Single(Directory.GetFiles(itemDir));
|
||||
|
||||
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10);
|
||||
Assert.Equal(frame, page.Records.Single().Bytes[9..]);
|
||||
|
||||
// 幂等重发不会重复落盘
|
||||
await Append(ch, o, frame);
|
||||
Assert.Single(Directory.GetFiles(itemDir));
|
||||
|
||||
Svc.DeleteItem(ch.Id, o, item);
|
||||
Assert.False(Directory.Exists(itemDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConflictingLargeFrameLeavesNoOrphanBlob()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var item = TestEnv.NewId();
|
||||
await Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x01));
|
||||
|
||||
await AssertCode(ErrorCode.Conflict, () => Append(ch, o, TestEnv.Frame(item, 0, FrameFlags.Final, 100_000, 0x02)));
|
||||
Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanBlobSweepRemovesOnlyUnreferencedOldFiles()
|
||||
{
|
||||
// 孤立判断基于真实文件时间,所以时钟要先对齐到真实时间,再建频道
|
||||
_env.Clock.NowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
var item = TestEnv.NewId();
|
||||
await Append(ch, ch.Owner(_ownerEp), TestEnv.Frame(item, 0, FrameFlags.Final, 100_000));
|
||||
|
||||
await _env.Blobs.PutAsync("aa/bb/0.dead", new byte[10]);
|
||||
await _env.Blobs.PutAsync("aa/bb/1.fresh", new byte[10]);
|
||||
string stale = Path.Combine(_env.Dir, "blobs", "aa", "bb", "0.dead");
|
||||
File.SetLastWriteTimeUtc(stale, DateTime.UtcNow.AddHours(-1));
|
||||
|
||||
Svc.Sweep(includeOrphanBlobs: true);
|
||||
|
||||
Assert.False(File.Exists(stale)); // 孤立且够旧:删除
|
||||
Assert.True(File.Exists(Path.Combine(_env.Dir, "blobs", "aa", "bb", "1.fresh"))); // 孤立但太新:保留
|
||||
Assert.Single(Directory.GetFiles(Path.Combine(_env.Dir, "blobs", ch.Id.ToHex(), item.ToHex()))); // 被引用:保留
|
||||
}
|
||||
|
||||
// ───── 并发 ─────
|
||||
|
||||
[Fact]
|
||||
public async Task ParallelAppendsGetUniqueContiguousSeqs()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 60).Select(_ => Task.Run(() => AppendSmall(ch, o))));
|
||||
|
||||
Assert.All(results, r => Assert.True(r.Created));
|
||||
Assert.Equal(Enumerable.Range(1, 60).Select(i => (long)i), results.Select(r => r.Seq).Order());
|
||||
Assert.Equal(60, (await LogOf(ch, o)).Count);
|
||||
Assert.Equal(60, Svc.GetChannel(ch.Id, o).LastSeq);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParallelIdenticalAppendsCreateExactlyOneFrame()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var frame = TestEnv.Frame(TestEnv.NewId(), 0, TestEnv.SmallEnc, TestEnv.EncPayload);
|
||||
|
||||
var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => Append(ch, o, frame))));
|
||||
|
||||
Assert.Equal(1, results.Count(r => r.Created));
|
||||
Assert.Single(results.Select(r => r.Seq).Distinct());
|
||||
Assert.Single(await LogOf(ch, o));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParallelIdenticalLargeAppendsKeepTheBlob()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, ChannelMode.Raw);
|
||||
var o = ch.Owner(_ownerEp);
|
||||
var item = TestEnv.NewId();
|
||||
var frame = TestEnv.Frame(item, 0, FrameFlags.Final, 100_000);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(() => Append(ch, o, frame))));
|
||||
|
||||
var page = await Svc.ReadLogAsync(ch.Id, o, 0, 10);
|
||||
Assert.Equal(frame, page.Records.Single().Bytes[9..]); // blob 仍在且内容正确
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendPublishesTheNewSeq()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var r = await AppendSmall(ch, ch.Owner(_ownerEp));
|
||||
Assert.Contains(_env.Events.Advances, e => e.Channel == ch.Id && e.Seq == r.Seq);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using System.Buffers.Binary;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
public class Id16Tests
|
||||
{
|
||||
[Fact]
|
||||
public void RoundTrips()
|
||||
{
|
||||
var id = TestEnv.NewId();
|
||||
Assert.True(Id16.TryParse(id.ToBase64Url(), out var parsed));
|
||||
Assert.Equal(id, parsed);
|
||||
Assert.Equal(22, id.ToBase64Url().Length);
|
||||
Assert.Equal(32, id.ToHex().Length);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("short")]
|
||||
[InlineData("AAAAAAAAAAAAAAAAAAAAAAA")] // 23 字符
|
||||
[InlineData("AAAAAAAAAAAAAAAAAAAA+/")] // 非 url 字母表
|
||||
public void RejectsMalformed(string text) => Assert.False(Id16.TryParse(text, out _));
|
||||
|
||||
[Fact]
|
||||
public void RejectsNonCanonicalEncoding()
|
||||
{
|
||||
// 末字符的低 2 位在 16 字节编码中必须为 0;AAAAAAAAAAAAAAAAAAAAAB 解码后与 ...AA 同值
|
||||
Assert.True(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAA", out var zero));
|
||||
Assert.False(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAB", out _));
|
||||
Assert.Equal(new byte[16], zero.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public class EnvelopeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParsesValidHeader()
|
||||
{
|
||||
var item = TestEnv.NewId();
|
||||
var bytes = TestEnv.Frame(item, 7, FrameFlags.Final | FrameFlags.Gated | FrameFlags.Enc, 1040);
|
||||
|
||||
Assert.True(Envelope.TryParse(bytes, out var env));
|
||||
Assert.Equal(item, env.ItemId);
|
||||
Assert.Equal(7u, env.FrameIndex);
|
||||
Assert.Equal(1040, env.PayloadLength);
|
||||
Assert.True(env.IsFinal && env.IsGated && env.IsEnc && !env.IsSnapshot);
|
||||
Assert.Equal(FrameFlags.Gated, env.ItemFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsBadVersion()
|
||||
{
|
||||
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
|
||||
bytes[0] = 2;
|
||||
Assert.False(Envelope.TryParse(bytes, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsReservedFlagBits()
|
||||
{
|
||||
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
|
||||
bytes[1] |= 0x10;
|
||||
Assert.False(Envelope.TryParse(bytes, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsLengthMismatch()
|
||||
{
|
||||
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 8);
|
||||
Assert.False(Envelope.TryParse(bytes.AsSpan(0, bytes.Length - 1), out _));
|
||||
Assert.False(Envelope.TryParse([.. bytes, 0], out _));
|
||||
Assert.False(Envelope.TryParse(bytes.AsSpan(0, 25), out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectsHugePayloadLength()
|
||||
{
|
||||
var bytes = TestEnv.Frame(TestEnv.NewId(), 0, FrameFlags.Final, 4);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), uint.MaxValue);
|
||||
Assert.False(Envelope.TryParse(bytes, out _));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1024 + 16, true)]
|
||||
[InlineData(4096 + 16, true)]
|
||||
[InlineData(1048576 + 16, true)]
|
||||
[InlineData(1024, false)]
|
||||
[InlineData(16, false)]
|
||||
[InlineData(1000 + 16, false)]
|
||||
[InlineData(0, false)]
|
||||
public void EncPayloadMustBeTierPlusTag(int length, bool valid) =>
|
||||
Assert.Equal(valid, PayloadTiers.IsValidEncPayloadLength(length));
|
||||
}
|
||||
|
||||
public class AuthTests
|
||||
{
|
||||
[Fact]
|
||||
public void TokenHashMatchesOnlyTheSameToken()
|
||||
{
|
||||
var token = TestEnv.Random(32);
|
||||
var hash = TokenHash.Compute(token);
|
||||
|
||||
Assert.True(TokenHash.Matches(token, hash));
|
||||
Assert.False(TokenHash.Matches(TestEnv.Random(32), hash));
|
||||
Assert.False(TokenHash.Matches(token, null));
|
||||
Assert.False(TokenHash.Matches(token, hash[..16]));
|
||||
}
|
||||
}
|
||||
|
||||
public class PolicyTests
|
||||
{
|
||||
private static Channel Chan(ChannelSettings s, bool writeHash = true) => new(
|
||||
TestEnv.NewId(), ChannelMode.Enc, s, new byte[32], writeHash ? new byte[32] : null, new byte[32], 0, 0, 0, 0, 0);
|
||||
|
||||
private static Item ItemBy(byte[] author) => new(
|
||||
TestEnv.NewId(), TestEnv.NewId(), author, FrameFlags.None, 0, 0, 0, -1, null, 0, 0, 0, null, null);
|
||||
|
||||
[Fact]
|
||||
public void WritePermission()
|
||||
{
|
||||
var ep = TestEnv.Endpoint();
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Owner, ep)));
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Write, ep)));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new()), new Principal(Role.Read, ep)));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Write, ep)));
|
||||
Assert.False(ChannelPolicy.CanWrite(Chan(new(), writeHash: false), new Principal(Role.Write, ep)));
|
||||
Assert.True(ChannelPolicy.CanWrite(Chan(new() { MembersCanUpload = false }), new Principal(Role.Owner, ep)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(DeletePolicy.OwnerOnly, Role.Write, true, false)]
|
||||
[InlineData(DeletePolicy.OwnerOnly, Role.Owner, false, true)]
|
||||
[InlineData(DeletePolicy.Own, Role.Write, true, true)]
|
||||
[InlineData(DeletePolicy.Own, Role.Write, false, false)]
|
||||
[InlineData(DeletePolicy.Own, Role.Owner, false, true)]
|
||||
[InlineData(DeletePolicy.Anyone, Role.Read, false, true)]
|
||||
public void DeletePermission(DeletePolicy policy, Role role, bool isAuthor, bool expected)
|
||||
{
|
||||
var me = TestEnv.Endpoint();
|
||||
var item = ItemBy(isAuthor ? me : TestEnv.Endpoint());
|
||||
var channel = Chan(new() { DeletePolicy = policy });
|
||||
Assert.Equal(expected, ChannelPolicy.CanDeleteItem(channel, item, new Principal(role, me)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SettingsPatchOnlyChangesProvidedFields()
|
||||
{
|
||||
var before = new ChannelSettings { Seats = 2, TtlSeconds = 100 };
|
||||
var after = new ChannelSettingsPatch { Seats = 5 }.ApplyTo(before);
|
||||
Assert.Equal(5, after.Seats);
|
||||
Assert.Equal(100, after.TtlSeconds);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 86400, 10)]
|
||||
[InlineData(999, 86400, 10)]
|
||||
[InlineData(2, 10, 10)]
|
||||
[InlineData(2, 99999999, 10)]
|
||||
[InlineData(2, 86400, 0)]
|
||||
public void SettingsValidationRejectsOutOfRange(int seats, int ttl, int maxItems)
|
||||
{
|
||||
var s = new ChannelSettings { Seats = seats, TtlSeconds = ttl, MaxItems = maxItems };
|
||||
var ex = Assert.Throws<WebDropException>(() => s.Validate(new ServerLimits()));
|
||||
Assert.Equal(ErrorCode.BadRequest, ex.Code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using WebDrop.Core;
|
||||
using WebDrop.Storage;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
public sealed class FakeClock : IClock
|
||||
{
|
||||
public long NowMs { get; set; } = 1_700_000_000_000;
|
||||
public void Advance(TimeSpan span) => NowMs += (long)span.TotalMilliseconds;
|
||||
}
|
||||
|
||||
public sealed class RecordingEvents : IChannelEvents
|
||||
{
|
||||
public List<(Id16 Channel, long Seq)> Advances { get; } = [];
|
||||
public int Settings, Seats, Deleted;
|
||||
public void Advanced(Id16 channelId, long seq) { lock (Advances) Advances.Add((channelId, seq)); }
|
||||
public void SettingsChanged(Id16 channelId) => Interlocked.Increment(ref Settings);
|
||||
public void SeatsChanged(Id16 channelId) => Interlocked.Increment(ref Seats);
|
||||
public void ChannelDeleted(Id16 channelId) => Interlocked.Increment(ref Deleted);
|
||||
}
|
||||
|
||||
/// <summary>一个频道的全部凭据,模拟客户端从 K / A 派生出的令牌。</summary>
|
||||
public sealed class TestChannel(Id16 id, ChannelMode mode, byte[] read, byte[] write, byte[] owner)
|
||||
{
|
||||
public Id16 Id { get; } = id;
|
||||
public ChannelMode Mode { get; } = mode;
|
||||
public byte[] ReadToken { get; } = read;
|
||||
public byte[] WriteToken { get; } = write;
|
||||
public byte[] OwnerToken { get; } = owner;
|
||||
|
||||
public Credentials Owner(byte[] endpoint) => new(Role.Owner, OwnerToken, endpoint);
|
||||
public Credentials Member(byte[] endpoint) => new(Role.Write, WriteToken, endpoint);
|
||||
public Credentials Reader(byte[] endpoint) => new(Role.Read, ReadToken, endpoint);
|
||||
}
|
||||
|
||||
public sealed class TestEnv : IDisposable
|
||||
{
|
||||
public string Dir { get; } = Path.Combine(Path.GetTempPath(), "webdrop-tests-" + Guid.NewGuid().ToString("N"));
|
||||
public FakeClock Clock { get; } = new();
|
||||
public RecordingEvents Events { get; } = new();
|
||||
public ServerLimits Limits { get; } = new();
|
||||
public SqliteChannelStore Store { get; }
|
||||
public DiskBlobStore Blobs { get; }
|
||||
public ChannelService Service { get; private set; }
|
||||
|
||||
public TestEnv()
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
Store = new SqliteChannelStore(Path.Combine(Dir, "test.db"));
|
||||
Blobs = new DiskBlobStore(Path.Combine(Dir, "blobs"));
|
||||
Service = new ChannelService(Store, Blobs, Clock, Limits, Events);
|
||||
}
|
||||
|
||||
public static byte[] Random(int n) => RandomNumberGenerator.GetBytes(n);
|
||||
public static byte[] Endpoint() => TokenHash.Compute(Random(32));
|
||||
public static Id16 NewId() => Id16.FromBytes(Random(16));
|
||||
|
||||
public TestChannel Create(
|
||||
byte[] ownerEndpoint,
|
||||
ChannelMode mode = ChannelMode.Enc,
|
||||
ChannelSettings? settings = null,
|
||||
bool withWriteHash = true)
|
||||
{
|
||||
var id = NewId();
|
||||
var read = Random(32);
|
||||
var write = Random(32);
|
||||
var owner = Random(32);
|
||||
|
||||
Service.CreateChannel(new CreateChannelRequest
|
||||
{
|
||||
Id = id.ToBase64Url(),
|
||||
Mode = mode,
|
||||
Settings = settings,
|
||||
ReadHash = mode == ChannelMode.Enc ? Codec.Encode(TokenHash.Compute(read)) : null,
|
||||
WriteHash = withWriteHash ? Codec.Encode(TokenHash.Compute(write)) : null,
|
||||
OwnerHash = Codec.Encode(TokenHash.Compute(owner)),
|
||||
}, ownerEndpoint);
|
||||
|
||||
return new TestChannel(id, mode, read, write, owner);
|
||||
}
|
||||
|
||||
/// <summary>构造一个封装。enc 模式的负载长度必须是 档位+16。</summary>
|
||||
public static byte[] Frame(Id16 item, uint index, FrameFlags flags, int payloadLength, byte fill = 0x42)
|
||||
{
|
||||
var bytes = new byte[Envelope.HeaderSize + payloadLength];
|
||||
bytes[0] = Envelope.Version;
|
||||
bytes[1] = (byte)flags;
|
||||
item.WriteTo(bytes.AsSpan(2));
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(18), index);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(22), (uint)payloadLength);
|
||||
bytes.AsSpan(Envelope.HeaderSize).Fill(fill);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public const int EncPayload = 1024 + 16;
|
||||
public const FrameFlags SmallEnc = FrameFlags.Final | FrameFlags.Enc;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Store.Dispose();
|
||||
try { Directory.Delete(Dir, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using WebDrop.Core;
|
||||
|
||||
namespace WebDrop.Core.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 用 test-vectors/vectors.json 校验服务端这一侧能复现协议的每个字节。
|
||||
/// 向量由 node:crypto 独立生成,这里用 .NET 的 HKDF / AesGcm / SHA256 再算一遍;
|
||||
/// 两套互不相干的实现一致,才说明 protocol.md 没有歧义。
|
||||
/// </summary>
|
||||
public class VectorTests
|
||||
{
|
||||
private static readonly JsonElement V = JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "vectors.json"))).RootElement;
|
||||
|
||||
private static byte[] H(JsonElement e) => Convert.FromHexString(e.GetString()!);
|
||||
private static byte[] H(string s) => Convert.FromHexString(s);
|
||||
|
||||
private static byte[] Hkdf(byte[] ikm, byte[] salt, string info) =>
|
||||
HKDF.DeriveKey(HashAlgorithmName.SHA256, ikm, 32, salt, Encoding.UTF8.GetBytes(info));
|
||||
|
||||
private static byte[] ChannelId => H(V.GetProperty("inputs").GetProperty("channel_id"));
|
||||
private static byte[] KEnc => Hkdf(H(V.GetProperty("inputs").GetProperty("k")), ChannelId, "webdrop/v1/enc");
|
||||
|
||||
private static byte[] ItemKey(byte[] itemId) => Hkdf(KEnc, itemId, "webdrop/v1/item");
|
||||
|
||||
private static byte[] Aad(byte[] channelId, byte[] envelope)
|
||||
{
|
||||
// "WD1" ‖ channel_id ‖ envelope[0..22)
|
||||
var aad = new byte[3 + 16 + 22];
|
||||
"WD1"u8.CopyTo(aad);
|
||||
channelId.CopyTo(aad, 3);
|
||||
envelope.AsSpan(0, 22).CopyTo(aad.AsSpan(19));
|
||||
return aad;
|
||||
}
|
||||
|
||||
/// <summary>用 AES-GCM 解密一帧,返回明文;认证失败抛 CryptographicException。</summary>
|
||||
private static byte[] Open(byte[] channelId, byte[] envelope)
|
||||
{
|
||||
Assert.True(Envelope.TryParse(envelope, out var env));
|
||||
var iv = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(iv.AsSpan(8), env.FrameIndex);
|
||||
var payload = envelope.AsSpan(Envelope.HeaderSize);
|
||||
var plain = new byte[payload.Length - 16];
|
||||
using var gcm = new AesGcm(ItemKey(env.ItemId.ToArray()), 16);
|
||||
gcm.Decrypt(iv, payload[..^16], payload[^16..], plain, Aad(channelId, envelope));
|
||||
return plain;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyDerivationMatchesTheVectors()
|
||||
{
|
||||
var inputs = V.GetProperty("inputs");
|
||||
var kdf = V.GetProperty("kdf");
|
||||
var k = H(inputs.GetProperty("k"));
|
||||
var a = H(inputs.GetProperty("a"));
|
||||
|
||||
Assert.Equal(H(kdf.GetProperty("k_enc")), Hkdf(k, ChannelId, "webdrop/v1/enc"));
|
||||
Assert.Equal(H(kdf.GetProperty("t_read")), Hkdf(k, ChannelId, "webdrop/v1/token/read"));
|
||||
Assert.Equal(H(kdf.GetProperty("t_write")), Hkdf(k, ChannelId, "webdrop/v1/token/write"));
|
||||
Assert.Equal(H(kdf.GetProperty("t_owner")), Hkdf(a, ChannelId, "webdrop/v1/token/owner"));
|
||||
Assert.Equal(H(kdf.GetProperty("k_label")), Hkdf(KEnc, ChannelId, "webdrop/v1/label"));
|
||||
|
||||
foreach (var item in V.GetProperty("item_keys").EnumerateArray())
|
||||
Assert.Equal(H(item.GetProperty("k_item")), ItemKey(H(item.GetProperty("item_id"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ServerTokenHashMatchesTheStoredHashes()
|
||||
{
|
||||
// 服务端存的是令牌的 SHA-256;向量里的 *_hash 必须与 TokenHash.Compute 一致,
|
||||
// 客户端才能发出服务端认得的哈希
|
||||
var kdf = V.GetProperty("kdf");
|
||||
foreach (var (token, hash) in new[] { ("t_read", "read_hash"), ("t_write", "write_hash"), ("t_owner", "owner_hash"), ("raw_write_token", "raw_write_hash") })
|
||||
{
|
||||
byte[] t = H(kdf.GetProperty(token));
|
||||
Assert.Equal(H(kdf.GetProperty(hash)), TokenHash.Compute(t));
|
||||
Assert.True(TokenHash.Matches(t, H(kdf.GetProperty(hash))));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IvAndAadMatchTheVectors()
|
||||
{
|
||||
foreach (var iv in V.GetProperty("iv").EnumerateArray())
|
||||
{
|
||||
var expected = new byte[12];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(expected.AsSpan(8), (uint)iv.GetProperty("index").GetInt64());
|
||||
Assert.Equal(H(iv.GetProperty("iv")), expected);
|
||||
}
|
||||
|
||||
foreach (var c in V.GetProperty("aad").EnumerateArray())
|
||||
{
|
||||
// 用一个只含头部的封装(payload_len = 0)构造 AAD 的前 22 字节
|
||||
var env = new byte[Envelope.HeaderSize];
|
||||
env[0] = 1;
|
||||
env[1] = (byte)c.GetProperty("flags").GetInt32();
|
||||
H(c.GetProperty("item_id")).CopyTo(env, 2);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(env.AsSpan(18), (uint)c.GetProperty("index").GetInt64());
|
||||
Assert.Equal(H(c.GetProperty("aad")), Aad(ChannelId, env));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaddingTiersMatchThePolicy()
|
||||
{
|
||||
foreach (var p in V.GetProperty("padding").EnumerateArray())
|
||||
{
|
||||
int len = p.GetProperty("data_len").GetInt32();
|
||||
int tier = p.GetProperty("plaintext_len").GetInt32();
|
||||
Assert.True(PayloadTiers.IsValidEncPayloadLength(tier + 16), $"data_len={len}");
|
||||
Assert.True(tier >= 4 + len);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryFrameVectorDecryptsToTheExpectedPlaintextAndPassesServerValidation()
|
||||
{
|
||||
foreach (var f in V.GetProperty("frames").EnumerateArray())
|
||||
{
|
||||
string name = f.GetProperty("name").GetString()!;
|
||||
byte[] envelope = H(f.GetProperty("envelope"));
|
||||
|
||||
// 服务端的封装解析与校验:这些帧就是合法客户端会发出的
|
||||
Assert.True(Envelope.TryParse(envelope, out var env), name);
|
||||
Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags);
|
||||
Assert.Equal((uint)f.GetProperty("index").GetInt64(), env.FrameIndex);
|
||||
Assert.True(env.IsEnc, name);
|
||||
Assert.True(PayloadTiers.IsValidEncPayloadLength(env.PayloadLength), name);
|
||||
|
||||
// 解密得到的明文(含 padding)必须逐字节一致
|
||||
byte[] plaintext = Open(ChannelId, envelope);
|
||||
Assert.Equal(H(f.GetProperty("plaintext")), plaintext);
|
||||
|
||||
// 明文布局:u32 data_len ‖ data ‖ 零填充
|
||||
int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plaintext);
|
||||
Assert.Equal(H(f.GetProperty("data")), plaintext.AsSpan(4, dataLen).ToArray());
|
||||
Assert.All(plaintext.AsSpan(4 + dataLen).ToArray(), b => Assert.Equal(0, b));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ItemVectorsFollowTheServerFramingRules()
|
||||
{
|
||||
foreach (var item in V.GetProperty("items").EnumerateArray())
|
||||
{
|
||||
string name = item.GetProperty("name").GetString()!;
|
||||
bool gated = item.GetProperty("gated").GetBoolean();
|
||||
var frames = item.GetProperty("frames").EnumerateArray().Select(e => H(e)).ToList();
|
||||
|
||||
var content = new List<byte>();
|
||||
for (int i = 0; i < frames.Count; i++)
|
||||
{
|
||||
Assert.True(Envelope.TryParse(frames[i], out var env), name);
|
||||
Assert.Equal((uint)i, env.FrameIndex);
|
||||
Assert.Equal(gated, env.IsGated);
|
||||
Assert.Equal(item.GetProperty("snapshot").GetBoolean(), env.IsSnapshot);
|
||||
Assert.Equal(i == frames.Count - 1, env.IsFinal);
|
||||
|
||||
byte[] plain = Open(ChannelId, frames[i]);
|
||||
int dataLen = (int)BinaryPrimitives.ReadUInt32BigEndian(plain);
|
||||
var data = plain.AsSpan(4, dataLen).ToArray();
|
||||
if (i == 0)
|
||||
{
|
||||
// 第 0 帧:u16 meta_len ‖ meta_json ‖ 内容
|
||||
int metaLen = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
Assert.Equal(item.GetProperty("meta_json").GetString(), Encoding.UTF8.GetString(data.AsSpan(2, metaLen)));
|
||||
content.AddRange(data.AsSpan(2 + metaLen).ToArray());
|
||||
}
|
||||
else
|
||||
{
|
||||
content.AddRange(data);
|
||||
}
|
||||
}
|
||||
Assert.Equal(H(item.GetProperty("content")), content.ToArray());
|
||||
|
||||
// 非 GATED 条目必须是单帧且 FINAL,这是服务端强制的规则
|
||||
if (!gated) Assert.Single(frames);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawFrameVectorsParse()
|
||||
{
|
||||
foreach (var f in V.GetProperty("raw_frames").EnumerateArray())
|
||||
{
|
||||
Assert.True(Envelope.TryParse(H(f.GetProperty("envelope")), out var env));
|
||||
Assert.False(env.IsEnc);
|
||||
Assert.Equal((FrameFlags)f.GetProperty("flags").GetInt32(), env.Flags);
|
||||
Assert.Equal(H(f.GetProperty("payload")).Length, env.PayloadLength);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LabelVectorsDecryptWithTheDerivedKey()
|
||||
{
|
||||
byte[] kLabel = Hkdf(KEnc, ChannelId, "webdrop/v1/label");
|
||||
foreach (var l in V.GetProperty("labels").EnumerateArray())
|
||||
{
|
||||
byte[] blob = H(l.GetProperty("blob"));
|
||||
Assert.True(blob.Length <= 256, "设备名总长不得超过 256 字节(服务端上限)");
|
||||
|
||||
var plain = new byte[blob.Length - 12 - 16];
|
||||
using var gcm = new AesGcm(kLabel, 16);
|
||||
gcm.Decrypt(blob.AsSpan(0, 12), blob.AsSpan(12, plain.Length), blob.AsSpan(blob.Length - 16), plain, H(l.GetProperty("aad")));
|
||||
Assert.Equal(l.GetProperty("name").GetString(), Encoding.UTF8.GetString(plain));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogRecordVectorsMatchTheServerEncoders()
|
||||
{
|
||||
foreach (var r in V.GetProperty("log_records").EnumerateArray())
|
||||
{
|
||||
var parsed = r.GetProperty("parsed");
|
||||
long seq = parsed.GetProperty("seq").GetInt64();
|
||||
byte[] expected = H(r.GetProperty("hex"));
|
||||
|
||||
switch (parsed.GetProperty("kind").GetString())
|
||||
{
|
||||
case "tombstone":
|
||||
var item = Id16.FromBytes(H(parsed.GetProperty("item_id")));
|
||||
Assert.Equal(expected, LogRecord.EncodeTombstone(seq, item, (TombstoneReason)parsed.GetProperty("reason").GetInt32()));
|
||||
break;
|
||||
case "announce":
|
||||
Assert.Equal(expected, LogRecord.EncodeAnnounce(seq, Id16.FromBytes(H(parsed.GetProperty("item_id"))),
|
||||
(FrameFlags)parsed.GetProperty("flags").GetInt32(), parsed.GetProperty("created_at").GetInt64()));
|
||||
break;
|
||||
case "frame":
|
||||
Assert.Equal(expected, LogRecord.Encode(seq, LogKind.Frame, H(parsed.GetProperty("envelope"))));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LinkIdsAreCanonicalForTheServer()
|
||||
{
|
||||
string id = V.GetProperty("inputs").GetProperty("channel_id_b64").GetString()!;
|
||||
Assert.True(Id16.TryParse(id, out var parsed));
|
||||
Assert.Equal(ChannelId, parsed.ToArray());
|
||||
Assert.False(Id16.TryParse("AAAAAAAAAAAAAAAAAAAAAB", out _)); // 非规范编码,对应向量 non_canonical_id
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeVectorsAreRejectedByTheServerSideChecksOrByAuthentication()
|
||||
{
|
||||
byte[] original = ChannelId;
|
||||
foreach (var n in V.GetProperty("negative").EnumerateArray())
|
||||
{
|
||||
string name = n.GetProperty("name").GetString()!;
|
||||
string expect = n.GetProperty("expect").GetString()!;
|
||||
byte[] envelope = H(n.GetProperty("envelope"));
|
||||
byte[] channel = n.TryGetProperty("channel_id", out var c) ? H(c) : original;
|
||||
|
||||
switch (expect)
|
||||
{
|
||||
case "bad_envelope":
|
||||
Assert.False(Envelope.TryParse(envelope, out _), name);
|
||||
break;
|
||||
case "auth_failed":
|
||||
// 结构合法,但 GCM 认证必须失败(篡改、换头部、换频道都被 AAD 抓住)
|
||||
Assert.True(Envelope.TryParse(envelope, out _), name);
|
||||
Assert.ThrowsAny<CryptographicException>(() => Open(channel, envelope));
|
||||
break;
|
||||
case "wrong_mode":
|
||||
// 没有 ENC 标志的帧:封装合法,但在 enc 频道上服务端会以 mode_mismatch 拒绝
|
||||
Assert.True(Envelope.TryParse(envelope, out var env), name);
|
||||
Assert.False(env.IsEnc, name);
|
||||
break;
|
||||
default:
|
||||
Assert.Fail($"未知的期望类型 {expect}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\WebDrop.Core\WebDrop.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\WebDrop.Storage\WebDrop.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 协议测试向量:由 test-vectors/generate.mjs 独立生成,这里用 .NET 的密码学实现复现 -->
|
||||
<ItemGroup>
|
||||
<Content Include="../../../test-vectors/vectors.json" Link="vectors.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in new issue
Block a user