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; /// /// 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. /// [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(options => options.MultipartBodyLengthLimit = AdminUtils.MaxUploadBytes); builder.Services.AddDbContext(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(); } }