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:
1 parent
d3da3597cc
commit
ccb2c074d6
19 files changed
+827
-278
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user