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
118 lines
5.4 KiB
C#
118 lines
5.4 KiB
C#
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 }, host.Services));
|
|
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();
|
|
}
|
|
}
|