2026-10-01 10:49:58 +02:00
|
|
|
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;
|
|
|
|
|
|
2026-10-01 11:25:05 +02:00
|
|
|
using PrivaPub.Federation.Moderation;
|
2026-10-01 10:49:58 +02:00
|
|
|
using PrivaPub.Infrastructure.Http;
|
2026-10-03 11:10:42 +02:00
|
|
|
using PrivaPub.Infrastructure.Statistics;
|
2026-10-01 11:25:05 +02:00
|
|
|
using PrivaPub.Models.Federation;
|
2026-10-01 10:49:58 +02:00
|
|
|
|
|
|
|
|
using System.Collections.Concurrent;
|
|
|
|
|
|
|
|
|
|
namespace PrivaPub.Tests.Support
|
|
|
|
|
{
|
|
|
|
|
public sealed class Peer : IAsyncDisposable
|
|
|
|
|
{
|
|
|
|
|
readonly WebApplication _app;
|
2026-10-03 10:43:03 +02:00
|
|
|
readonly ConcurrentDictionary<string, (string Text, string ContentType)> _documents = new();
|
2026-10-01 11:09:35 +02:00
|
|
|
readonly ConcurrentDictionary<string, (int Status, TimeSpan Delay)> _answers = new();
|
2026-10-01 12:22:08 +02:00
|
|
|
readonly ConcurrentDictionary<string, (byte[] Bytes, string ContentType)> _files = new();
|
2026-10-01 10:49:58 +02:00
|
|
|
|
|
|
|
|
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 =>
|
|
|
|
|
{
|
2026-10-03 10:43:03 +02:00
|
|
|
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)));
|
2026-10-01 10:49:58 +02:00
|
|
|
var key = context.Request.Path.Value;
|
2026-10-01 12:22:08 +02:00
|
|
|
if (peer._files.TryGetValue(key, out var file))
|
|
|
|
|
{
|
2026-10-03 10:43:03 +02:00
|
|
|
await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context);
|
2026-10-01 12:22:08 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-10-01 11:09:35 +02:00
|
|
|
if (peer._answers.TryGetValue(key, out var answer))
|
|
|
|
|
{
|
|
|
|
|
if (answer.Delay > TimeSpan.Zero)
|
|
|
|
|
await Task.Delay(answer.Delay);
|
|
|
|
|
context.Response.StatusCode = answer.Status;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-01 10:49:58 +02:00
|
|
|
if (!peer._documents.TryGetValue(key, out var document))
|
|
|
|
|
{
|
|
|
|
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-03 10:43:03 +02:00
|
|
|
context.Response.ContentType = document.ContentType;
|
|
|
|
|
await context.Response.WriteAsync(document.Text.Replace("{A}", peer.A).Replace("{B}", peer.B));
|
2026-10-01 10:49:58 +02:00
|
|
|
});
|
|
|
|
|
await app.StartAsync();
|
|
|
|
|
peer = new Peer(app, new Uri(app.Urls.First()).Port);
|
|
|
|
|
return peer;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 10:43:03 +02:00
|
|
|
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);
|
2026-10-01 10:49:58 +02:00
|
|
|
|
2026-10-01 12:22:08 +02:00
|
|
|
public void ServeFile(string path, byte[] bytes, string contentType) => _files[path] = (bytes, contentType);
|
|
|
|
|
|
2026-10-01 11:09:35 +02:00
|
|
|
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
|
|
|
|
|
|
2026-10-03 11:33:30 +02:00
|
|
|
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default, IInteractionLedger ledger = default,
|
|
|
|
|
IConnectedAddresses connected = default)
|
2026-10-01 10:49:58 +02:00
|
|
|
{
|
|
|
|
|
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
|
|
|
|
|
var services = new ServiceCollection();
|
|
|
|
|
services.AddHttpClient(FederationHttp.ClientName)
|
2026-10-03 11:33:30 +02:00
|
|
|
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options, connected));
|
2026-10-01 10:49:58 +02:00
|
|
|
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
|
|
|
|
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
|
2026-10-03 11:10:42 +02:00
|
|
|
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance, ledger);
|
2026-10-01 10:49:58 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-01 11:25:05 +02:00
|
|
|
public sealed class NoBlocks : IDomainBlocks
|
|
|
|
|
{
|
|
|
|
|
public DomainBlock Find(string host) => default;
|
|
|
|
|
public bool IsSuspended(string host) => false;
|
|
|
|
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 10:43:03 +02:00
|
|
|
public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default,
|
|
|
|
|
IReadOnlyDictionary<string, string> Headers = default);
|
2026-10-01 10:49:58 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|