Files
WebDrop/server/tests/WebDrop.Api.Tests/ServerFixture.cs
T
justme c858bd2a09 feat(server)!: require a logged-in account for every channel request
Drop unauthenticated guests from channels: the allow_guests setting, the
/guests endpoints and the guest seat table are gone, and requests without an
account session get 401 account_required. Share links are unaffected.
Contract version is now 8.0.0.
2026-10-16 06:46:15 +08:00

229 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 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));
}