mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RadicadeServer.Admin;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Utils;
|
||||
using Xunit;
|
||||
|
||||
namespace RadicadeServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Importing a large room file, over real Kestrel and MVC model binding.
|
||||
///
|
||||
/// The room upload route never raised Kestrel's default 30 MB request ceiling, so every
|
||||
/// .room, .htr and .assetbundle above it - the user's own Showdown_0_1.assetbundle is 73 MB -
|
||||
/// had its connection killed part-way through the body. The panel saw a broken connection
|
||||
/// instead of a refusal, which is what made a big import look like an authentication failure,
|
||||
/// and every big room import failed. These pin the ceiling the route is actually held to,
|
||||
/// the part-by-part path the panel now uses for large files, and the rule that a file being
|
||||
/// assembled from parts is invisible until it is whole.
|
||||
/// </summary>
|
||||
[Collection(DbFactoryCollection.Name)]
|
||||
public class AdminRoomPartUploadTests
|
||||
{
|
||||
private const string Secret = "room-part-test-key";
|
||||
private const int PartBytes = 4 * 1024 * 1024;
|
||||
|
||||
private sealed class Storage : IDisposable
|
||||
{
|
||||
private readonly string _previousStorage = DBConfig.CDNStorage;
|
||||
private readonly string _previousKey = DBConfig.AdminApiKey;
|
||||
|
||||
public Storage()
|
||||
{
|
||||
Root = Directory.CreateTempSubdirectory("radicade-room-part-").FullName;
|
||||
DBConfig.CDNStorage = Root;
|
||||
DBConfig.AdminApiKey = Secret;
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
DBConfig.CDNStorage = _previousStorage;
|
||||
DBConfig.AdminApiKey = _previousKey;
|
||||
try { Directory.Delete(Root, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
|
||||
private static WebApplication BuildApp()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseKestrel().UseUrls("http://127.0.0.1:0");
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Services.AddControllers().AddApplicationPart(typeof(AdminPanelAPI).Assembly);
|
||||
builder.Services.AddCors(options => options.AddPolicy("AdminPanel", policy => policy.AllowAnyOrigin()));
|
||||
builder.Services.Configure<FormOptions>(options => options.MultipartBodyLengthLimit = AdminUtils.MaxUploadBytes);
|
||||
builder.Services.AddDbContext<RecDBContext>(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
|
||||
var app = builder.Build();
|
||||
app.UseCors();
|
||||
app.MapControllers();
|
||||
return app;
|
||||
}
|
||||
|
||||
private static byte[] Bytes(long length, int seed = 7)
|
||||
{
|
||||
var bytes = new byte[length];
|
||||
new Random(seed).NextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static async Task<(HttpStatusCode Status, JsonElement Body)> PostPartAsync(
|
||||
HttpClient client, string name, long offset, long total, byte[] chunk)
|
||||
{
|
||||
using var content = new ByteArrayContent(chunk);
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
using var response = await client.PostAsync(
|
||||
$"/admin/panel/upload/part?key={Secret}&name={Uri.EscapeDataString(name)}&offset={offset}&total={total}", content);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
return (response.StatusCode, text.StartsWith('{') ? JsonDocument.Parse(text).RootElement : default);
|
||||
}
|
||||
|
||||
/// A body bigger than the old 30 MB ceiling now arrives intact, byte for byte, under the
|
||||
/// name it was sent as - the failure that started this, over real Kestrel.
|
||||
[Fact]
|
||||
public async Task ARoomFileBiggerThanKestrelsDefaultCeilingStillImports()
|
||||
{
|
||||
using var storage = new Storage();
|
||||
await using var app = BuildApp();
|
||||
await app.StartAsync();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()), Timeout = TimeSpan.FromMinutes(5) };
|
||||
|
||||
var bytes = Bytes(40L * 1024 * 1024, seed: 11);
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new ByteArrayContent(bytes), "file", "Showdown_0_1.assetbundle");
|
||||
using var response = await client.PostAsync(
|
||||
$"/admin/panel/upload/room?key={Secret}&name=Showdown_0_1.assetbundle", form);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("assetbundle", JsonDocument.Parse(body).RootElement.GetProperty("kind").GetString());
|
||||
Assert.Equal(
|
||||
SHA256.HashData(bytes),
|
||||
SHA256.HashData(await File.ReadAllBytesAsync(Path.Join(storage.Root, "Showdown_0_1.assetbundle"))));
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
|
||||
/// A large file sent in parts lands as one file, byte for byte, under its own name.
|
||||
[Fact]
|
||||
public async Task AFileSentInPartsIsAssembledUnderTheNameItWasSentWith()
|
||||
{
|
||||
using var storage = new Storage();
|
||||
await using var app = BuildApp();
|
||||
await app.StartAsync();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) };
|
||||
|
||||
var bytes = Bytes(10L * 1024 * 1024 + 1234, seed: 3);
|
||||
JsonElement last = default;
|
||||
for (long offset = 0; offset < bytes.LongLength; offset += PartBytes)
|
||||
{
|
||||
var chunk = bytes[(int)offset..(int)Math.Min(offset + PartBytes, bytes.LongLength)];
|
||||
var (status, body) = await PostPartAsync(client, "Showdown.room", offset, bytes.LongLength, chunk);
|
||||
Assert.Equal(HttpStatusCode.OK, status);
|
||||
Assert.Equal(offset + chunk.Length, body.GetProperty("received").GetInt64());
|
||||
Assert.Equal(bytes.LongLength, body.GetProperty("total").GetInt64());
|
||||
last = body;
|
||||
}
|
||||
|
||||
Assert.True(last.GetProperty("complete").GetBoolean());
|
||||
Assert.Equal("Showdown.room", last.GetProperty("name").GetString());
|
||||
Assert.Equal("room", last.GetProperty("kind").GetString());
|
||||
Assert.Equal(
|
||||
SHA256.HashData(bytes),
|
||||
SHA256.HashData(await File.ReadAllBytesAsync(Path.Join(storage.Root, "Showdown.room"))));
|
||||
|
||||
// Nothing of the assembly is left lying around once the file is whole.
|
||||
Assert.Empty(Directory.GetFiles(storage.Root, "*" + AdminUtils.PartSuffix));
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
|
||||
/// A part that does not line up with what is stored is refused, and told where to continue,
|
||||
/// so a part retried after a lost answer resumes instead of corrupting the file.
|
||||
[Fact]
|
||||
public async Task APartThatDoesNotLineUpIsRefusedWithWhereToContinue()
|
||||
{
|
||||
using var storage = new Storage();
|
||||
await using var app = BuildApp();
|
||||
await app.StartAsync();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; var bytes = Bytes(10L * 1024 * 1024, seed: 5);
|
||||
var first = bytes[..PartBytes];
|
||||
var (firstStatus, _) = await PostPartAsync(client, "Showdown.room", 0, bytes.LongLength, first); Assert.Equal(HttpStatusCode.OK, firstStatus);
|
||||
// One part is stored, and the file it belongs to is not there yet.
|
||||
Assert.Equal(PartBytes, new FileInfo(Path.Join(storage.Root, "Showdown.room" + AdminUtils.PartSuffix)).Length);
|
||||
Assert.False(File.Exists(Path.Join(storage.Root, "Showdown.room"))); // Jumped ahead: the file holds one part, and the answer says so.
|
||||
var ahead = bytes[(PartBytes * 2)..];
|
||||
var (aheadStatus, aheadBody) = await PostPartAsync(client, "Showdown.room", PartBytes * 2, bytes.LongLength, ahead);
|
||||
Assert.Equal(HttpStatusCode.Conflict, aheadStatus);
|
||||
Assert.Equal("part_out_of_order", aheadBody.GetProperty("error").GetString());
|
||||
Assert.Equal(PartBytes, aheadBody.GetProperty("stored").GetInt64());
|
||||
|
||||
// Re-sent from where the server actually is, it finishes cleanly.
|
||||
var rest = bytes[PartBytes..];
|
||||
var (restStatus, restBody) = await PostPartAsync(client, "Showdown.room", aheadBody.GetProperty("stored").GetInt64(), bytes.LongLength, rest);
|
||||
Assert.Equal(HttpStatusCode.OK, restStatus);
|
||||
Assert.True(restBody.GetProperty("complete").GetBoolean());
|
||||
Assert.Equal(
|
||||
SHA256.HashData(bytes),
|
||||
SHA256.HashData(await File.ReadAllBytesAsync(Path.Join(storage.Root, "Showdown.room"))));
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
|
||||
/// A part of a file being uploaded is not an upload: it is hidden from every listing, and
|
||||
/// only the finished file appears.
|
||||
[Fact]
|
||||
public async Task AFileStillBeingAssembledIsNotListedAsAnUpload()
|
||||
{
|
||||
using var storage = new Storage();
|
||||
await using var app = BuildApp();
|
||||
await app.StartAsync();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) };
|
||||
|
||||
var bytes = Bytes(6L * 1024 * 1024, seed: 9);
|
||||
var (partStatus, _) = await PostPartAsync(client, "Showdown_0_1.assetbundle", 0, bytes.LongLength, bytes[..PartBytes]);
|
||||
Assert.Equal(HttpStatusCode.OK, partStatus);
|
||||
|
||||
using var half = await client.GetAsync($"/admin/panel/uploads?key={Secret}");
|
||||
var halfBody = await half.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("Showdown_0_1.assetbundle", halfBody);
|
||||
Assert.DoesNotContain(AdminUtils.PartSuffix, halfBody);
|
||||
|
||||
var (restStatus, _) = await PostPartAsync(client, "Showdown_0_1.assetbundle", PartBytes, bytes.LongLength, bytes[PartBytes..]);
|
||||
Assert.Equal(HttpStatusCode.OK, restStatus);
|
||||
|
||||
using var whole = await client.GetAsync($"/admin/panel/uploads?key={Secret}");
|
||||
var wholeBody = await whole.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Showdown_0_1.assetbundle", wholeBody);
|
||||
Assert.DoesNotContain(AdminUtils.PartSuffix, wholeBody);
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
|
||||
/// A part cannot be stored under a partial name, and a file beyond the ceiling cannot be
|
||||
/// imported this way at all - both are refused before anything is written.
|
||||
[Fact]
|
||||
public async Task APartialNameAndAnOversizedFileAreRefused()
|
||||
{
|
||||
using var storage = new Storage();
|
||||
await using var app = BuildApp();
|
||||
await app.StartAsync();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) };
|
||||
|
||||
var chunk = Bytes(1024);
|
||||
var (partialName, _) = await PostPartAsync(client, "Showdown.room.uploading", 0, 2048, chunk);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, partialName);
|
||||
|
||||
var (tooBig, _) = await PostPartAsync(client, "Showdown.room", 0, AdminUtils.MaxUploadBytes + 1, chunk);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, tooBig);
|
||||
|
||||
// A part carrying more than the file it belongs to says the import was discarded.
|
||||
var (overrun, _) = await PostPartAsync(client, "Showdown.room", 0, 512, chunk);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, overrun);
|
||||
|
||||
Assert.Empty(Directory.GetFiles(storage.Root));
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
|
||||
/// Starting over at zero begins the file again rather than appending to an abandoned
|
||||
/// attempt, so a retried import cannot end up as two files welded together.
|
||||
[Fact]
|
||||
public async Task StartingAgainAtZeroReplacesAnAbandonedAttempt()
|
||||
{
|
||||
using var storage = new Storage();
|
||||
await using var app = BuildApp();
|
||||
await app.StartAsync();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) }; var bytes = Bytes(3L * 1024 * 1024, seed: 13);
|
||||
// An import that got one part in and then stopped.
|
||||
await PostPartAsync(client, "Showdown.room", 0, bytes.LongLength, Bytes(1024, seed: 99));
|
||||
Assert.Equal(1024, new FileInfo(Path.Join(storage.Root, "Showdown.room" + AdminUtils.PartSuffix)).Length);
|
||||
|
||||
var (status, body) = await PostPartAsync(client, "Showdown.room", 0, bytes.LongLength, bytes);
|
||||
Assert.Equal(HttpStatusCode.OK, status);
|
||||
Assert.True(body.GetProperty("complete").GetBoolean());
|
||||
Assert.Equal(
|
||||
SHA256.HashData(bytes),
|
||||
SHA256.HashData(await File.ReadAllBytesAsync(Path.Join(storage.Root, "Showdown.room"))));
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
|
||||
/// The part route is as guarded as the routes it stands in for: no key, no write.
|
||||
[Fact]
|
||||
public async Task PartsWithoutTheAdminKeyAreRefused()
|
||||
{
|
||||
using var storage = new Storage();
|
||||
await using var app = BuildApp();
|
||||
await app.StartAsync();
|
||||
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.Single()) };
|
||||
|
||||
using var content = new ByteArrayContent(Bytes(1024));
|
||||
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
using var response = await client.PostAsync("/admin/panel/upload/part?key=wrong&name=Showdown.room&offset=0&total=1024", content);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Contains("Invalid secret", await response.Content.ReadAsStringAsync());
|
||||
Assert.Empty(Directory.GetFiles(storage.Root));
|
||||
|
||||
await app.StopAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user