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();