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
120 lines
5.2 KiB
C#
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 }
|
|
});
|
|
}
|
|
}
|