using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.Infrastructure.Backup; using PrivaPub.Models.User; using PrivaPub.Tests.Support; using PrivaPub.Tests.Support.Host; using System.Formats.Tar; using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json.Nodes; namespace PrivaPub.Tests.Http { // The administrator's page backs up, downloads, uploads and restores (owner decision 2026-10-07): only an // administrator the database still says is one; a download and a restore ask for the password again, a restore for // the host typed out; an upload resumes where it stopped, and one that would write outside its backup is refused. [Trait("Category", "Integration")] [Xunit.Collection(nameof(Exclusive))] public sealed class BackupEndpointTests : IAsyncLifetime { const string Base = "/clientapi/admin/backups"; PrivaPubHost _host; static CancellationToken Token => TestContext.Current.CancellationToken; public async ValueTask InitializeAsync() { Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); _host = await PrivaPubHost.Shared(); } public ValueTask DisposeAsync() { RestoreMarker.Clear(_host?.Get().Root ?? Path.GetTempPath()); return ValueTask.CompletedTask; } async Task BackedUp(HttpClient admin) { var before = _host.Get().List().Select(b => b.Id).ToHashSet(); Assert.Equal(HttpStatusCode.Accepted, (await admin.PostAsync(Base, null, Token)).StatusCode); var made = default(string); Assert.True(await Wait(async () => { var list = (await admin.GetFromJsonAsync(Base, Token))!; made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue()).FirstOrDefault(id => !before.Contains(id)); return made != default && list["running"] is null; }), "the backup was never made"); return made; } static async Task Wait(Func> done) { for (var i = 0; i < 120; i++) { if (await done()) return true; await Task.Delay(250, Token); } return false; } [Fact] public async Task Only_an_administrator_the_database_still_knows() { var root = await _host.SignUp("plain"); using (var plain = _host.As(root.Jwt)) Assert.Equal(HttpStatusCode.Forbidden, (await plain.GetAsync(Base, Token)).StatusCode); var admin = await _host.Admin(); using var client = _host.As(admin.Jwt); Assert.Equal(HttpStatusCode.OK, (await client.GetAsync(Base, Token)).StatusCode); await DB.Default.Update().MatchID(admin.Id).Modify(u => u.Policies, [Policies.IsUser]).ExecuteAsync(Token); Assert.Equal(HttpStatusCode.Forbidden, (await client.GetAsync(Base, Token)).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await client.PostAsync(Base, null, Token)).StatusCode); } [Fact] public async Task A_backup_is_made_downloaded_whole_or_in_part_and_uploaded_again() { var admin = await _host.Admin(); using var client = _host.As(admin.Jwt); var id = await BackedUp(client); var listed = (await client.GetFromJsonAsync(Base, Token))!; Assert.Equal("privapub.test", listed["host"]!.GetValue()); Assert.Equal("manual", listed["backups"]!.AsArray().Single(b => b!["id"]!.GetValue() == id)!["kind"]!.GetValue()); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/ticket", new { password = "wrong" }, Token)).StatusCode); var ticket = (await (await client.PostAsJsonAsync($"{Base}/{id}/ticket", new { password = admin.Password }, Token)).Content.ReadFromJsonAsync(Token))!; var url = ticket["url"]!.GetValue(); // a plain link: no token needed, the ticket is the credential using var anonymous = _host.Client(); var whole = await anonymous.GetAsync(url, Token); Assert.Equal(HttpStatusCode.OK, whole.StatusCode); var bytes = await whole.Content.ReadAsByteArrayAsync(Token); Assert.Equal(ticket["bytes"]!.GetValue(), bytes.Length); Assert.Equal(bytes.Length, whole.Content.Headers.ContentLength); var names = new List(); await using (var reader = new TarReader(new MemoryStream(bytes))) while (await reader.GetNextEntryAsync(cancellationToken: Token) is { } entry) names.Add(entry.Name); Assert.Contains($"{id}/manifest.json", names); Assert.Contains($"{id}/db/Avatar.jsonl.gz", names); var part = new HttpRequestMessage(HttpMethod.Get, url); part.Headers.Range = new RangeHeaderValue(700, 2747); var partial = await anonymous.SendAsync(part, Token); Assert.Equal(HttpStatusCode.PartialContent, partial.StatusCode); Assert.Equal($"bytes 700-2747/{bytes.Length}", partial.Content.Headers.ContentRange!.ToString()); Assert.Equal(bytes.AsSpan(700, 2048).ToArray(), await partial.Content.ReadAsByteArrayAsync(Token)); Assert.Equal(HttpStatusCode.NotFound, (await anonymous.GetAsync($"{Base}/download/{new string('0', 64)}", Token)).StatusCode); // gone, then uploaded again in three pieces, one sent at the wrong place and refused with where to go on Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode); var upload = (await (await client.PostAsync($"{Base}/uploads", null, Token)).Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); var third = bytes.Length / 3; Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 0, bytes[..third])).StatusCode); var gap = await Piece(client, upload, third + 10, bytes[(third + 10)..]); Assert.Equal(HttpStatusCode.Conflict, gap.StatusCode); Assert.Equal(third, (await gap.Content.ReadFromJsonAsync(Token))!["received"]!.GetValue()); Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, third, bytes[third..(2 * third)])).StatusCode); Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 2 * third, bytes[(2 * third)..])).StatusCode); var finished = await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token); Assert.Equal(HttpStatusCode.OK, finished.StatusCode); Assert.Equal("uploaded", (await finished.Content.ReadFromJsonAsync(Token))!["kind"]!.GetValue()); Assert.Empty(await _host.Get().Verify(id, Token)); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token)).StatusCode); _host.Get().Delete(id); } Task Piece(HttpClient client, string upload, long offset, byte[] bytes) => client.PutAsync($"{Base}/uploads/{upload}?offset={offset}", new ByteArrayContent(bytes), Token); [Theory] [InlineData("escape")] [InlineData("link")] [InlineData("elsewhere")] [InlineData("junk")] public async Task An_upload_that_is_not_a_plain_backup_is_refused_and_leaves_nothing(string how) { var admin = await _host.Admin(); using var client = _host.As(admin.Jwt); const string id = "20260101-000000-manual"; var tar = new MemoryStream(); if (how == "junk") tar.Write("not a tar at all, not even close"u8); else await using (var writer = new TarWriter(tar, TarEntryFormat.Pax, leaveOpen: true)) { await writer.WriteEntryAsync(new PaxTarEntry(TarEntryType.RegularFile, $"{id}/manifest.json") { DataStream = new MemoryStream("{}"u8.ToArray()) }, Token); await writer.WriteEntryAsync(how switch { "escape" => new PaxTarEntry(TarEntryType.RegularFile, $"{id}/../../evil") { DataStream = new MemoryStream("x"u8.ToArray()) }, "link" => new PaxTarEntry(TarEntryType.SymbolicLink, $"{id}/media/x.jpg") { LinkName = "/etc/passwd" }, _ => new PaxTarEntry(TarEntryType.RegularFile, "20260101-000000-nightly/db/Post.jsonl.gz") { DataStream = new MemoryStream("x"u8.ToArray()) } }, Token); } var upload = (await (await client.PostAsync($"{Base}/uploads", null, Token)).Content.ReadFromJsonAsync(Token))!["id"]!.GetValue(); Assert.Equal(HttpStatusCode.OK, (await Piece(client, upload, 0, tar.ToArray())).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token)).StatusCode); var root = _host.Get().Root; Assert.False(Directory.Exists(Path.Combine(root, id))); Assert.False(Directory.Exists(Path.Combine(root, id + ServerBackup.PartialSuffix))); Assert.False(File.Exists(Path.Combine(root, "..", "evil"))); } // asked for: written for the next start (the test host has no watcher to stop it, and the marker is taken away) [Fact] public async Task A_restore_asks_for_the_password_and_the_host_typed_out() { var admin = await _host.Admin(); using var client = _host.As(admin.Jwt); var id = await BackedUp(client); var root = _host.Get().Root; try { Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = "wrong", host = "privapub.test" }, Token)).StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = admin.Password, host = "elsewhere.test" }, Token)).StatusCode); Assert.Null(RestoreMarker.Read(root)); Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsJsonAsync($"{Base}/{id}/restore", new { password = admin.Password, host = "PrivaPub.test" }, Token)).StatusCode); var marker = RestoreMarker.Read(root); Assert.Equal((id, "pending", admin.UserName), (marker.Backup, marker.State, marker.RequestedBy)); Assert.Equal(id, (await client.GetFromJsonAsync(Base, Token))!["waiting"]!["backup"]!.GetValue()); Assert.Equal(HttpStatusCode.Conflict, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode); } finally { RestoreMarker.Clear(root); _host.Get().Delete(id); } } } }