feat(files): file service owns file domains, quotas and S3 backup

- 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
This commit is contained in:
justme committed 2026-10-19 13:38:29 +08:00
1 parent d3da3597cc
commit ccb2c074d6
19 files changed
+827 -278

No files matched your search

+13 -12
View File
@@ -5,21 +5,22 @@ using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
/// <summary>主后端每天把数据库快照压缩后传到外部存储(WebDAV),按保留期清理旧的。</summary>
/// <summary>主后端每天把数据库快照压缩后传到外部存储(S3 协议),按保留期清理旧的。</summary>
public sealed class BackupTests : IAsyncLifetime
{
private readonly FakeWebDav _dav = new();
private readonly FakeS3 _s3 = new();
private ServerFixture _server = null!;
public async Task InitializeAsync()
{
await _dav.StartAsync();
await _s3.StartAsync();
_server = ServerFixture.WithSettings(new()
{
["WebDrop:Backup:WebDav:Url"] = _dav.Url,
["WebDrop:Backup:WebDav:Username"] = FakeWebDav.User,
["WebDrop:Backup:WebDav:Password"] = FakeWebDav.Password,
["WebDrop:Backup:WebDav:BasePath"] = "webdrop",
["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();
@@ -28,7 +29,7 @@ public sealed class BackupTests : IAsyncLifetime
public async Task DisposeAsync()
{
await _server.DisposeAsync();
await _dav.DisposeAsync();
await _s3.DisposeAsync();
}
[Fact]
@@ -40,18 +41,18 @@ public sealed class BackupTests : IAsyncLifetime
// 40 天前做的一份,和今天的一份
var old = await worker.RunOnceAsync(DateTime.Now.AddDays(-40), CancellationToken.None);
Assert.Equal(0, old.Purged);
Assert.True(_dav.Files.ContainsKey($"webdrop/chat/{old.Name}"));
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(_dav.Files.ContainsKey($"webdrop/chat/{old.Name}"));
Assert.True(_dav.Files.ContainsKey($"webdrop/chat/{today.Name}"));
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(_dav.Files[$"webdrop/chat/{today.Name}"]))
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);
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace WebDrop.Api.Tests;
/// <summary>
/// 只实现备份用到的 S3 子集(PUT、HEAD、DELETE、ListObjectsV2),路径风格,数据放在内存里。
/// 检查 SigV4 的凭据字段和 x-amz-content-sha256 与正文是否一致;不重算签名本身(签名算法由真实服务兜底)。
/// </summary>
public sealed class FakeS3 : IAsyncDisposable
{
private readonly WebApplication _app;
/// <summary>键(不含存储桶)→ 内容</summary>
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
public bool Down { get; set; }
public string Url { get; private set; } = "";
public const string AccessKey = "AKIATEST", SecretKey = "secret", Bucket = "bk";
public FakeS3()
{
var builder = WebApplication.CreateSlimBuilder();
builder.Configuration["urls"] = "http://127.0.0.1:0";
builder.Logging.ClearProviders();
_app = builder.Build();
_app.Map("/{bucket}/{**key}", async (HttpContext ctx, string bucket, string? key) =>
{
if (Down) { ctx.Response.StatusCode = 503; return; }
string auth = ctx.Request.Headers.Authorization.ToString();
if (bucket != Bucket || !auth.StartsWith($"AWS4-HMAC-SHA256 Credential={AccessKey}/", StringComparison.Ordinal) || !auth.Contains("Signature=") || !ctx.Request.Headers.ContainsKey("x-amz-date"))
{ ctx.Response.StatusCode = 403; return; }
key = Uri.UnescapeDataString(key ?? "");
switch (ctx.Request.Method)
{
case "PUT":
{
using var ms = new MemoryStream();
await ctx.Request.Body.CopyToAsync(ms);
var body = ms.ToArray();
if (!string.Equals(ctx.Request.Headers["x-amz-content-sha256"], Convert.ToHexStringLower(SHA256.HashData(body)), StringComparison.Ordinal)) { ctx.Response.StatusCode = 400; return; }
Files[key] = body;
ctx.Response.StatusCode = 200;
break;
}
case "HEAD": ctx.Response.StatusCode = Files.ContainsKey(key) ? 200 : 404; break;
case "DELETE": Files.TryRemove(key, out _); ctx.Response.StatusCode = 204; break;
case "GET":
{
string prefix = ctx.Request.Query["prefix"].ToString(), delimiter = ctx.Request.Query["delimiter"].ToString();
var keys = Files.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).Order(StringComparer.Ordinal).ToList();
XNamespace ns = "http://s3.amazonaws.com/doc/2006-03-01/";
var root = new XElement(ns + "ListBucketResult", new XElement(ns + "IsTruncated", "false"));
var folders = new SortedSet<string>(StringComparer.Ordinal);
foreach (var k in keys)
{
int cut = delimiter.Length == 0 ? -1 : k.IndexOf(delimiter, prefix.Length, StringComparison.Ordinal);
if (cut < 0) root.Add(new XElement(ns + "Contents", new XElement(ns + "Key", k)));
else folders.Add(k[..(cut + delimiter.Length)]);
}
foreach (var f in folders) root.Add(new XElement(ns + "CommonPrefixes", new XElement(ns + "Prefix", f)));
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "application/xml";
await ctx.Response.WriteAsync(root.ToString(), Encoding.UTF8);
break;
}
default: ctx.Response.StatusCode = 405; break;
}
});
}
public async Task StartAsync()
{
await _app.StartAsync();
Url = _app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First().TrimEnd('/');
}
public async ValueTask DisposeAsync()
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
@@ -1,81 +0,0 @@
using System.Collections.Concurrent;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace WebDrop.Api.Tests;
/// <summary>只实现备份用到的 WebDAV 子集(MKCOL、PUT、DELETE、PROPFIND Depth 1),数据放在内存里,带基本认证。</summary>
public sealed class FakeWebDav : IAsyncDisposable
{
private readonly WebApplication _app;
public ConcurrentDictionary<string, byte[]> Files { get; } = new();
public ConcurrentDictionary<string, bool> Dirs { get; } = new();
public bool Down { get; set; }
public string Url { get; private set; } = "";
public const string User = "backup", Password = "p@ss";
public FakeWebDav()
{
var builder = WebApplication.CreateSlimBuilder();
builder.Configuration["urls"] = "http://127.0.0.1:0";
builder.Logging.ClearProviders();
_app = builder.Build();
_app.Map("/dav/{**path}", async (HttpContext ctx, string? path) =>
{
if (Down) { ctx.Response.StatusCode = 503; return; }
string expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{User}:{Password}"));
if (ctx.Request.Headers.Authorization != expected) { ctx.Response.StatusCode = 401; return; }
path = Uri.UnescapeDataString(path ?? "").Trim('/');
switch (ctx.Request.Method)
{
case "MKCOL":
ctx.Response.StatusCode = Dirs.TryAdd(path, true) ? 201 : 405;
break;
case "PUT":
using (var ms = new MemoryStream()) { await ctx.Request.Body.CopyToAsync(ms); Files[path] = ms.ToArray(); }
ctx.Response.StatusCode = 201;
break;
case "DELETE":
{
bool removed = Files.TryRemove(path, out _);
foreach (var key in Files.Keys.Where(k => k.StartsWith(path + "/", StringComparison.Ordinal)).ToList()) { Files.TryRemove(key, out _); removed = true; }
foreach (var key in Dirs.Keys.Where(k => k == path || k.StartsWith(path + "/", StringComparison.Ordinal)).ToList()) { Dirs.TryRemove(key, out _); removed = true; }
ctx.Response.StatusCode = removed ? 204 : 404;
break;
}
case "PROPFIND":
{
if (path.Length > 0 && !Dirs.ContainsKey(path)) { ctx.Response.StatusCode = 404; return; }
string prefix = path.Length == 0 ? "" : path + "/";
var children = Files.Keys.Concat(Dirs.Keys).Where(k => k.StartsWith(prefix, StringComparison.Ordinal) && k.Length > prefix.Length && !k[prefix.Length..].Contains('/')).Distinct().Order();
var xml = new StringBuilder("<?xml version=\"1.0\"?><d:multistatus xmlns:d=\"DAV:\">");
xml.Append($"<d:response><d:href>/dav/{path}/</d:href></d:response>");
foreach (var child in children) xml.Append($"<d:response><d:href>/dav/{child}</d:href></d:response>");
xml.Append("</d:multistatus>");
ctx.Response.StatusCode = 207;
ctx.Response.ContentType = "application/xml";
await ctx.Response.WriteAsync(xml.ToString());
break;
}
default: ctx.Response.StatusCode = 405; break;
}
});
}
public async Task StartAsync()
{
await _app.StartAsync();
Url = _app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First().TrimEnd('/') + "/dav";
}
public async ValueTask DisposeAsync()
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
@@ -0,0 +1,248 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using WebDrop.FileApi;
using WebDrop.FileProtocol;
namespace WebDrop.Api.Tests;
/// <summary>文件域是 fileAPI 自己的概念:创建、查询、容量、删除都由它负责,凭空间签发的票据访问。</summary>
public sealed class FileDomainTests : IAsyncLifetime
{
private const string Secret = "domain-test-secret-0123456789abcdef0123";
private static readonly byte[] SecretBytes = Encoding.UTF8.GetBytes(Secret);
private readonly string _dir = Path.Combine(Path.GetTempPath(), "wd-domain-" + Guid.NewGuid().ToString("N"));
private WebApplication _files = null!;
private HttpClient _http = null!;
private readonly string _space = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
public async Task InitializeAsync()
{
_files = FileApiApp.Build([], b =>
{
b.Configuration["FileApi:DataDir"] = _dir;
b.Configuration["FileApi:Secret"] = Secret;
b.Configuration["FileApi:QuotaChoices:0"] = "2048";
b.Configuration["FileApi:QuotaChoices:1"] = "8192";
b.Configuration["FileApi:DefaultQuota"] = "2048";
b.Configuration["FileApi:CorsOrigins:0"] = "http://localhost:5173";
b.Configuration["urls"] = "http://127.0.0.1:0";
b.Logging.ClearProviders();
});
await _files.StartAsync();
_http = new HttpClient { BaseAddress = new Uri(_files.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
}
public async Task DisposeAsync()
{
_http.Dispose();
await _files.StopAsync();
await _files.DisposeAsync();
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
}
/// <summary>票据默认是本测试的空间签发的(管理票据不属于任何空间)</summary>
private string Ticket(FileOp op, string channel, string item = "*", string? space = null) =>
FileTokens.Sign(SecretBytes, new FileTicket(op, channel, item, 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + 60_000, space ?? (op == FileOp.Admin ? "" : _space)));
private HttpRequestMessage Request(HttpMethod method, string url, string ticket, HttpContent? content = null)
{
var request = new HttpRequestMessage(method, url) { Content = content };
request.Headers.Add(FileWire.TicketHeader, ticket);
return request;
}
private async Task<FileDomainBody> CreateAsync(long quota = 0)
{
using var request = Request(HttpMethod.Post, "/file/v1/domains", Ticket(FileOp.Create, _space), JsonContent.Create(new FileDomainCreate(quota), options: null));
using var response = await _http.SendAsync(request);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(), FileProtocolJson.Default.FileDomainBody)!;
}
private static byte[] Frame(string itemHex, uint index, int payload)
{
var frame = new byte[FrameHeader.Size + payload];
frame[0] = FrameHeader.Version;
frame[1] = FrameHeader.FinalFlag;
Convert.FromHexString(itemHex).CopyTo(frame, 2);
BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(18), index);
BinaryPrimitives.WriteUInt32BigEndian(frame.AsSpan(22), (uint)payload);
RandomNumberGenerator.Fill(frame.AsSpan(FrameHeader.Size));
return frame;
}
private Task<HttpResponseMessage> PutAsync(string domain, string item, uint index, byte[] frame, string? ticket = null) =>
_http.SendAsync(Request(HttpMethod.Put, $"/file/v1/domains/{domain}/items/{item}/frames/{index}", ticket ?? Ticket(FileOp.Put, domain, item), new ByteArrayContent(frame)));
[Fact]
public async Task OptionsAreAPublicStatementOfWhatTheFileServiceOffers()
{
var options = JsonSerializer.Deserialize(await _http.GetStringAsync("/file/v1/options"), FileProtocolJson.Default.FileDomainOptions)!;
Assert.Equal([2048L, 8192L], options.QuotaChoices);
Assert.Equal(2048, options.DefaultQuota);
}
[Fact]
public async Task OnlyASpaceIssuedCreateTicketCreatesADomainAndTheQuotaMustBeOneOfTheChoices()
{
var domain = await CreateAsync(8192);
Assert.Equal(8192, domain.Quota);
Assert.Equal(_space, domain.Space);
Assert.True(FileWire.IsHex32(domain.Id));
Assert.Equal(2048, (await CreateAsync()).Quota); // 不选用默认值
using (var odd = Request(HttpMethod.Post, "/file/v1/domains", Ticket(FileOp.Create, _space), JsonContent.Create(new FileDomainCreate(12345))))
using (var rejected = await _http.SendAsync(odd)) Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
// 读写票据、没有票据都不能创建
using (var wrong = Request(HttpMethod.Post, "/file/v1/domains", Ticket(FileOp.Put, _space, "*"), JsonContent.Create(new FileDomainCreate())))
using (var denied = await _http.SendAsync(wrong)) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
using (var anonymous = await _http.PostAsync("/file/v1/domains", JsonContent.Create(new FileDomainCreate()))) Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
[Fact]
public async Task WritesAreCountedAgainstTheDomainQuotaAndOverflowIsRefused()
{
var domain = await CreateAsync(); // 2048 字节
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), other = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var first = await PutAsync(domain.Id, item, 0, Frame(item, 0, 1000))) Assert.Equal(HttpStatusCode.OK, first.StatusCode); // 1026 字节
using (var query = Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}", Ticket(FileOp.Get, domain.Id)))
using (var info = await _http.SendAsync(query))
{
var body = JsonSerializer.Deserialize(await info.Content.ReadAsStringAsync(), FileProtocolJson.Default.FileDomainBody)!;
Assert.Equal(1026, body.Used);
Assert.Equal(2048, body.Quota);
}
using (var second = await PutAsync(domain.Id, other, 0, Frame(other, 0, 1000))) Assert.Equal(HttpStatusCode.InsufficientStorage, second.StatusCode); // 再存会超出
using (var small = await PutAsync(domain.Id, other, 0, Frame(other, 0, 500))) Assert.Equal(HttpStatusCode.OK, small.StatusCode);
}
[Fact]
public async Task ATicketOnlyWorksForItsOwnDomainAndUnknownDomainsDoNotExist()
{
var a = await CreateAsync(); var b = await CreateAsync();
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var cross = await PutAsync(b.Id, item, 0, Frame(item, 0, 10), Ticket(FileOp.Put, a.Id, item))) Assert.Equal(HttpStatusCode.Unauthorized, cross.StatusCode);
string ghost = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var missing = await PutAsync(ghost, item, 0, Frame(item, 0, 10))) Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode);
using (var read = Request(HttpMethod.Get, $"/file/v1/domains/{ghost}", Ticket(FileOp.Get, ghost)))
using (var notFound = await _http.SendAsync(read)) Assert.Equal(HttpStatusCode.Unauthorized, notFound.StatusCode);
}
[Fact]
public async Task DeletingADomainRemovesItsFilesAndOnlyAnAdminTicketMay()
{
var domain = await CreateAsync();
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
using (var denied = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}", Ticket(FileOp.Put, domain.Id, item)))) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
using (var removed = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}", Ticket(FileOp.Admin, "*")))) Assert.Equal(HttpStatusCode.NoContent, removed.StatusCode);
using (var gone = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.Unauthorized, gone.StatusCode);
using (var read = await _http.SendAsync(Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}/items/{item}/frames", Ticket(FileOp.Get, domain.Id, item)))) Assert.Equal(HttpStatusCode.Unauthorized, read.StatusCode);
}
[Fact]
public async Task ATicketFromAnotherSpaceIsRefusedEvenIfItNamesTheDomain()
{
var domain = await CreateAsync(); // 属于 _space
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
string otherSpace = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var foreign = await PutAsync(domain.Id, item, 0, Frame(item, 0, 10), Ticket(FileOp.Put, domain.Id, item, otherSpace))) Assert.Equal(HttpStatusCode.Unauthorized, foreign.StatusCode);
using (var own = await PutAsync(domain.Id, item, 0, Frame(item, 0, 10))) Assert.Equal(HttpStatusCode.OK, own.StatusCode);
}
[Fact]
public async Task AReadTicketWorksOnlyOnceSoReplayingItCannotBypassTheDownloadCount()
{
var domain = await CreateAsync();
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
string ticket = Ticket(FileOp.Get, domain.Id, item);
using (var first = await _http.SendAsync(Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}/items/{item}/frames", ticket))) Assert.Equal(HttpStatusCode.OK, first.StatusCode);
using (var replay = await _http.SendAsync(Request(HttpMethod.Get, $"/file/v1/domains/{domain.Id}/items/{item}/frames", ticket))) Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode);
// 上传票据一个条目的所有帧共用,不受一次性限制
string put = Ticket(FileOp.Put, domain.Id, item);
using (var f1 = await PutAsync(domain.Id, item, 1, Frame(item, 1, 50), put)) Assert.Equal(HttpStatusCode.OK, f1.StatusCode);
using (var f2 = await PutAsync(domain.Id, item, 2, Frame(item, 2, 50), put)) Assert.Equal(HttpStatusCode.OK, f2.StatusCode);
}
[Fact]
public async Task AnUploadStaysPendingUntilCommittedAndAbandonedOnesAreSwept()
{
var domain = await CreateAsync();
var store = _files.Services.GetRequiredService<FrameStore>();
string kept = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), abandoned = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), old = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
foreach (var item in new[] { kept, abandoned })
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 100))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
// 都还待确认;确认了一个之后,它不再被当成没人要的
Assert.Contains($"{domain.Id}/{kept}", store.StalePending(TimeSpan.Zero));
using (var commit = await _http.SendAsync(Request(HttpMethod.Post, $"/file/v1/domains/{domain.Id}/items/{kept}/commit", Ticket(FileOp.Put, domain.Id, kept)))) Assert.Equal(HttpStatusCode.NoContent, commit.StatusCode);
Assert.DoesNotContain($"{domain.Id}/{kept}", store.StalePending(TimeSpan.Zero));
Assert.Contains($"{domain.Id}/{abandoned}", store.StalePending(TimeSpan.Zero));
// 读取票据不能确认
using (var denied = await _http.SendAsync(Request(HttpMethod.Post, $"/file/v1/domains/{domain.Id}/items/{abandoned}/commit", Ticket(FileOp.Get, domain.Id, abandoned)))) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
// 没确认的过了保留期被清理,确认过的和旧版本留下的(没有标记)不受影响
Directory.CreateDirectory(Path.Combine(_dir, "frames", domain.Id, old));
File.WriteAllBytes(Path.Combine(_dir, "frames", domain.Id, old, "0000000000"), new byte[10]);
var sweeper = _files.Services.GetServices<Microsoft.Extensions.Hosting.IHostedService>().OfType<PendingSweeper>().Single();
foreach (var path in Directory.EnumerateFiles(Path.Combine(_dir, "frames", domain.Id, abandoned))) File.SetLastWriteTimeUtc(path, DateTime.UtcNow.AddDays(-2));
Assert.Equal(1, sweeper.SweepOnce());
Assert.False(Directory.Exists(Path.Combine(_dir, "frames", domain.Id, abandoned)));
Assert.True(Directory.Exists(Path.Combine(_dir, "frames", domain.Id, kept)));
Assert.True(Directory.Exists(Path.Combine(_dir, "frames", domain.Id, old)));
}
[Fact]
public async Task CancellingAnUploadWithTheUploadTicketFreesTheDomainSpace()
{
var domain = await CreateAsync();
var store = _files.Services.GetRequiredService<FrameStore>();
string item = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var stored = await PutAsync(domain.Id, item, 0, Frame(item, 0, 500))) Assert.Equal(HttpStatusCode.OK, stored.StatusCode);
Assert.True(store.UsedBy(domain.Id) > 0);
using (var cancelled = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}/items/{item}", Ticket(FileOp.Put, domain.Id, item)))) Assert.Equal(HttpStatusCode.NoContent, cancelled.StatusCode);
Assert.Equal(0, store.UsedBy(domain.Id));
// 读取票据不能拿来删
using (var again = await PutAsync(domain.Id, item, 0, Frame(item, 0, 500))) Assert.Equal(HttpStatusCode.OK, again.StatusCode);
using (var denied = await _http.SendAsync(Request(HttpMethod.Delete, $"/file/v1/domains/{domain.Id}/items/{item}", Ticket(FileOp.Get, domain.Id, item)))) Assert.Equal(HttpStatusCode.Unauthorized, denied.StatusCode);
}
[Theory]
[InlineData("POST")]
[InlineData("PUT")]
[InlineData("DELETE")]
[InlineData("GET")]
public async Task BrowserPreflightAllowsEveryMethodTheFrontendUses(string method)
{
// 浏览器直连文件服务:创建文件域、确认条目是 POST,取消和删除是 DELETE,不在预检允许的方法里会被浏览器拦下
using var request = new HttpRequestMessage(HttpMethod.Options, "/file/v1/domains");
request.Headers.Add("Origin", "http://localhost:5173");
request.Headers.Add("Access-Control-Request-Method", method);
request.Headers.Add("Access-Control-Request-Headers", "x-wd-ticket,content-type");
using var response = await _http.SendAsync(request);
Assert.Contains(method, response.Headers.GetValues("Access-Control-Allow-Methods").Single().Split(','));
}
[Fact]
public async Task DomainUsageSurvivesARestartAndStaysCorrectAfterADelete()
{
// 重启之后用量从磁盘重新统计:先删一个条目,剩下的条目不能被算两次扣减
var domain = await CreateAsync();
string a = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16)), b = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
using (var one = await PutAsync(domain.Id, a, 0, Frame(a, 0, 1000))) Assert.Equal(HttpStatusCode.OK, one.StatusCode);
using (var two = await PutAsync(domain.Id, b, 0, Frame(b, 0, 300))) Assert.Equal(HttpStatusCode.OK, two.StatusCode);
var restarted = new FrameStore(Path.Combine(_dir, "frames"), 0, Path.Combine(_dir, "sync-restarted"));
Assert.True(restarted.DeletePrefix($"{domain.Id}/{a}"));
Assert.Equal(26 + 300, restarted.UsedBy(domain.Id));
}
}
+38 -27
View File
@@ -11,42 +11,48 @@ using static WebDrop.Api.Tests.ServerFixture;
namespace WebDrop.Api.Tests;
/// <summary>文件服务把新增的帧同步到外部存储(WebDAV):增量、可重试、删除后保留一段时间再清理。</summary>
/// <summary>文件服务把新增的帧同步到外部存储(S3 协议):增量、可重试、删除后保留一段时间再清理。</summary>
public sealed class FileSyncTests : IAsyncLifetime
{
private const string Secret = "sync-test-secret-0123456789abcdef-xyz-0123";
private readonly string _dir = Path.Combine(Path.GetTempPath(), "webdrop-sync-" + Guid.NewGuid().ToString("N"));
private readonly FakeWebDav _dav = new();
private readonly FakeS3 _s3 = new();
private WebApplication _files = null!;
private HttpClient _http = null!;
private string _channel = "", _channelId = "";
private string _channel = "";
public async Task InitializeAsync()
{
await _dav.StartAsync();
await _s3.StartAsync();
_files = FileApiApp.Build([], b =>
{
b.Configuration["FileApi:DataDir"] = _dir;
b.Configuration["FileApi:Secret"] = Secret;
b.Configuration["FileApi:Backup:Enabled"] = "false"; // 定时任务不在测试里跑,测试手动触发
b.Configuration["FileApi:Backup:RetainDays"] = "30";
b.Configuration["FileApi:Backup:WebDav:Url"] = _dav.Url;
b.Configuration["FileApi:Backup:WebDav:Username"] = FakeWebDav.User;
b.Configuration["FileApi:Backup:WebDav:Password"] = FakeWebDav.Password;
b.Configuration["FileApi:Backup:WebDav:BasePath"] = "webdrop";
b.Configuration["FileApi:Backup:S3:Endpoint"] = _s3.Url;
b.Configuration["FileApi:Backup:S3:AccessKey"] = FakeS3.AccessKey;
b.Configuration["FileApi:Backup:S3:SecretKey"] = FakeS3.SecretKey;
b.Configuration["FileApi:Backup:S3:BasePath"] = "webdrop";
b.Configuration["FileApi:Backup:S3:Bucket"] = FakeS3.Bucket;
b.Configuration["urls"] = "http://127.0.0.1:0";
b.Logging.ClearProviders();
});
await _files.StartAsync();
_http = new HttpClient { BaseAddress = new Uri(_files.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
_channelId = NewId();
_channel = Hex(_channelId);
// 帧都存在一个文件域里:先用「创建」票据建一个
string space = Convert.ToHexStringLower(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16));
using var create = new HttpRequestMessage(HttpMethod.Post, "/file/v1/domains") { Content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json") };
create.Headers.Add("X-WD-Ticket", FileTokens.Sign(System.Text.Encoding.UTF8.GetBytes(Secret), new FileTicket(FileOp.Create, space, "*", 0, DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeMilliseconds())));
using var created = await _http.SendAsync(create);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
_channel = System.Text.Json.JsonSerializer.Deserialize(await created.Content.ReadAsStringAsync(), FileProtocolJson.Default.FileDomainBody)!.Id;
}
public async Task DisposeAsync()
{
_http.Dispose();
await _files.StopAsync(); await _files.DisposeAsync(); await _dav.DisposeAsync();
await _files.StopAsync(); await _files.DisposeAsync(); await _s3.DisposeAsync();
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { }
}
@@ -57,10 +63,15 @@ public sealed class FileSyncTests : IAsyncLifetime
private async Task<byte[]> PutAsync(string item, uint index, byte fill)
{
var frame = Frame(item, index, GatedEnc, EncPayload, fill);
using var request = new HttpRequestMessage(HttpMethod.Put, $"/f/{_channel}/{Hex(item)}/{index}") { Content = new ByteArrayContent(frame) };
using var request = new HttpRequestMessage(HttpMethod.Put, $"/file/v1/domains/{_channel}/items/{Hex(item)}/frames/{index}") { Content = new ByteArrayContent(frame) };
request.Headers.Add("X-WD-Ticket", Admin());
using var response = await _http.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// 确认这个条目(已登记到主后端):没确认的待确认条目不同步
using var commit = new HttpRequestMessage(HttpMethod.Post, $"/file/v1/domains/{_channel}/items/{Hex(item)}/commit");
commit.Headers.Add("X-WD-Ticket", Admin());
using var committed = await _http.SendAsync(commit);
Assert.Equal(HttpStatusCode.NoContent, committed.StatusCode);
return frame;
}
@@ -74,24 +85,24 @@ public sealed class FileSyncTests : IAsyncLifetime
var frames = new[] { await PutAsync(a, 0, 1), await PutAsync(a, 1, 2), await PutAsync(a, 2, 3), await PutAsync(b, 0, 4) };
// 外部存储暂时连不上:不会标记已同步,下次接着来
_dav.Down = true;
_s3.Down = true;
var down = await Worker.RunOnceAsync(CancellationToken.None);
Assert.Equal(0, down.Uploaded); Assert.True(down.Failed > 0);
Assert.Empty(_dav.Files);
Assert.Empty(_s3.Files);
_dav.Down = false;
_s3.Down = false;
var first = await Worker.RunOnceAsync(CancellationToken.None);
Assert.Equal(4, first.Uploaded);
Assert.Equal(frames[0], _dav.Files[Remote(a, 0)]);
Assert.Equal(frames[2], _dav.Files[Remote(a, 2)]);
Assert.Equal(frames[3], _dav.Files[Remote(b, 0)]);
Assert.Equal(frames[0], _s3.Files[Remote(a, 0)]);
Assert.Equal(frames[2], _s3.Files[Remote(a, 2)]);
Assert.Equal(frames[3], _s3.Files[Remote(b, 0)]);
// 增量:没有新内容就什么也不传;新来的帧只传新的
Assert.Equal(0, (await Worker.RunOnceAsync(CancellationToken.None)).Uploaded);
var more = await PutAsync(a, 3, 5);
var second = await Worker.RunOnceAsync(CancellationToken.None);
Assert.Equal(1, second.Uploaded);
Assert.Equal(more, _dav.Files[Remote(a, 3)]);
Assert.Equal(more, _s3.Files[Remote(a, 3)]);
}
[Fact]
@@ -100,10 +111,10 @@ public sealed class FileSyncTests : IAsyncLifetime
string a = NewId(), b = NewId();
await PutAsync(a, 0, 1); await PutAsync(b, 0, 2);
await Worker.RunOnceAsync(CancellationToken.None);
Assert.True(_dav.Files.ContainsKey(Remote(a, 0)));
Assert.True(_s3.Files.ContainsKey(Remote(a, 0)));
// 在文件服务上删掉 a:本地没了,外部备份里还在(误删可以找回)
using (var delete = new HttpRequestMessage(HttpMethod.Delete, $"/internal/prefix?p={_channel}/{Hex(a)}"))
using (var delete = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{_channel}/items/{Hex(a)}"))
{
delete.Headers.Add("X-WD-Ticket", Admin());
using var response = await _http.SendAsync(delete);
@@ -111,8 +122,8 @@ public sealed class FileSyncTests : IAsyncLifetime
}
var early = await Worker.RunOnceAsync(CancellationToken.None);
Assert.Equal(0, early.Purged);
Assert.True(_dav.Files.ContainsKey(Remote(a, 0)));
Assert.True(_dav.Files.ContainsKey(Remote(b, 0)));
Assert.True(_s3.Files.ContainsKey(Remote(a, 0)));
Assert.True(_s3.Files.ContainsKey(Remote(b, 0)));
// 删除记录满 30 天后,下一次同步才把外部备份里的也清掉(没删的 b 不受影响)
var log = Path.Combine(_dir, "sync", "deleted.log");
@@ -120,8 +131,8 @@ public sealed class FileSyncTests : IAsyncLifetime
File.WriteAllLines(log, File.ReadAllLines(log).Select(l => l.Split('\t')[0] + "\t" + old));
var late = await Worker.RunOnceAsync(CancellationToken.None);
Assert.Equal(1, late.Purged);
Assert.False(_dav.Files.ContainsKey(Remote(a, 0)));
Assert.True(_dav.Files.ContainsKey(Remote(b, 0)));
Assert.False(_s3.Files.ContainsKey(Remote(a, 0)));
Assert.True(_s3.Files.ContainsKey(Remote(b, 0)));
Assert.Empty(File.ReadAllLines(log));
}
@@ -130,7 +141,7 @@ public sealed class FileSyncTests : IAsyncLifetime
{
string a = NewId();
await PutAsync(a, 0, 1);
using (var delete = new HttpRequestMessage(HttpMethod.Delete, $"/internal/prefix?p={_channel}/{Hex(a)}"))
using (var delete = new HttpRequestMessage(HttpMethod.Delete, $"/file/v1/domains/{_channel}/items/{Hex(a)}"))
{
delete.Headers.Add("X-WD-Ticket", Admin());
using var response = await _http.SendAsync(delete);
@@ -138,7 +149,7 @@ public sealed class FileSyncTests : IAsyncLifetime
}
var run = await Worker.RunOnceAsync(CancellationToken.None);
Assert.Equal(0, run.Uploaded);
Assert.Empty(_dav.Files);
Assert.Empty(_s3.Files);
Assert.False(File.Exists(Path.Combine(_dir, "sync", "deleted.log")));
}
}