Files
SocialPub/PrivaPub.Tests/Http/BackupEndpointTests.cs
thepraandClaude Opus 5.5 bc5f2beaf7 The administrator's page backs up, downloads, uploads and restores
/clientapi/admin/backups (owner decision 2026-10-07, approving these endpoints in production): the list with what runs,
the restore waiting and the last restore's report; back up now; delete; a download for the password, through a ticket
good for ten minutes in the link's path, as one tar whose length is known first and which honours Range (BackupTar);
an upload in pieces of at most 32 MB, each at the offset already received or refused with it, so a broken upload
resumes, read into a backup only when it holds nothing but plain files under one backup's folder; and a restore for
the password and the host typed out. Every call checks the administrator against the database. nginx streams downloads
for an hour and takes upload pieces unbuffered, rate-limited.

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

201 lines
9.8 KiB
C#

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<Backups>().Root ?? Path.GetTempPath());
return ValueTask.CompletedTask;
}
async Task<string> BackedUp(HttpClient admin)
{
var before = _host.Get<Backups>().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<JsonObject>(Base, Token))!;
made = list["backups"]!.AsArray().Select(b => b!["id"]!.GetValue<string>()).FirstOrDefault(id => !before.Contains(id));
return made != default && list["running"] is null;
}), "the backup was never made");
return made;
}
static async Task<bool> Wait(Func<Task<bool>> 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<RootUser>().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<JsonObject>(Base, Token))!;
Assert.Equal("privapub.test", listed["host"]!.GetValue<string>());
Assert.Equal("manual", listed["backups"]!.AsArray().Single(b => b!["id"]!.GetValue<string>() == id)!["kind"]!.GetValue<string>());
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<JsonObject>(Token))!;
var url = ticket["url"]!.GetValue<string>();
// 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<long>(), bytes.Length);
Assert.Equal(bytes.Length, whole.Content.Headers.ContentLength);
var names = new List<string>();
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<JsonObject>(Token))!["id"]!.GetValue<string>();
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<JsonObject>(Token))!["received"]!.GetValue<long>());
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<JsonObject>(Token))!["kind"]!.GetValue<string>());
Assert.Empty(await _host.Get<Backups>().Verify(id, Token));
Assert.Equal(HttpStatusCode.UnprocessableEntity, (await client.PostAsync($"{Base}/uploads/{upload}/finish", null, Token)).StatusCode);
_host.Get<Backups>().Delete(id);
}
Task<HttpResponseMessage> 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<JsonObject>(Token))!["id"]!.GetValue<string>();
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<Backups>().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<Backups>().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<JsonObject>(Base, Token))!["waiting"]!["backup"]!.GetValue<string>());
Assert.Equal(HttpStatusCode.Conflict, (await client.DeleteAsync($"{Base}/{id}", Token)).StatusCode);
}
finally
{
RestoreMarker.Clear(root);
_host.Get<Backups>().Delete(id);
}
}
}
}