/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
201 lines
9.8 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|