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 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 BackUp(ILocator panel) { var before = (await PastureAdmin.Get("/clientapi/admin/backups"))!["backups"]!.AsArray().Select(b => b!["id"]!.GetValue()).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()).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(); 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() == 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() == id)!; Assert.Equal("uploaded", uploaded["kind"]!.GetValue()); 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(); 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(); } } }