Files
SocialPub/PrivaPub.Tests/Support/Host/PrivaPubHost.cs
T
thepraandClaude Opus 5.5 12bb75809f The server backs itself up: every collection byte for byte, the media linked
A backup is a directory <stamp>-<kind> under Backups:Root (/var/lib/privapub/backups, 2770, files 0640), written as
.partial and renamed once whole: a manifest (host, build, newest migration, each collection's count, size, sha256 and
indexes, what was left out and why, the media list), each collection as gzipped canonical Extended JSON read raw, and
hard links to the files of untrashed media rows (copies where a link can't be made). On a replica set every collection
is read in one snapshot session. Never in a backup: the statistics salt, jobs, recovery codes, sessions, the
maintenance lock and the configuration's copy with its SMTP password.

One backup or restore at a time (MaintenanceLock, a heartbeat document), and the janitor purges nothing meanwhile.
BackupScheduler backs up nightly at 03:30 UTC (or at once after missing a night); rotation keeps 7 daily, 4 weekly,
3 pre-deploy and 3 pre-restore backups. CLI: admin backup [--kind] [--db-only], admin backups, admin backup verify;
these run before migrations, so the deploy's own pre-deploy backup, which replaces mongodump, is of the database as the
live build left it. EntityMaps.Warm runs once under a lock, since test hosts now boot side by side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 11:40:06 +02:00

173 lines
5.8 KiB
C#

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 PrivaPub.Infrastructure.Statistics;
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), typeof(StatisticsSchedule),
typeof(PrivaPub.Domain.Social.FollowResender), typeof(PrivaPub.Infrastructure.Backup.BackupScheduler) };
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",
["Registrations:Mode"] = "Open",
["Statistics:Geo:AutoUpdate"] = "false",
["Statistics:Cdn:AutoUpdate"] = "false",
["Media:Root"] = _mediaRoot,
["Backups:Root"] = _mediaRoot + "-backups",
["Backups:Nightly"] = "false",
["RateLimits:UploadsBurst"] = "1000",
["RateLimits:ProxyBurst"] = "100000",
["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)
return;
foreach (var directory in new[] { _mediaRoot, _mediaRoot + "-backups", _mediaRoot + "-trash", _mediaRoot + "-incoming", _mediaRoot + "-proxy" })
if (Directory.Exists(directory))
Directory.Delete(directory, 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"));
}
}