From 5e34517e7335dbb1ecbddf8becfb559aad9d92b8 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 3 Oct 2026 10:43:03 +0200 Subject: [PATCH] T3: the whole server under test PrivaPubHost is a WebApplicationFactory 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 Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2 --- CLAUDE.md | 6 + PrivaPub.Tests/Http/HostBootTests.cs | 146 ++++++++++++++++++ PrivaPub.Tests/PrivaPub.Tests.csproj | 1 + PrivaPub.Tests/Support/Harness.cs | 6 +- PrivaPub.Tests/Support/Host/Accounts.cs | 117 ++++++++++++++ PrivaPub.Tests/Support/Host/Jobs.cs | 64 ++++++++ PrivaPub.Tests/Support/Host/PrivaPubHost.cs | 160 ++++++++++++++++++++ PrivaPub.Tests/Support/MongoFixture.cs | 1 + PrivaPub.Tests/Support/Peer.cs | 22 ++- PrivaPub.Tests/Support/RemoteActor.cs | 39 ++++- PrivaPub/Program.cs | 2 +- 11 files changed, 546 insertions(+), 18 deletions(-) create mode 100644 PrivaPub.Tests/Http/HostBootTests.cs create mode 100644 PrivaPub.Tests/Support/Host/Accounts.cs create mode 100644 PrivaPub.Tests/Support/Host/Jobs.cs create mode 100644 PrivaPub.Tests/Support/Host/PrivaPubHost.cs diff --git a/CLAUDE.md b/CLAUDE.md index 63167ed..4c050b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -333,6 +333,12 @@ mongod fails the run instead of silently skipping half the tests. - `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost`), so origin rules can be tested; `Support/RemoteActor` signs real deliveries with its own key. - Inbox scenarios go through `InboxReceiver.Receive` with a signed request, not through the private handlers. +- `Support/Host/PrivaPubHost` is the whole server under test (`WebApplicationFactory`, environment `Testing`, + configured only through `UseSetting`, on the fixture's database). `PrivaPubHost.Shared()` boots it once per run; + `SecureModeHost` is the same with `Federation:SecureMode`. Background workers are removed, so a test runs the jobs it + queued with `host.Run(j => ...)` or `host.RunInbox(activityId)`. `Accounts` signs a root up, adds personas, and gets a + Mastodon token through the real `/oauth` code flow; `RemoteActor.SignedPost`/`SignedGet` sign `HttpRequestMessage`s + for the real `/peasants` routes. Each client gets its own `X-Test-Client` address, so rate limits don't collide. - All test classes share one database and run in parallel, so a test touches only rows it made: - random names and GUIDs; - `Harness.Outgoing` sees only deliveries queued since that harness started, because Peer ports are reused; diff --git a/PrivaPub.Tests/Http/HostBootTests.cs b/PrivaPub.Tests/Http/HostBootTests.cs new file mode 100644 index 0000000..a4b3d12 --- /dev/null +++ b/PrivaPub.Tests/Http/HostBootTests.cs @@ -0,0 +1,146 @@ +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.DependencyInjection; + +using MongoDB.Entities; + +using PrivaPub.Federation.Inbox; +using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Models; +using PrivaPub.Models.Jobs; +using PrivaPub.Models.Post; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Http +{ + [Trait("Category", "Integration")] + public sealed class HostBootTests : IAsyncLifetime + { + PrivaPubHost _host; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + [Fact] + public async Task The_host_shares_the_fixture_database() + { + Assert.True(await DB.Default.Find().ExecuteAnyAsync(TestContext.Current.CancellationToken)); + using var client = _host.Client(); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/build.json", TestContext.Current.CancellationToken)).StatusCode); + } + + [Fact] + public async Task The_harness_handles_the_same_activities_as_the_server() + { + await using var harness = await Harness.Start(); + var registered = _host.Services.GetServices().Select(h => h.GetType()).OrderBy(t => t.Name); + var harnessed = harness.Handlers.Select(h => h.GetType()).OrderBy(t => t.Name); + + Assert.Equal(registered, harnessed); + } + + [Fact] + public void Every_job_kind_has_exactly_one_handler() + { + var handlers = _host.Services.GetServices().GroupBy(h => h.Kind).ToDictionary(g => g.Key, g => g.Count()); + + foreach (var kind in Enum.GetValues()) + Assert.True(handlers.GetValueOrDefault(kind) == 1, $"{kind} has {handlers.GetValueOrDefault(kind)} handlers"); + } + + [Fact] + public void Every_controller_and_page_model_can_be_made() + { + using var scope = _host.Services.CreateScope(); + var controllers = _host.Get().ActionDescriptors.Items + .OfType() + .Select(d => d.ControllerTypeInfo.AsType()) + .Distinct() + .ToList(); + var pages = typeof(Program).Assembly.GetTypes().Where(t => t.IsSubclassOf(typeof(PageModel)) && !t.IsAbstract).ToList(); + + Assert.NotEmpty(controllers); + Assert.NotEmpty(pages); + foreach (var type in controllers.Concat(pages)) + Assert.NotNull(ActivatorUtilities.CreateInstance(scope.ServiceProvider, type)); + } + + [Fact] + public void The_service_graph_is_valid() + { + var services = new ServiceCollection(); + foreach (var descriptor in _host.Registered) + ((ICollection)services).Add(descriptor); + + using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }); + } + + [Fact] + public async Task Swagger_is_not_served_outside_development() + { + using var client = _host.Client(); + + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/swagger/index.html", TestContext.Current.CancellationToken)).StatusCode); + } + + [Fact] + public async Task A_persona_gets_a_mastodon_token_through_the_real_code_flow() + { + var root = await _host.SignUp(); + var persona = await _host.Persona(root, "boot"); + + var token = await _host.MastodonToken(persona); + + using var client = _host.As(token); + var account = await client.GetStringAsync("/api/v1/accounts/verify_credentials", TestContext.Current.CancellationToken); + Assert.Contains($"\"id\":\"{persona.Id}\"", account); + Assert.DoesNotContain(root.Id, account); + Assert.DoesNotContain(root.UserName, account); + } + + [Fact] + public async Task A_signed_delivery_through_the_real_route_is_queued_and_stored() + { + var token = TestContext.Current.CancellationToken; + await using var peer = await Peer.Start(); + var persona = await _host.Persona(await _host.SignUp(), "mouth"); + var bob = new RemoteActor(peer, "bob"); + var noteId = $"{peer.A}/notes/{Guid.NewGuid():N}"; + var activity = new JsonObject + { + ["id"] = noteId + "/activity", + ["type"] = "Create", + ["actor"] = bob.Id, + ["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"), + ["object"] = new JsonObject + { + ["id"] = noteId, + ["type"] = "Note", + ["attributedTo"] = bob.Id, + ["to"] = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"), + ["content"] = "

hello through the front door

", + ["published"] = DateTime.UtcNow.ToString("O") + } + }; + using var client = _host.Client(); + + var response = await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", activity), token); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal(1, await _host.RunInbox(noteId + "/activity", token)); + var stored = await DB.Default.Find().Match(p => p.ObjectURI == noteId).ExecuteFirstAsync(token); + Assert.Equal(PostVisibility.Direct, stored.Visibility); + Assert.Contains("front door", stored.ContentHtml); + } + } +} diff --git a/PrivaPub.Tests/PrivaPub.Tests.csproj b/PrivaPub.Tests/PrivaPub.Tests.csproj index d4dd227..64004cf 100644 --- a/PrivaPub.Tests/PrivaPub.Tests.csproj +++ b/PrivaPub.Tests/PrivaPub.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 021df01..24e7111 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -52,7 +52,7 @@ namespace PrivaPub.Tests.Support Outbox = new OutboxPublisher(Db, Local, Delivery); Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance); - Processor = new InboxProcessor(Remote, new IActivityHandler[] + Handlers = new IActivityHandler[] { new FollowHandler(Db, Local, Remote, Delivery), new AcceptHandler(Db, Local, Quotes), @@ -68,7 +68,8 @@ namespace PrivaPub.Tests.Support new DeleteHandler(Db, Local, Remote, Delivery, Groups, Quotes), new UpdateHandler(Db, Local, Remote, Groups, Records, Quotes), new FlagHandler(Db, Local) - }, NullLogger.Instance); + }; + Processor = new InboxProcessor(Remote, Handlers, NullLogger.Instance); Follows = new FollowService(Db, Local, Remote, Delivery, new KeyLocalizer(), NullLogger.Instance); Content = new ContentRenderer(Local, Remote); Media = new MediaService(new StaticOptions(new MediaOptions { Root = Path.Combine(Path.GetTempPath(), $"privapub-media-{Guid.NewGuid():N}") }), @@ -88,6 +89,7 @@ namespace PrivaPub.Tests.Support public DeliveryService Delivery { get; } public InboxReceiver Receiver { get; } public InboxProcessor Processor { get; } + public IReadOnlyList Handlers { get; } public FollowService Follows { get; } public ContentRenderer Content { get; } public OutboxPublisher Outbox { get; } diff --git a/PrivaPub.Tests/Support/Host/Accounts.cs b/PrivaPub.Tests/Support/Host/Accounts.cs new file mode 100644 index 0000000..d31b5c1 --- /dev/null +++ b/PrivaPub.Tests/Support/Host/Accounts.cs @@ -0,0 +1,117 @@ +using PrivaPub.Infrastructure.Cli; + +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace PrivaPub.Tests.Support.Host +{ + public sealed record Root(string Id, string UserName, string Password, string Jwt); + + public sealed record Persona(string Id, string UserName, Root Root); + + public static partial class Accounts + { + public const string Password = "Test-Pass-1!"; + const string OutOfBand = "urn:ietf:wg:oauth:2.0:oob"; + + public static async Task SignUp(this PrivaPubHost host, string name = "root") + { + var userName = $"{name}{Guid.NewGuid():N}"[..24]; + using var client = host.Client(); + var response = await client.PostAsJsonAsync("/clientapi/user/signup", new { userName, password = Password }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var jwt = (await response.Content.ReadFromJsonAsync())!; + return new Root(jwt["userId"]!.GetValue(), userName, Password, jwt["token"]!.GetValue()); + } + + public static async Task LogIn(this PrivaPubHost host, Root root) + { + using var client = host.Client(); + var response = await client.PostAsJsonAsync("/clientapi/user/login", new { userName = root.UserName, password = root.Password }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var jwt = (await response.Content.ReadFromJsonAsync())!; + return root with { Jwt = jwt["token"]!.GetValue() }; + } + + public static async Task Admin(this PrivaPubHost host) + { + var root = await host.SignUp("admin"); + Assert.Equal(0, await AdminCommands.Run(new[] { "promote", root.UserName })); + return await host.LogIn(root); + } + + public static async Task Persona(this PrivaPubHost host, Root root, string name = "persona") + { + var userName = $"{name}{Guid.NewGuid():N}"[..20]; + using var client = host.As(root.Jwt); + var response = await client.PostAsJsonAsync("/clientapi/avatar/private/insert", new { userName, name, biography = "testing" }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var avatar = (await response.Content.ReadFromJsonAsync())!; + return new Persona(avatar["id"]!.GetValue(), userName, root); + } + + public static async Task MastodonToken(this PrivaPubHost host, Persona persona, string scopes = "read write follow") + { + using var client = host.Client(cookies: true); + var app = await Form(client, "/api/v1/apps", ("client_name", "privapub-tests"), ("redirect_uris", OutOfBand), ("scopes", scopes)); + var clientId = app["client_id"]!.GetValue(); + var clientSecret = app["client_secret"]!.GetValue(); + var query = $"client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(OutOfBand)}&response_type=code&scope={Uri.EscapeDataString(scopes)}"; + + var code = await Authorize(client, persona, query); + var token = await Form(client, "/oauth/token", ("grant_type", "authorization_code"), ("code", code), ("client_id", clientId), + ("client_secret", clientSecret), ("redirect_uri", OutOfBand)); + return token["access_token"]!.GetValue(); + } + + public static async Task Authorize(HttpClient client, Persona persona, string query, string decision = "allow") + { + var returnUrl = "/oauth/authorize?" + query; + var login = await client.GetStringAsync("/oauth/login?returnUrl=" + Uri.EscapeDataString(returnUrl)); + var antiforgery = AntiforgeryToken().Match(login).Groups[1].Value; + var signedIn = await client.PostAsync("/oauth/login", new FormUrlEncodedContent(new Dictionary + { + ["returnUrl"] = returnUrl, + ["__RequestVerificationToken"] = antiforgery, + ["userName"] = persona.Root.UserName, + ["password"] = persona.Root.Password + })); + Assert.Equal(HttpStatusCode.Redirect, signedIn.StatusCode); + + var choose = await client.GetStringAsync(returnUrl + "&signed_in=1"); + var fields = HiddenInput().Matches(choose).Select(m => new KeyValuePair(m.Groups[1].Value, WebUtility.HtmlDecode(m.Groups[2].Value))).ToList(); + fields.Add(new("avatarId", persona.Id)); + fields.Add(new("decision", decision)); + var answer = await client.PostAsync("/oauth/authorize", new FormUrlEncodedContent(fields)); + var page = await answer.Content.ReadAsStringAsync(); + return Code().Match(page) is { Success: true } match ? match.Groups[1].Value : default; + } + + public static HttpClient As(this PrivaPubHost host, string bearer) + { + var client = host.Client(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + return client; + } + + static async Task Form(HttpClient client, string path, params (string Key, string Value)[] fields) + { + var response = await client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair(f.Key, f.Value)))); + var body = await response.Content.ReadAsStringAsync(); + Assert.True(response.IsSuccessStatusCode, $"{path} answered {(int)response.StatusCode}: {body}"); + return JsonNode.Parse(body)!.AsObject(); + } + + [GeneratedRegex("name=\"__RequestVerificationToken\" type=\"hidden\" value=\"([^\"]*)\"")] + private static partial Regex AntiforgeryToken(); + + [GeneratedRegex("([^<]*)")] + private static partial Regex Code(); + } +} diff --git a/PrivaPub.Tests/Support/Host/Jobs.cs b/PrivaPub.Tests/Support/Host/Jobs.cs new file mode 100644 index 0000000..68c8649 --- /dev/null +++ b/PrivaPub.Tests/Support/Host/Jobs.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.DependencyInjection; + +using MongoDB.Entities; + +using PrivaPub.Federation.Outbox; +using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Models.Jobs; + +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Support.Host +{ + public static class Jobs + { + public static async Task Run(this PrivaPubHost host, Expression> which, CancellationToken token = default) + { + var handlers = host.Services.GetServices().ToDictionary(h => h.Kind); + var queue = host.Get(); + var ran = 0; + for (var round = 0; round < 50; round++) + { + var pending = await DB.Default.Find().Match(which).Match(j => j.State == JobState.Pending).ExecuteAsync(token); + if (pending.Count == 0) + return ran; + foreach (var candidate in pending) + { + var job = await DB.Default.UpdateAndGet() + .Match(j => j.ID == candidate.ID && j.State == JobState.Pending) + .Modify(j => j.State, JobState.Running) + .Modify(j => j.LeasedUntil, DateTime.UtcNow + JobQueue.LeaseTime) + .Modify(b => b.Inc(j => j.Attempts, 1)) + .ExecuteAsync(token); + if (job == default) + continue; + var handler = handlers[job.Kind]; + JobOutcome outcome; + try + { + outcome = await handler.Handle(job, token); + } + catch (Exception ex) + { + outcome = JobOutcome.Dead(ex.GetType().Name + ": " + ex.Message); + } + await queue.Finish(job, outcome, handler.MaxAttempts, token); + ran++; + } + } + return ran; + } + + public static Task RunInbox(this PrivaPubHost host, string activityId, CancellationToken token = default) => + host.Run(j => j.Kind == JobKind.ProcessInbox && j.DedupeKey == "inbox|" + activityId, token); + + public static async Task> Deliveries(string inbox, DateTime since, CancellationToken token = default) => + (await DB.Default.Find().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= since).ExecuteAsync(token)) + .Select(j => JsonSerializer.Deserialize(j.Payload)) + .Where(p => p.Inbox == inbox) + .Select(p => JsonNode.Parse(p.Body)!.AsObject()) + .ToList(); + } +} diff --git a/PrivaPub.Tests/Support/Host/PrivaPubHost.cs b/PrivaPub.Tests/Support/Host/PrivaPubHost.cs new file mode 100644 index 0000000..b349f07 --- /dev/null +++ b/PrivaPub.Tests/Support/Host/PrivaPubHost.cs @@ -0,0 +1,160 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using PrivaPub.Api.Mastodon.Auth; +using PrivaPub.Domain.Media; +using PrivaPub.Infrastructure.Jobs; + +using System.Net; + +namespace PrivaPub.Tests.Support.Host +{ + public class PrivaPubHost : WebApplicationFactory + { + public const string Host = "privapub.test"; + public const string Base = "https://" + Host; + public const string ClientHeader = "X-Test-Client"; + + static readonly SemaphoreSlim Boot = new(1, 1); + static readonly Type[] Unwanted = { typeof(JobWorker), typeof(MediaJanitor), typeof(OAuthPruner) }; + static PrivaPubHost _shared; + + readonly string _mediaRoot = Path.Combine(Path.GetTempPath(), $"privapub-tests-{Guid.NewGuid():N}"); + + public IReadOnlyList Registered { get; private set; } + + public static async Task Shared() + { + await Boot.WaitAsync(); + try + { + if (_shared == default) + { + var host = new PrivaPubHost(); + _ = host.Services; + _shared = host; + } + return _shared; + } + finally + { + Boot.Release(); + } + } + + protected virtual IEnumerable> Settings() => new Dictionary + { + ["MongoSettings:ConnectionString"] = MongoFixture.Connection, + ["MongoSettings:Database"] = MongoFixture.Database, + ["MongoSettings:LogsDatabase"] = "logs", + ["AppConfiguration:Version"] = "0.0.0-test", + ["AppConfiguration:BackendBaseAddress"] = Base, + ["AppConfiguration:MaxAllowedUploadFiles"] = "3", + ["AppConfiguration:MaxAllowedFileSize"] = "2097152", + ["AppConfiguration:SupportedLanguages:0"] = "en", + ["AppConfiguration:SupportedLanguages:1"] = "it", + ["AppConfiguration:HashingOptions:Iterations"] = "1000", + ["AppConfiguration:Jwt:Key"] = "privapub-tests-only-signing-key-0123456789abcdef0123456789abcdef", + ["AppConfiguration:Jwt:Issuer"] = Base, + ["AppConfiguration:Jwt:Audience"] = Base, + ["AppConfiguration:Jwt:HoursTimeout"] = "1", + ["AppConfiguration:EmailConfiguration:SmtpServer"] = "127.0.0.1", + ["AppConfiguration:EmailConfiguration:SmtpPort"] = "9", + ["AppConfiguration:EmailConfiguration:UseSSL"] = "false", + ["AppConfiguration:EmailConfiguration:SmtpUsername"] = "nobody@privapub.test", + ["AppConfiguration:EmailConfiguration:SmtpPassword"] = "none", + ["Federation:AllowPrivateNetworks"] = "true", + ["Federation:AllowPlainHttp"] = "true", + ["Federation:FetchLinkPreviews"] = "false", + ["Media:Root"] = _mediaRoot, + ["Logging:LogLevel:Default"] = "Warning", + ["Serilog:MinimumLevel:Default"] = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_LOGS") == "1" ? "Information" : "Fatal" + }; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + foreach (var (key, value) in Settings()) + builder.UseSetting(key, value); + builder.ConfigureTestServices(services => + { + foreach (var hosted in services.Where(s => s.ServiceType == typeof(IHostedService) && Unwanted.Contains(s.ImplementationType)).ToList()) + services.Remove(hosted); + services.AddDataProtection().UseEphemeralDataProtectionProvider(); + services.AddSingleton(); + Registered = services.ToList(); + }); + } + + protected override void ConfigureClient(HttpClient client) + { + client.BaseAddress = new Uri(Base + "/"); + client.DefaultRequestHeaders.Add(ClientHeader, $"10.{Random.Shared.Next(256)}.{Random.Shared.Next(256)}.{Random.Shared.Next(1, 255)}"); + } + + public HttpClient Client(bool cookies = false) => CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri(Base + "/"), + AllowAutoRedirect = false, + HandleCookies = cookies + }); + + public T Get() where T : notnull => Services.GetRequiredService(); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing && Directory.Exists(_mediaRoot)) + Directory.Delete(_mediaRoot, recursive: true); + } + + sealed class ClientAddressFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use(async (context, call) => + { + if (IPAddress.TryParse(context.Request.Headers[ClientHeader].ToString(), out var address)) + context.Connection.RemoteIpAddress = address; + await call(context); + }); + next(app); + }; + } + } + + public sealed class SecureModeHost : PrivaPubHost + { + static readonly SemaphoreSlim Boot = new(1, 1); + static SecureModeHost _shared; + + public static new async Task Shared() + { + await PrivaPubHost.Shared(); + await Boot.WaitAsync(); + try + { + if (_shared == default) + { + var host = new SecureModeHost(); + _ = host.Services; + _shared = host; + } + return _shared; + } + finally + { + Boot.Release(); + } + } + + protected override IEnumerable> Settings() => + base.Settings().Append(new KeyValuePair("Federation:SecureMode", "true")); + } +} diff --git a/PrivaPub.Tests/Support/MongoFixture.cs b/PrivaPub.Tests/Support/MongoFixture.cs index 23352c1..a0b826a 100644 --- a/PrivaPub.Tests/Support/MongoFixture.cs +++ b/PrivaPub.Tests/Support/MongoFixture.cs @@ -41,6 +41,7 @@ namespace PrivaPub.Tests.Support await DB.InitAsync(Database, settings); await RefuseProductionData(); EntityMaps.Warm(); + await DB.Default.MigrateAsync(); await Indexes.Create(); } diff --git a/PrivaPub.Tests/Support/Peer.cs b/PrivaPub.Tests/Support/Peer.cs index 6923324..d08b157 100644 --- a/PrivaPub.Tests/Support/Peer.cs +++ b/PrivaPub.Tests/Support/Peer.cs @@ -17,7 +17,7 @@ namespace PrivaPub.Tests.Support public sealed class Peer : IAsyncDisposable { readonly WebApplication _app; - readonly ConcurrentDictionary _documents = new(); + readonly ConcurrentDictionary _documents = new(); readonly ConcurrentDictionary _answers = new(); readonly ConcurrentDictionary _files = new(); @@ -40,12 +40,15 @@ namespace PrivaPub.Tests.Support Peer peer = default; app.Run(async context => { - peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString())); + 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)) { - context.Response.ContentType = file.ContentType; - await context.Response.Body.WriteAsync(file.Bytes); + await Results.Bytes(file.Bytes, file.ContentType, enableRangeProcessing: true).ExecuteAsync(context); return; } if (peer._answers.TryGetValue(key, out var answer)) @@ -60,15 +63,17 @@ namespace PrivaPub.Tests.Support context.Response.StatusCode = StatusCodes.Status404NotFound; return; } - context.Response.ContentType = "application/activity+json"; - await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B)); + 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; + 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); @@ -95,7 +100,8 @@ namespace PrivaPub.Tests.Support public Task Reload(CancellationToken token) => Task.CompletedTask; } - public sealed record HttpRequestRecord(string Method, string Path, string Signature); + public sealed record HttpRequestRecord(string Method, string Path, string Signature, string Body = default, + IReadOnlyDictionary Headers = default); public sealed class StaticOptions : IOptionsMonitor { diff --git a/PrivaPub.Tests/Support/RemoteActor.cs b/PrivaPub.Tests/Support/RemoteActor.cs index ef87494..8fdfb06 100644 --- a/PrivaPub.Tests/Support/RemoteActor.cs +++ b/PrivaPub.Tests/Support/RemoteActor.cs @@ -44,18 +44,46 @@ namespace PrivaPub.Tests.Support } }; + public HttpRequestMessage SignedGet(string path, string host = "privapub.test") + { + var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.TryAddWithoutValidation("Date", date); + request.Headers.TryAddWithoutValidation("Accept", "application/activity+json"); + request.Headers.TryAddWithoutValidation("Signature", Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date")); + return request; + } + + public HttpRequestMessage SignedPost(string path, JsonNode activity, string host = "privapub.test", string date = default) + { + var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); + date ??= DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); + var digest = HttpSignatures.Digest(body); + var request = new HttpRequestMessage(HttpMethod.Post, path) { Content = new ByteArrayContent(body) }; + request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/activity+json"); + request.Headers.TryAddWithoutValidation("Date", date); + request.Headers.TryAddWithoutValidation("Digest", digest); + request.Headers.TryAddWithoutValidation("Signature", + Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest")); + return request; + } + + string Signature(string signingString, string headers) + { + var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + return $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"{headers}\",signature=\"{signature}\""; + } + public HttpRequest Get(string host, string path) { var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); - var signingString = $"(request-target): get {path}\nhost: {host}\ndate: {date}"; - var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); var context = new DefaultHttpContext(); context.Request.Method = "GET"; context.Request.Host = new HostString(host); context.Request.Path = path; context.Features.Get().RawTarget = path; context.Request.Headers["Date"] = date; - context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date\",signature=\"{signature}\""; + context.Request.Headers["Signature"] = Signature($"(request-target): get {path}\nhost: {host}\ndate: {date}", "(request-target) host date"); return context.Request; } @@ -64,9 +92,6 @@ namespace PrivaPub.Tests.Support var body = Encoding.UTF8.GetBytes(activity.ToJsonString()); var date = DateTimeOffset.UtcNow.ToString("r", CultureInfo.InvariantCulture); var digest = HttpSignatures.Digest(body); - var signingString = $"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}"; - var signature = Convert.ToBase64String(_key.SignData(Encoding.UTF8.GetBytes(signingString), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); - var context = new DefaultHttpContext(); context.Request.Method = "POST"; context.Request.Host = new HostString(host); @@ -75,7 +100,7 @@ namespace PrivaPub.Tests.Support context.Request.Headers["Date"] = date; context.Request.Headers["Digest"] = digest; context.Request.Headers["Content-Type"] = "application/activity+json"; - context.Request.Headers["Signature"] = $"keyId=\"{KeyId}\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"{signature}\""; + context.Request.Headers["Signature"] = Signature($"(request-target): post {path}\nhost: {host}\ndate: {date}\ndigest: {digest}", "(request-target) host date digest"); context.Request.Body = new MemoryStream(body); context.Request.ContentLength = body.Length; return context.Request; diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index e635b62..e825882 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -78,7 +78,7 @@ try try { - BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); + BsonSerializer.TryRegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get(); await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString)); EntityMaps.Warm();