Files
decePubClient/tests/e2e/Support/Pasture.cs
T
thepraandClaude Opus 5.5 dc2190e271 Administration backs the server up, downloads, uploads and restores it
A Backups section (for administrators, PrivaPub's /clientapi/admin/backups): the backups kept with their kind, size,
media and the logins deleted since, what runs, the restore waiting and the last restore's report. Back up now; download
for the password through a link good for ten minutes, which the browser saves to disk; upload a downloaded backup in
pieces straight from the picked file (js/transfer.js PUTs file.slice at the offset PrivaPub has, never through .NET's
memory, and carries on after a refused or lost piece); delete after asking; restore for the password and the host typed
out, saying what it keeps and what it loses, then showing PrivaPub restarting until the ended session sends the page to
sign in.

e2e: a backup made, downloaded and uploaded again; and, only with --filter "Category=Restore", a real restore of the
pasture, which signs the town in again.

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

120 lines
5.2 KiB
C#

using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace decePubClient.E2E.Support
{
// The pasture and its town as the tests see them: the sites, the accounts the town made (read from the town's own
// state), and the town's command line for what each server holds, so a test checks the other side of the wire.
public static class Pasture
{
public const string Site = "https://decepub.test";
public const string Api = "https://privapub.test";
public static readonly string Root = Path.GetFullPath(Path.Combine(RepoRoot(), "..", "SocialPub", "tools", "pasture"));
static string RepoRoot()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !File.Exists(Path.Combine(dir, "decePubClient.csproj")))
dir = Path.GetDirectoryName(dir);
return dir ?? throw new InvalidOperationException("decePubClient.csproj not found above the test output");
}
public sealed record Persona(string UserName, string Name, string Root, string Password, string Token, string LocalId, string ActorUri);
// every PrivaPub persona the town made, with its root's login
public static IReadOnlyList<Persona> Personas()
{
var sessions = JsonNode.Parse(File.ReadAllText(Path.Combine(Root, ".state", "town", "sessions.json")))!.AsObject();
var personas = new List<Persona>();
foreach (var (key, value) in sessions)
{
// st_* are the drivers' selftest accounts, not the town's
if (!key.StartsWith("privapub/") || key.StartsWith("privapub/st_") || value?["account"]?["root"]?.GetValue<string>() is not { } root)
continue;
var account = value["account"]!;
personas.Add(new Persona(account["username"]!.GetValue<string>(), account["name"]?.GetValue<string>(), root,
account["password"]!.GetValue<string>(),
value["token"]!.GetValue<string>(), value["local_id"]!.GetValue<string>(), value["actor_uri"]!.GetValue<string>()));
}
return personas;
}
// tools/pasture/town.sh <args>, its standard output parsed as JSON
public static async Task<JsonNode> Town(params string[] args)
{
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add(Path.Combine(Root, "town", "town.py"));
foreach (var arg in args)
start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
var output = await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"town.sh {string.Join(' ', args)} failed: {error}");
return JsonNode.Parse(output.Trim().Split('\n').Last())!;
}
// the town's PrivaPub accounts signed in again (town.sh renew privapub): a restore ends every session
public static async Task RenewTown()
{
var start = new ProcessStartInfo("python3") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { Path.Combine(Root, "town", "town.py"), "renew", "privapub" })
start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
await process.StandardOutput.ReadToEndAsync();
var error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"town.sh renew privapub failed: {error}");
}
// a script on PrivaPub's own database in the pasture, to undo what no API undoes
public static async Task Mongo(string script)
{
var start = new ProcessStartInfo("podman") { RedirectStandardOutput = true, RedirectStandardError = true };
foreach (var arg in new[] { "exec", "pasture-mongo", "mongosh", "--quiet", "PrivaPub", "--eval", script })
start.ArgumentList.Add(arg);
using var process = Process.Start(start)!;
var error = await process.StandardError.ReadToEndAsync();
await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
throw new InvalidOperationException($"mongosh failed: {error}");
}
// what a server holds of each object, from its database (town.sh stored)
public static async Task<JsonObject> Stored(string platform, params string[] uris) =>
(await Town(["stored", platform, .. uris])).AsObject();
public static async Task<bool> Eventually(Func<Task<bool>> condition, int seconds = 30)
{
var until = DateTime.UtcNow.AddSeconds(seconds);
while (true)
{
if (await condition())
return true;
if (DateTime.UtcNow > until)
return false;
await Task.Delay(1000);
}
}
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
// an HttpClient that reaches https://<name>.test through Caddy on 127.0.0.1:6443, as the browser does
public static HttpClient Http() => new(new SocketsHttpHandler
{
ConnectCallback = async (context, token) =>
{
var socket = new System.Net.Sockets.Socket(System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Tcp);
await socket.ConnectAsync("127.0.0.1", 6443, token);
return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true);
},
SslOptions = { RemoteCertificateValidationCallback = (_, _, _, _) => true }
});
}
}