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; /// 在真实 Kestrel 上启动服务(随机端口、临时数据目录),测试走真实的 HTTP / WebSocket / SSE。 public sealed class ServerFixture : IAsyncLifetime, IAsyncDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-api-" + Guid.NewGuid().ToString("N")); private readonly Dictionary _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 overrides) => _overrides = overrides; /// xunit 要求 class fixture 只有一个公开构造函数,带配置覆盖的版本走工厂方法。 public static ServerFixture WithSettings(Dictionary 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().Features.Get()!.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 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; } /// 创建频道并返回原始响应,用于测试验证码等失败路径。 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 { ["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 JoinAsync(Chan ch, Who who) => await Http.SendAsync(Req(HttpMethod.Post, $"/api/v1/channels/{ch.Id}/seats", who)); public async Task 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 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); /// 解析 log 接口返回的记录拼接:seq(8) ‖ kind(1) ‖ body。frame 的 body 自带 payload_len,其余定长。 public static List ParseLog(byte[] data) { var list = new List(); 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)); }