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
139 lines
6.5 KiB
C#
139 lines
6.5 KiB
C#
using System.Formats.Tar;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using decePubClient.E2E.Support;
|
|
|
|
using Microsoft.Playwright;
|
|
|
|
namespace decePubClient.E2E.Actions
|
|
{
|
|
// The server's backups from Administration (owner decision 2026-10-07), as the pasture's administrator: one made now,
|
|
// downloaded through its link (a tar whose files are the backup's), deleted and uploaded again from the browser in
|
|
// pieces; each step checked on /clientapi
|
|
public sealed class BackupsTests(Browser browser)
|
|
{
|
|
const string Root = PastureAdmin.Root;
|
|
static readonly Pasture.Persona Admin = new("alice", "alice", Root, PastureAdmin.Password, null, null, null);
|
|
|
|
internal static async Task<ILocator> Open(IPage page)
|
|
{
|
|
await page.GotoAsync($"{Pasture.Site}/administration");
|
|
var header = page.Locator(".collapse-title", new() { HasText = "Backups" }).First;
|
|
await header.ClickAsync();
|
|
var panel = header.Locator("xpath=..").Locator("[data-backups]");
|
|
await panel.Locator("[data-backup-now]").WaitForAsync(new() { Timeout = 30000 });
|
|
return panel;
|
|
}
|
|
|
|
internal static async Task<string> BackUp(ILocator panel)
|
|
{
|
|
var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).ToHashSet();
|
|
await panel.Locator("[data-backup-now]").ClickAsync();
|
|
var made = default(string);
|
|
Assert.True(await Pasture.Eventually(async () =>
|
|
{
|
|
var list = (await PastureAdmin.Get("/clientapi/admin/backups"))!;
|
|
made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).FirstOrDefault(id => !before.Contains(id));
|
|
return made is not null && list["running"] is null;
|
|
}, 300), "the backup was never made");
|
|
await Assertions.Expect(panel.Locator($"[data-backup='{made}']")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
|
return made;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_backup_is_made_downloaded_and_uploaded_again()
|
|
{
|
|
await using var session = await SignIn.As(browser, Admin);
|
|
var page = session.Page;
|
|
var file = Path.Combine(Path.GetTempPath(), $"decepub-backup-{Guid.NewGuid():N}.tar");
|
|
try
|
|
{
|
|
await Results.Cell("client.admin.backup", "privapub", Root, async () =>
|
|
{
|
|
var panel = await Open(page);
|
|
var id = await BackUp(panel);
|
|
var row = panel.Locator($"[data-backup='{id}']");
|
|
await Assertions.Expect(row).ToContainTextAsync("Made by hand");
|
|
|
|
// the link, for the password
|
|
await row.Locator("[data-backup-download]").ClickAsync();
|
|
var dialog = page.Locator("[data-backup-download-dialog]");
|
|
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
|
await dialog.Locator("[data-backup-confirm]").ClickAsync();
|
|
var link = dialog.Locator("[data-backup-link]");
|
|
await Assertions.Expect(link).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
|
|
await download.SaveAsAsync(file);
|
|
var names = new List<string>();
|
|
await using (var reader = new TarReader(File.OpenRead(file)))
|
|
while (await reader.GetNextEntryAsync() is { } entry)
|
|
names.Add(entry.Name);
|
|
Assert.Contains($"{id}/manifest.json", names);
|
|
Assert.Contains(names, n => n.StartsWith($"{id}/db/", StringComparison.Ordinal));
|
|
await dialog.Locator("[data-backup-dialog-close]").ClickAsync();
|
|
|
|
// deleted (asked first), then uploaded again from the file
|
|
await row.Locator("[data-backup-delete]").ClickAsync();
|
|
await page.Locator("[data-confirm-yes]").ClickAsync();
|
|
await Assertions.Expect(row).ToHaveCountAsync(0, new() { Timeout = 15000 });
|
|
Assert.DoesNotContain((await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray(), b => b!["id"]!.GetValue<string>() == id);
|
|
|
|
await panel.Locator("input[data-backup-upload]").SetInputFilesAsync(file);
|
|
await panel.Locator("[data-backup-upload-start]").ClickAsync();
|
|
await Assertions.Expect(row).ToContainTextAsync("Uploaded", new() { Timeout = 120000 });
|
|
var uploaded = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Single(b => b!["id"]!.GetValue<string>() == id)!;
|
|
Assert.Equal("uploaded", uploaded["kind"]!.GetValue<string>());
|
|
await session.AssertGuards();
|
|
}, page);
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
// A real restore of the pasture from Administration: PrivaPub stops and restores as it starts again, every session
|
|
// ends, and the page says so; signed in again, the last restore's report is there. It restores the whole server and
|
|
// ends the town's sessions, so it runs only when asked (--filter "Category=Restore"), alone, and signs the town in
|
|
// again (town.sh renew privapub).
|
|
[Trait("Category", "Restore")]
|
|
public sealed class RestoreTests(Browser browser)
|
|
{
|
|
static readonly Pasture.Persona Admin = new("alice", "alice", PastureAdmin.Root, PastureAdmin.Password, null, null, null);
|
|
|
|
[Fact]
|
|
public async Task A_backup_is_restored_and_everyone_signs_in_again()
|
|
{
|
|
await using var session = await SignIn.As(browser, Admin);
|
|
var page = session.Page;
|
|
await Results.Cell("client.admin.restore", "privapub", PastureAdmin.Root, async () =>
|
|
{
|
|
var panel = await BackupsTests.Open(page);
|
|
var id = await BackupsTests.BackUp(panel);
|
|
var host = (await PastureAdmin.Get("/clientapi/admin/backups"))!["host"]!.GetValue<string>();
|
|
|
|
await panel.Locator($"[data-backup='{id}'] [data-backup-restore]").ClickAsync();
|
|
var dialog = page.Locator("[data-backup-restore-dialog]");
|
|
await dialog.Locator("[data-backup-password]").FillAsync(PastureAdmin.Password);
|
|
var confirm = dialog.Locator("[data-backup-confirm]");
|
|
await dialog.Locator("[data-backup-host]").FillAsync("elsewhere.test");
|
|
await Assertions.Expect(confirm).ToBeDisabledAsync();
|
|
await dialog.Locator("[data-backup-host]").FillAsync(host);
|
|
await confirm.ClickAsync();
|
|
await Assertions.Expect(page.Locator("[data-backup-restarting]")).ToBeVisibleAsync(new() { Timeout = 15000 });
|
|
|
|
// once PrivaPub is back, the session it ended sends the page to sign in again
|
|
await page.WaitForURLAsync(url => url.Contains("/login"), new() { Timeout = 300000 });
|
|
await Pasture.RenewTown();
|
|
}, page);
|
|
|
|
await using var again = await SignIn.As(browser, Admin);
|
|
var panelAgain = await BackupsTests.Open(again.Page);
|
|
await Assertions.Expect(panelAgain.Locator("[data-last-restore]")).ToBeVisibleAsync(new() { Timeout = 30000 });
|
|
Assert.NotNull((await PastureAdmin.Get("/clientapi/admin/backups"))!["lastRestore"]);
|
|
await again.AssertGuards();
|
|
}
|
|
}
|
|
}
|