T3: the whole server under test
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
This commit is contained in:
1 parent
85fdff606d
commit
5e34517e73
11 files changed
+546
-18
No files matched your search
@@ -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<Root> 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<JsonObject>())!;
|
||||
return new Root(jwt["userId"]!.GetValue<string>(), userName, Password, jwt["token"]!.GetValue<string>());
|
||||
}
|
||||
|
||||
public static async Task<Root> 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<JsonObject>())!;
|
||||
return root with { Jwt = jwt["token"]!.GetValue<string>() };
|
||||
}
|
||||
|
||||
public static async Task<Root> 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> 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<JsonObject>())!;
|
||||
return new Persona(avatar["id"]!.GetValue<string>(), userName, root);
|
||||
}
|
||||
|
||||
public static async Task<string> 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<string>();
|
||||
var clientSecret = app["client_secret"]!.GetValue<string>();
|
||||
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<string>();
|
||||
}
|
||||
|
||||
public static async Task<string> 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<string, string>
|
||||
{
|
||||
["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<string, string>(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<JsonObject> Form(HttpClient client, string path, params (string Key, string Value)[] fields)
|
||||
{
|
||||
var response = await client.PostAsync(path, new FormUrlEncodedContent(fields.Select(f => new KeyValuePair<string, string>(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("<input type=\"hidden\" name=\"([^\"]*)\" value=\"([^\"]*)\"")]
|
||||
private static partial Regex HiddenInput();
|
||||
|
||||
[GeneratedRegex("<code>([^<]*)</code>")]
|
||||
private static partial Regex Code();
|
||||
}
|
||||
}
|
||||
@@ -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<int> Run(this PrivaPubHost host, Expression<Func<Job, bool>> which, CancellationToken token = default)
|
||||
{
|
||||
var handlers = host.Services.GetServices<IJobHandler>().ToDictionary(h => h.Kind);
|
||||
var queue = host.Get<IJobQueue>();
|
||||
var ran = 0;
|
||||
for (var round = 0; round < 50; round++)
|
||||
{
|
||||
var pending = await DB.Default.Find<Job>().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<Job>()
|
||||
.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<int> 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<List<JsonObject>> Deliveries(string inbox, DateTime since, CancellationToken token = default) =>
|
||||
(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.Deliver && j.CreatedAt >= since).ExecuteAsync(token))
|
||||
.Select(j => JsonSerializer.Deserialize<DeliveryPayload>(j.Payload))
|
||||
.Where(p => p.Inbox == inbox)
|
||||
.Select(p => JsonNode.Parse(p.Body)!.AsObject())
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -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<Program>
|
||||
{
|
||||
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<ServiceDescriptor> Registered { get; private set; }
|
||||
|
||||
public static async Task<PrivaPubHost> Shared()
|
||||
{
|
||||
await Boot.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_shared == default)
|
||||
{
|
||||
var host = new PrivaPubHost();
|
||||
_ = host.Services;
|
||||
_shared = host;
|
||||
}
|
||||
return _shared;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Boot.Release();
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual IEnumerable<KeyValuePair<string, string>> Settings() => new Dictionary<string, string>
|
||||
{
|
||||
["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<IStartupFilter, ClientAddressFilter>();
|
||||
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<T>() where T : notnull => Services.GetRequiredService<T>();
|
||||
|
||||
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<IApplicationBuilder> Configure(Action<IApplicationBuilder> 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<SecureModeHost> 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<KeyValuePair<string, string>> Settings() =>
|
||||
base.Settings().Append(new KeyValuePair<string, string>("Federation:SecureMode", "true"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user