- new projects WebDrop.FileApi (file service), WebDrop.FileProtocol (shared tickets and receipts) and WebDrop.Sync (WebDAV) - file content of any size goes to the file service; chat, avatars and space structure stay on the main backend - tickets issued by the main backend, receipts by the file service, deletion queue and daily reconciliation - daily database snapshot and file sync to external storage (OpenList/WebDAV) with a 30 day retention - reorganize Core, Storage and Api into feature folders (namespaces unchanged) - web client uploads file frames to the file service and reads them with tickets
230 lines
10 KiB
C#
230 lines
10 KiB
C#
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;
|
|
using WebDrop.Storage;
|
|
|
|
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 string DataDir => _dir;
|
|
public WebApplication App { get; private set; } = null!;
|
|
public HttpClient Http { get; private set; } = null!;
|
|
public Uri BaseUri { get; private set; } = null!;
|
|
public string OwnerAccountToken { get; private set; } = null!;
|
|
/// <summary>非所有者的默认账号。频道只接受登录账号,所以测试里的「成员」「读者」都是这个账号。</summary>
|
|
public string MemberAccountToken { 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) };
|
|
var accounts = App.Services.GetRequiredService<SqliteAccountStore>();
|
|
OwnerAccountToken = NewAccountToken("fixture_owner");
|
|
MemberAccountToken = NewAccountToken("fixture_member");
|
|
}
|
|
|
|
/// <summary>注册一个账号并返回它的会话令牌。</summary>
|
|
public string NewAccountToken(string username)
|
|
{
|
|
var accounts = App.Services.GetRequiredService<SqliteAccountStore>();
|
|
var verifier = RandomNumberGenerator.GetBytes(32);
|
|
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
|
var point = key.ExportParameters(false).Q;
|
|
accounts.Register(username, RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }));
|
|
return accounts.Authenticate(username, verifier)!;
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
Http.Dispose();
|
|
await App.StopAsync();
|
|
await App.DisposeAsync();
|
|
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);
|
|
}
|
|
|
|
/// <summary>AccountToken 为空时:所有者用 OwnerAccountToken,其余用 MemberAccountToken。</summary>
|
|
public sealed record Who(string Role, byte[]? Token, byte[] EndpointSecret, string? AccountToken = null);
|
|
|
|
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));
|
|
// 频道和空间只接受登录账号;便捷分享(/shares)仍由未登录终端使用
|
|
if (path.StartsWith("/api/v1/channels", StringComparison.Ordinal) || path.StartsWith("/api/v1/spaces", StringComparison.Ordinal))
|
|
req.Headers.Add("X-WD-Account", who.AccountToken ?? (who.Role == "owner" ? OwnerAccountToken : MemberAccountToken));
|
|
else if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
|
|
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));
|
|
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
|
|
body["settings"] = settings ?? new { name = "Test space", max_members = 2, join_policy = "open" };
|
|
|
|
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}/join", who));
|
|
|
|
public async Task<HttpResponseMessage> AppendAsync(
|
|
Chan ch,
|
|
Who who,
|
|
byte[] frame,
|
|
string? maxDownloads = null,
|
|
bool expectContinue = false)
|
|
{
|
|
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);
|
|
req.Headers.ExpectContinue = expectContinue;
|
|
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));
|
|
}
|