- file domains are created, queried, sized and deleted by the file service (POST/GET/DELETE /file/v1/domains), with per-domain quota enforced on write - /file/v1/options publishes the quota choices; nothing about files lives on the host any more - tickets carry the issuing space and a unique id: a domain only accepts tickets from its own space, and read/delete tickets work once (no replaying a counted download) - uploads stay pending until the client confirms them; abandoned ones are swept after 24h, and an upload ticket can cancel its own bytes - backup target is S3 (SigV4 client, no SDK) instead of WebDAV - CORS allows the methods the browser really uses (POST, DELETE) and exposes Retry-After
73 lines
3.1 KiB
C#
73 lines
3.1 KiB
C#
using System.IO.Compression;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using static WebDrop.Api.Tests.ServerFixture;
|
|
|
|
namespace WebDrop.Api.Tests;
|
|
|
|
/// <summary>主后端每天把数据库快照压缩后传到外部存储(S3 协议),按保留期清理旧的。</summary>
|
|
public sealed class BackupTests : IAsyncLifetime
|
|
{
|
|
private readonly FakeS3 _s3 = new();
|
|
private ServerFixture _server = null!;
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await _s3.StartAsync();
|
|
_server = ServerFixture.WithSettings(new()
|
|
{
|
|
["WebDrop:Backup:S3:Endpoint"] = _s3.Url,
|
|
["WebDrop:Backup:S3:AccessKey"] = FakeS3.AccessKey,
|
|
["WebDrop:Backup:S3:SecretKey"] = FakeS3.SecretKey,
|
|
["WebDrop:Backup:S3:BasePath"] = "webdrop",
|
|
["WebDrop:Backup:S3:Bucket"] = FakeS3.Bucket,
|
|
["WebDrop:Backup:RetainDays"] = "30",
|
|
});
|
|
await _server.InitializeAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
await _server.DisposeAsync();
|
|
await _s3.DisposeAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TheSnapshotIsAConsistentCompressedDatabaseAndOldOnesAreRetired()
|
|
{
|
|
var ch = await _server.CreateAsync();
|
|
var worker = _server.App.Services.GetServices<Microsoft.Extensions.Hosting.IHostedService>().OfType<WebDrop.Api.BackupWorker>().Single();
|
|
|
|
// 40 天前做的一份,和今天的一份
|
|
var old = await worker.RunOnceAsync(DateTime.Now.AddDays(-40), CancellationToken.None);
|
|
Assert.Equal(0, old.Purged);
|
|
Assert.True(_s3.Files.ContainsKey($"webdrop/chat/{old.Name}"));
|
|
|
|
var today = await worker.RunOnceAsync(DateTime.Now, CancellationToken.None);
|
|
Assert.Equal(1, today.Purged); // 超过 30 天的那份被清掉
|
|
Assert.False(_s3.Files.ContainsKey($"webdrop/chat/{old.Name}"));
|
|
Assert.True(_s3.Files.ContainsKey($"webdrop/chat/{today.Name}"));
|
|
|
|
// 备份能还原成数据库,里面有刚建的频道和账号
|
|
string path = Path.Combine(Path.GetTempPath(), "restore-" + Guid.NewGuid().ToString("N") + ".db");
|
|
try
|
|
{
|
|
using (var input = new MemoryStream(_s3.Files[$"webdrop/chat/{today.Name}"]))
|
|
using (var zip = new GZipStream(input, CompressionMode.Decompress))
|
|
using (var output = File.Create(path))
|
|
zip.CopyTo(output);
|
|
using var conn = new SqliteConnection($"Data Source={path};Mode=ReadOnly");
|
|
conn.Open();
|
|
using var count = conn.CreateCommand();
|
|
count.CommandText = "SELECT (SELECT COUNT(*) FROM channels), (SELECT COUNT(*) FROM accounts)";
|
|
using var reader = count.ExecuteReader();
|
|
Assert.True(reader.Read());
|
|
Assert.True(reader.GetInt64(0) >= 1 && reader.GetInt64(1) >= 2);
|
|
}
|
|
finally { SqliteConnection.ClearAllPools(); try { File.Delete(path); } catch (IOException) { } }
|
|
|
|
// 本地不留临时文件
|
|
Assert.Empty(Directory.EnumerateFiles(Path.Combine(_server.DataDir, "v2", "backup-tmp")));
|
|
}
|
|
}
|