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;
/// 在真实 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 string OwnerAccountToken { 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) };
var accounts = App.Services.GetRequiredService();
var verifier = RandomNumberGenerator.GetBytes(32);
using var key = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
var point = key.ExportParameters(false).Q;
accounts.Register("fixture_owner", RandomNumberGenerator.GetBytes(16), verifier, new byte[28], JsonSerializer.Serialize(new { kty = "EC", crv = "P-256", x = B64(point.X!), y = B64(point.Y!) }));
OwnerAccountToken = accounts.Authenticate("fixture_owner", 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);
}
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));
if (who.Role == "owner") req.Headers.Add("X-WD-Account", OwnerAccountToken);
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));
// General transport tests explicitly use open spaces; creation defaults have separate coverage.
body["settings"] = settings ?? new { name = "Test space", seats = 2, join_policy = "open", allow_guests = true };
using var req = Req(HttpMethod.Post, "/api/v1/channels", ch.Owner, JsonBody(body));
if (capToken is not null) req.Headers.Add("X-WD-Cap", capToken);
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,
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 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));
}