Owner decisions (2026-10-04, recorded in docs/ROADMAP.md): production runs everything that is built, and nothing waits
on a person running a command.
- Geolocation updates itself. GeoUpdater, a hosted service, checks daily whether each DB-IP Lite database was built this
month. If not, it fetches this month's, or last month's early in the month. It installs a file only once it opens as
the right kind of database, then swaps it in atomically, and the locator reloads at once. Lookups now run under the
lock, so a reload can no longer dispose a reader mid-lookup. The systemd timer, its script and their setup.sh lines
are gone: the root step they needed never happened, and none is needed now. /stargazing names the database in use.
- The admin CLI runs after the app is built, with every service and nothing started.
- `create-root <login> [--admin]` takes the password on stdin; it is how the first login is made while sign-up is
closed.
- `smoke <persona>` keeps the root `deploy-smoke` and an undiscoverable persona, and gives the root a new password
on every run.
- The deploy signs in as @thepra. It runs the CLI, gets a token through the real OAuth flow (tools/smoke/oauth.sh,
moved out of the pasture's privapub_token, which now uses it), checks the signed-in API and that @thepra is
undiscoverable, then revokes the token. PRIVAPUB_SMOKE_TOKEN is gone.
- The deploy also fails when:
- NodeInfo and the instance API disagree about registrations;
- /stargazing does not say the crawler is on;
- the geolocation databases are missing or more than 40 days old.
- The crawler is on in production, seeded with ten large servers of different kinds. FEDERATION.md now describes it
and how to opt out.
- One registrations switch (Registrations:Mode, default Invitations; Open in tests and the pasture). It is read by
open sign-up (403 when closed), NodeInfo `openRegistrations`, and v1 and v2 of the instance API, so they can no longer
disagree. Before, NodeInfo said open and the instance API said closed. Group invitations always work, so
invites_enabled is true.
- A persona edit through /clientapi no longer resets what the Mastodon API set (discoverable, locked, quote policy…):
the theme is merged into the settings instead of replacing them.
650 tests pass. The deploy's smoke step was rehearsed against the pasture's PrivaPub.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
164 lines
5.3 KiB
C#
164 lines
5.3 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) };
|
|
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",
|
|
["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"));
|
|
}
|
|
}
|