PrivaPubHost is a WebApplicationFactory<Program> on the fixture's database, configured only through UseSetting (visible before Build, unlike ConfigureAppConfiguration). It drops the background workers so tests run the jobs they queue (Jobs.Run, RunInbox), gives each client its own address for the rate limiter, and has a SecureMode variant. Accounts signs up roots, adds personas and gets Mastodon tokens through the real /oauth code flow. RemoteActor signs HttpRequestMessages for the real /peasants routes; Peer records bodies and headers and serves files with ranges and text pages. Program registers the Guid serializer with TryRegisterSerializer, so a second host in one process starts; the fixture runs the migrations in production's order before any test. HostBootTests: the host shares the fixture database; the harness handles exactly the activities the server registers; every job kind has one handler; every controller and page model can be made; the service graph validates with ValidateOnBuild and ValidateScopes; Swagger is 404 outside Development; a persona's token never names its root; a signed DM through the real /mouth route is queued, processed and stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
114 lines
4.4 KiB
C#
114 lines
4.4 KiB
C#
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using PrivaPub.Federation.Moderation;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Models.Federation;
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
namespace PrivaPub.Tests.Support
|
|
{
|
|
public sealed class Peer : IAsyncDisposable
|
|
{
|
|
readonly WebApplication _app;
|
|
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
|
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
|
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
|
|
|
public int Port { get; }
|
|
public string A => $"http://127.0.0.1:{Port}";
|
|
public string B => $"http://localhost:{Port}";
|
|
public ConcurrentQueue<HttpRequestRecord> Requests { get; } = new();
|
|
|
|
Peer(WebApplication app, int port)
|
|
{
|
|
_app = app;
|
|
Port = port;
|
|
}
|
|
|
|
public static async Task<Peer> Start()
|
|
{
|
|
var builder = WebApplication.CreateSlimBuilder();
|
|
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
|
var app = builder.Build();
|
|
Peer peer = default;
|
|
app.Run(async context =>
|
|
{
|
|
var body = context.Request.ContentLength > 0 || context.Request.Headers.TransferEncoding.Count > 0
|
|
? await new StreamReader(context.Request.Body).ReadToEndAsync()
|
|
: default;
|
|
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString(), body,
|
|
context.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase)));
|
|
var key = context.Request.Path.Value;
|
|
if (peer._files.TryGetValue(key, out var file))
|
|
{
|
|
await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context);
|
|
return;
|
|
}
|
|
if (peer._answers.TryGetValue(key, out var answer))
|
|
{
|
|
if (answer.Delay > TimeSpan.Zero)
|
|
await Task.Delay(answer.Delay);
|
|
context.Response.StatusCode = answer.Status;
|
|
return;
|
|
}
|
|
if (!peer._documents.TryGetValue(key, out var document))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
return;
|
|
}
|
|
context.Response.ContentType = document.ContentType;
|
|
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
|
});
|
|
await app.StartAsync();
|
|
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
|
return peer;
|
|
}
|
|
|
|
public void Serve(string path, string json) => _documents[path] = (json, "application/activity+json");
|
|
|
|
public void ServeText(string path, string text, string contentType) => _documents[path] = (text, contentType);
|
|
|
|
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
|
|
|
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
|
|
|
|
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default)
|
|
{
|
|
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
|
var services = new ServiceCollection();
|
|
services.AddHttpClient(FederationHttp.ClientName)
|
|
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
|
|
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
|
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
|
|
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance);
|
|
}
|
|
|
|
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
|
}
|
|
|
|
public sealed class NoBlocks : IDomainBlocks
|
|
{
|
|
public DomainBlock Find(string host) => default;
|
|
public bool IsSuspended(string host) => false;
|
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
|
}
|
|
|
|
public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default,
|
|
IReadOnlyDictionary<string, string> Headers = default);
|
|
|
|
public sealed class StaticOptions<T> : IOptionsMonitor<T>
|
|
{
|
|
public StaticOptions(T value) => CurrentValue = value;
|
|
public T CurrentValue { get; }
|
|
public T Get(string name) => CurrentValue;
|
|
public IDisposable OnChange(Action<T, string> listener) => default;
|
|
}
|
|
}
|