From b834c37437d31197705323681e7fca0717334167 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Fri, 17 Jul 2026 16:44:23 +0300 Subject: [PATCH 01/19] refactor: sln migration, test project added, test for status endpoints added --- .gitignore | 1 + Directory.Packages.props | 5 + Robust.Cdn.Tests/ControllerTestCollection.cs | 12 + .../Controllers/StatusControllerTests.cs | 59 +++++ Robust.Cdn.Tests/DatabaseFixture.cs | 56 +++++ .../Properties/launchSettings.json | 12 + Robust.Cdn.Tests/Robust.Cdn.Tests.csproj | 26 ++ Robust.Cdn.Tests/TestBase.cs | 57 +++++ Robust.Cdn.sln | 41 --- Robust.Cdn.slnx | 17 ++ Robust.Cdn/Program.cs | 236 ++++++++++-------- 11 files changed, 373 insertions(+), 149 deletions(-) create mode 100644 Robust.Cdn.Tests/ControllerTestCollection.cs create mode 100644 Robust.Cdn.Tests/Controllers/StatusControllerTests.cs create mode 100644 Robust.Cdn.Tests/DatabaseFixture.cs create mode 100644 Robust.Cdn.Tests/Properties/launchSettings.json create mode 100644 Robust.Cdn.Tests/Robust.Cdn.Tests.csproj create mode 100644 Robust.Cdn.Tests/TestBase.cs delete mode 100644 Robust.Cdn.sln create mode 100644 Robust.Cdn.slnx diff --git a/.gitignore b/.gitignore index 03b6e2d..1ecb8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ Robust.Cdn/content.db* Robust.Cdn/manifest.db* *.user testData/ +/.vs/** diff --git a/Directory.Packages.props b/Directory.Packages.props index 6573608..4654292 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,5 +14,10 @@ + + + + + \ No newline at end of file diff --git a/Robust.Cdn.Tests/ControllerTestCollection.cs b/Robust.Cdn.Tests/ControllerTestCollection.cs new file mode 100644 index 0000000..8a6cb0b --- /dev/null +++ b/Robust.Cdn.Tests/ControllerTestCollection.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests; + +/// +/// Defines a test collection that uses DatabaseFixture for cleanup. +/// +[CollectionDefinition("ControllerTests")] +public sealed class ControllerTestCollection + : ICollectionFixture>, ICollectionFixture +{ +} diff --git a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs new file mode 100644 index 0000000..f84524d --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for StatusController. +/// +/// Endpoint: GET /control/status +/// +public sealed class StatusControllerTests(WebApplicationFactory factory, DatabaseFixture database) + : TestBase(factory, database) +{ + protected override Dictionary GetConfigurationOverrides() + { + var baseDir = Database.CreateTestVersionOnDisk("testfork", "1.0.0"); + return new() + { + ["Manifest:FileDiskPath"] = baseDir, + }; + } + + [Fact] + public async Task GetControlStatus_ReturnsOkWithVersionInfo() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync("/control/status"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("OK", content); + Assert.Contains("contentVersions", content); + Assert.Contains("version", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetControlStatus_WithExistingVersion_ReturnsNonZeroCount() + { + var client = Factory.CreateClient(); + + // The ingest job runs asynchronously at startup. Poll until we see some version info uploaded. + const int maxAttempts = 20; + for (var i = 0; i < maxAttempts; i++) + { + var response = await client.GetAsync("/control/status"); + var content = await response.Content.ReadAsStringAsync(); + var deserialized = JsonSerializer.Deserialize>(content); + if (deserialized != null + && int.TryParse(deserialized["contentVersions"].ToString(), out var versionsCount) + && versionsCount > 0) + return; + + await Task.Delay(200); + } + + Assert.Fail("Version was not ingested after startup"); + } +} diff --git a/Robust.Cdn.Tests/DatabaseFixture.cs b/Robust.Cdn.Tests/DatabaseFixture.cs new file mode 100644 index 0000000..d8a82a2 --- /dev/null +++ b/Robust.Cdn.Tests/DatabaseFixture.cs @@ -0,0 +1,56 @@ +using System.IO.Compression; + +namespace Robust.Cdn.Tests; + +/// +/// Collection fixture that tracks all temp database files created during a test collection +/// and cleans them up when the collection is done. +/// +public sealed class DatabaseFixture : IDisposable +{ + private readonly List _tempFiles = new(); + private readonly List _tempDirs = new(); + + public string CreateTempDb() + { + var path = Path.GetTempFileName(); + _tempFiles.Add(path); + return path; + } + + /// + /// Creates a fork directory with a version directory and a client zip for the ingest job to find. + /// Returns the base path that should be used as FileDiskPath. + /// + public string CreateTestVersionOnDisk(string fork, string version, string clientZipName = "test") + { + var baseDir = Path.Combine(Path.GetTempPath(), "RobustCdnTests", Guid.NewGuid().ToString()); + var versionDir = Path.Combine(baseDir, fork, version); + Directory.CreateDirectory(versionDir); + _tempDirs.Add(baseDir); + + var zipPath = Path.Combine(versionDir, $"{clientZipName}.zip"); + using (var zipStream = File.Create(zipPath)) + using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create)) + { + var entry = zip.CreateEntry("test.txt"); + using var writer = new StreamWriter(entry.Open()); + writer.Write("test content"); + } + + return baseDir; + } + + public void Dispose() + { + foreach (var file in _tempFiles) + { + try { File.Delete(file); } catch { } + } + + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { } + } + } +} diff --git a/Robust.Cdn.Tests/Properties/launchSettings.json b/Robust.Cdn.Tests/Properties/launchSettings.json new file mode 100644 index 0000000..1d29fe6 --- /dev/null +++ b/Robust.Cdn.Tests/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Robust.Cdn.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:58678;http://localhost:58679" + } + } +} \ No newline at end of file diff --git a/Robust.Cdn.Tests/Robust.Cdn.Tests.csproj b/Robust.Cdn.Tests/Robust.Cdn.Tests.csproj new file mode 100644 index 0000000..ec8fd3f --- /dev/null +++ b/Robust.Cdn.Tests/Robust.Cdn.Tests.csproj @@ -0,0 +1,26 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs new file mode 100644 index 0000000..4f2bf4a --- /dev/null +++ b/Robust.Cdn.Tests/TestBase.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests; + +/// +/// Base class for integration tests providing a pre-configured WebApplicationFactory +/// and automatic cleanup of temp database files after the test collection finishes. +/// +[Collection("ControllerTests")] +public abstract class TestBase : IClassFixture +{ + protected WebApplicationFactory Factory { get; } + protected DatabaseFixture Database { get; } + + protected TestBase(WebApplicationFactory factory, DatabaseFixture database) + { + Database = database; + + var config = new Dictionary(GetDefaultConfiguration()); + foreach (var (key, value) in GetConfigurationOverrides()) + { + config[key] = value; + } + + Factory = factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(config); + }); + }); + } + /// + /// Provides default configuration for all tests. + /// Override to change defaults globally. + /// + private Dictionary GetDefaultConfiguration() + { + return new() + { + ["Cdn:DatabaseFileName"] = Database.CreateTempDb(), + ["Manifest:DatabaseFileName"] = Database.CreateTempDb(), + ["Manifest:FileDiskPath"] = Path.GetTempPath(), + ["Manifest:Forks:testfork:ClientZipName"] = "test", + ["Manifest:Forks:testfork:BuildsPageLinkText"] = "test", + }; + } + + /// + /// Override to add or replace specific configuration keys for a particular test class. + /// Returned values take precedence over GetDefaultConfiguration(). + /// Please do not use there, as this method is used + /// in c-tor or base class and factory won't be set up yet (config is required for factory). + /// + protected virtual Dictionary GetConfigurationOverrides() => new(); +} + diff --git a/Robust.Cdn.sln b/Robust.Cdn.sln deleted file mode 100644 index 5c42122..0000000 --- a/Robust.Cdn.sln +++ /dev/null @@ -1,41 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Cdn", "Robust.Cdn\Robust.Cdn.csproj", "{4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Files", "Solution Files", "{D1E362E7-E406-42E1-A58D-F3318A12ACB6}" - ProjectSection(SolutionItems) = preProject - .gitignore = .gitignore - .gitattributes = .gitattributes - .editorconfig = .editorconfig - LICENSE.txt = LICENSE.txt - Dockerfile = Dockerfile - .dockerignore = .dockerignore - Directory.Packages.props = Directory.Packages.props - RELEASE-NOTES.md = RELEASE-NOTES.md - README.md = README.md - EndProjectSection -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Cdn.Lib", "Robust.Cdn.Lib\Robust.Cdn.Lib.csproj", "{9B66C804-46C1-4D91-A028-8D12E25827CA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Robust.Cdn.Downloader", "Robust.Cdn.Downloader\Robust.Cdn.Downloader.csproj", "{937F7101-5979-4A47-ABF4-36405B839D4A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4B470D95-7DE8-4ED7-BB1B-0F17C53C7CE2}.Release|Any CPU.Build.0 = Release|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {9B66C804-46C1-4D91-A028-8D12E25827CA}.Release|Any CPU.Build.0 = Release|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {937F7101-5979-4A47-ABF4-36405B839D4A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/Robust.Cdn.slnx b/Robust.Cdn.slnx new file mode 100644 index 0000000..2538b03 --- /dev/null +++ b/Robust.Cdn.slnx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Robust.Cdn/Program.cs b/Robust.Cdn/Program.cs index 53815b5..cdbcd25 100644 --- a/Robust.Cdn/Program.cs +++ b/Robust.Cdn/Program.cs @@ -2,127 +2,43 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; using Quartz; -using Robust.Cdn; using Robust.Cdn.Config; using Robust.Cdn.Controllers; using Robust.Cdn.Helpers; using Robust.Cdn.Jobs; using Robust.Cdn.Services; -var builder = WebApplication.CreateBuilder(args); -builder.Host.UseSystemd(); +namespace Robust.Cdn; -// Add services to the container. - -builder.Services.Configure(builder.Configuration.GetSection(CdnOptions.Position)); -builder.Services.Configure(builder.Configuration.GetSection(ManifestOptions.Position)); - -builder.Services.AddControllersWithViews(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(services => services.GetRequiredService()); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddQuartz(q => +public class Program { - q.AddJob(j => j.WithIdentity(IngestNewCdnContentJob.Key).StoreDurably()); - q.AddJob(j => - { - j.WithIdentity(MakeNewManifestVersionsAvailableJob.Key).StoreDurably(); - }); - q.AddJob(j => j.WithIdentity(NotifyWatchdogUpdateJob.Key).StoreDurably()); - q.AddJob(j => j.WithIdentity(UpdateForkManifestJob.Key).StoreDurably()); - q.ScheduleJob(trigger => trigger.WithSimpleSchedule(schedule => + public static async Task Main(string[] args) { - schedule.RepeatForever().WithIntervalInHours(24); - })); - q.ScheduleJob(t => - t.WithSimpleSchedule(s => s.RepeatForever().WithIntervalInHours(24))); -}); - -builder.Services.AddQuartzHostedService(q => -{ - q.WaitForJobsToComplete = true; -}); - -const string userAgent = "Robust.Cdn"; - -builder.Services.AddHttpClient(ForkPublishController.PublishFetchHttpClient, c => -{ - c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); -}); -builder.Services.AddHttpClient(NotifyWatchdogUpdateJob.HttpClientName, c => -{ - c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); -}); + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSystemd(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddHttpContextAccessor(); +// Add services to the container. -/* -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); -*/ + SetupDependencies(builder.Services, builder.Configuration); -var app = builder.Build(); + var app = builder.Build(); -var pathBase = app.Configuration.GetValue("PathBase"); -if (!string.IsNullOrEmpty(pathBase)) -{ - app.Services.GetRequiredService>().LogInformation("Using PathBase: {PathBase}", pathBase); - app.UsePathBase(pathBase); -} + var pathBase = app.Configuration.GetValue("PathBase"); + if (!string.IsNullOrEmpty(pathBase)) + { + app.Services.GetRequiredService>().LogInformation("Using PathBase: {PathBase}", pathBase); + app.UsePathBase(pathBase); + } -app.UseRouting(); + app.UseRouting(); // Make sure SQLite cleanly shuts down. -app.Lifetime.ApplicationStopped.Register(SqliteConnection.ClearAllPools); - -{ - using var initScope = app.Services.CreateScope(); - var services = initScope.ServiceProvider; - var logFactory = services.GetRequiredService(); - var loggerStartup = logFactory.CreateLogger("Robust.Cdn.Program"); - var manifestOptions = services.GetRequiredService>().Value; - var db = services.GetRequiredService(); - var manifestDb = services.GetRequiredService(); - - if (string.IsNullOrEmpty(manifestOptions.FileDiskPath)) - { - loggerStartup.LogCritical("Manifest.FileDiskPath not set in configuration!"); - return 1; - } + app.Lifetime.ApplicationStopped.Register(SqliteConnection.ClearAllPools); - if (manifestOptions.Forks.Count == 0) - { - loggerStartup.LogCritical("No forks defined in Manifest configuration!"); - return 1; - } - - loggerStartup.LogDebug("Running migrations!"); - var loggerMigrator = logFactory.CreateLogger(); - - var success = Migrator.Migrate(services, loggerMigrator, db.Connection, "Robust.Cdn.Migrations"); - success &= Migrator.Migrate(services, loggerMigrator, manifestDb.Connection, "Robust.Cdn.ManifestMigrations"); - if (!success) - return 1; - - loggerStartup.LogDebug("Done running migrations!"); - - loggerStartup.LogDebug("Ensuring forks created in manifest DB"); - manifestDb.EnsureForksCreated(); - loggerStartup.LogDebug("Done creating forks in manifest DB!"); - - var scheduler = await initScope.ServiceProvider.GetRequiredService().GetScheduler(); - foreach (var fork in manifestOptions.Forks.Keys) - { - await scheduler.TriggerJob(IngestNewCdnContentJob.Key, IngestNewCdnContentJob.Data(fork)); - } -} + { + if (!await TryInit(app.Services)) + return 1; + } /* // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -134,10 +50,114 @@ // app.UseHttpsRedirection(); -app.UseAuthorization(); + app.UseAuthorization(); + + app.MapControllers(); -app.MapControllers(); + await app.RunAsync(); -await app.RunAsync(); + return 0; + } -return 0; + private static async Task TryInit(IServiceProvider sp) + { + using var initScope = sp.CreateScope(); + var services = initScope.ServiceProvider; + var logFactory = services.GetRequiredService(); + var loggerStartup = logFactory.CreateLogger("Robust.Cdn.Program"); + var manifestOptions = services.GetRequiredService>().Value; + var db = services.GetRequiredService(); + var manifestDb = services.GetRequiredService(); + + if (string.IsNullOrEmpty(manifestOptions.FileDiskPath)) + { + loggerStartup.LogCritical("Manifest.FileDiskPath not set in configuration!"); + return false; + } + + if (manifestOptions.Forks.Count == 0) + { + loggerStartup.LogCritical("No forks defined in Manifest configuration!"); + return false; + } + + loggerStartup.LogDebug("Running migrations!"); + var loggerMigrator = logFactory.CreateLogger(); + + var success = Migrator.Migrate(services, loggerMigrator, db.Connection, "Robust.Cdn.Migrations"); + success &= Migrator.Migrate(services, loggerMigrator, manifestDb.Connection, "Robust.Cdn.ManifestMigrations"); + if (!success) + return false; + + loggerStartup.LogDebug("Done running migrations!"); + + loggerStartup.LogDebug("Ensuring forks created in manifest DB"); + manifestDb.EnsureForksCreated(); + loggerStartup.LogDebug("Done creating forks in manifest DB!"); + + var scheduler = await initScope.ServiceProvider.GetRequiredService().GetScheduler(); + foreach (var fork in manifestOptions.Forks.Keys) + { + await scheduler.TriggerJob(IngestNewCdnContentJob.Key, IngestNewCdnContentJob.Data(fork)); + } + + return true; + } + + private static void SetupDependencies(IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(CdnOptions.Position)); + services.Configure(configuration.GetSection(ManifestOptions.Position)); + + services.AddControllersWithViews(); + services.AddScoped(); + services.AddSingleton(); + services.AddHostedService(s => s.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(TimeProvider.System); + services.AddQuartz(q => + { + q.AddJob(j => j.WithIdentity(IngestNewCdnContentJob.Key).StoreDurably()); + q.AddJob(j => + { + j.WithIdentity(MakeNewManifestVersionsAvailableJob.Key).StoreDurably(); + }); + q.AddJob(j => j.WithIdentity(NotifyWatchdogUpdateJob.Key).StoreDurably()); + q.AddJob(j => j.WithIdentity(UpdateForkManifestJob.Key).StoreDurably()); + q.ScheduleJob(trigger => trigger.WithSimpleSchedule(schedule => + { + schedule.RepeatForever().WithIntervalInHours(24); + })); + q.ScheduleJob(t => + t.WithSimpleSchedule(s => s.RepeatForever().WithIntervalInHours(24))); + }); + + services.AddQuartzHostedService(q => + { + q.WaitForJobsToComplete = true; + }); + + const string userAgent = "Robust.Cdn"; + + services.AddHttpClient(ForkPublishController.PublishFetchHttpClient, c => + { + c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); + }); + services.AddHttpClient(NotifyWatchdogUpdateJob.HttpClientName, c => + { + c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddHttpContextAccessor(); + + /* + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(); + */ + } +} From a7c4fcd7d1af610bef1aeb78958f688195cb836c Mon Sep 17 00:00:00 2001 From: Fildrance Date: Fri, 17 Jul 2026 17:19:03 +0300 Subject: [PATCH 02/19] refactor: revert change for Program.cs from top-level-statement --- .../Controllers/StatusControllerTests.cs | 1 + Robust.Cdn/Program.cs | 214 +++++++++--------- 2 files changed, 106 insertions(+), 109 deletions(-) diff --git a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs index f84524d..3dfaa68 100644 --- a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs @@ -9,6 +9,7 @@ namespace Robust.Cdn.Tests.Controllers; /// /// Endpoint: GET /control/status /// +[Trait("Category", "Integration")] public sealed class StatusControllerTests(WebApplicationFactory factory, DatabaseFixture database) : TestBase(factory, database) { diff --git a/Robust.Cdn/Program.cs b/Robust.Cdn/Program.cs index cdbcd25..1b03d8a 100644 --- a/Robust.Cdn/Program.cs +++ b/Robust.Cdn/Program.cs @@ -2,162 +2,158 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; using Quartz; +using Robust.Cdn; using Robust.Cdn.Config; using Robust.Cdn.Controllers; using Robust.Cdn.Helpers; using Robust.Cdn.Jobs; using Robust.Cdn.Services; -namespace Robust.Cdn; - -public class Program -{ - public static async Task Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); - builder.Host.UseSystemd(); +var builder = WebApplication.CreateBuilder(args); +builder.Host.UseSystemd(); // Add services to the container. - SetupDependencies(builder.Services, builder.Configuration); +SetupDependencies(builder.Services, builder.Configuration); - var app = builder.Build(); +var app = builder.Build(); - var pathBase = app.Configuration.GetValue("PathBase"); - if (!string.IsNullOrEmpty(pathBase)) - { - app.Services.GetRequiredService>().LogInformation("Using PathBase: {PathBase}", pathBase); - app.UsePathBase(pathBase); - } +var pathBase = app.Configuration.GetValue("PathBase"); +if (!string.IsNullOrEmpty(pathBase)) +{ + app.Services.GetRequiredService>().LogInformation("Using PathBase: {PathBase}", pathBase); + app.UsePathBase(pathBase); +} - app.UseRouting(); +app.UseRouting(); // Make sure SQLite cleanly shuts down. - app.Lifetime.ApplicationStopped.Register(SqliteConnection.ClearAllPools); +app.Lifetime.ApplicationStopped.Register(SqliteConnection.ClearAllPools); - { - if (!await TryInit(app.Services)) - return 1; - } +{ + if (!await TryInit(app.Services)) + return 1; +} /* // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(); +app.UseSwagger(); +app.UseSwaggerUI(); } */ // app.UseHttpsRedirection(); - app.UseAuthorization(); +app.UseAuthorization(); - app.MapControllers(); +app.MapControllers(); - await app.RunAsync(); +await app.RunAsync(); - return 0; - } +return 0; - private static async Task TryInit(IServiceProvider sp) +static async Task TryInit(IServiceProvider sp) +{ + using var initScope = sp.CreateScope(); + var services = initScope.ServiceProvider; + var logFactory = services.GetRequiredService(); + var loggerStartup = logFactory.CreateLogger("Robust.Cdn.Program"); + var manifestOptions = services.GetRequiredService>().Value; + var db = services.GetRequiredService(); + var manifestDb = services.GetRequiredService(); + + if (string.IsNullOrEmpty(manifestOptions.FileDiskPath)) { - using var initScope = sp.CreateScope(); - var services = initScope.ServiceProvider; - var logFactory = services.GetRequiredService(); - var loggerStartup = logFactory.CreateLogger("Robust.Cdn.Program"); - var manifestOptions = services.GetRequiredService>().Value; - var db = services.GetRequiredService(); - var manifestDb = services.GetRequiredService(); - - if (string.IsNullOrEmpty(manifestOptions.FileDiskPath)) - { - loggerStartup.LogCritical("Manifest.FileDiskPath not set in configuration!"); - return false; - } - - if (manifestOptions.Forks.Count == 0) - { - loggerStartup.LogCritical("No forks defined in Manifest configuration!"); - return false; - } + loggerStartup.LogCritical("Manifest.FileDiskPath not set in configuration!"); + return false; + } - loggerStartup.LogDebug("Running migrations!"); - var loggerMigrator = logFactory.CreateLogger(); + if (manifestOptions.Forks.Count == 0) + { + loggerStartup.LogCritical("No forks defined in Manifest configuration!"); + return false; + } - var success = Migrator.Migrate(services, loggerMigrator, db.Connection, "Robust.Cdn.Migrations"); - success &= Migrator.Migrate(services, loggerMigrator, manifestDb.Connection, "Robust.Cdn.ManifestMigrations"); - if (!success) - return false; + loggerStartup.LogDebug("Running migrations!"); + var loggerMigrator = logFactory.CreateLogger(); - loggerStartup.LogDebug("Done running migrations!"); + var success = Migrator.Migrate(services, loggerMigrator, db.Connection, "Robust.Cdn.Migrations"); + success &= Migrator.Migrate(services, loggerMigrator, manifestDb.Connection, "Robust.Cdn.ManifestMigrations"); + if (!success) + return false; - loggerStartup.LogDebug("Ensuring forks created in manifest DB"); - manifestDb.EnsureForksCreated(); - loggerStartup.LogDebug("Done creating forks in manifest DB!"); + loggerStartup.LogDebug("Done running migrations!"); - var scheduler = await initScope.ServiceProvider.GetRequiredService().GetScheduler(); - foreach (var fork in manifestOptions.Forks.Keys) - { - await scheduler.TriggerJob(IngestNewCdnContentJob.Key, IngestNewCdnContentJob.Data(fork)); - } + loggerStartup.LogDebug("Ensuring forks created in manifest DB"); + manifestDb.EnsureForksCreated(); + loggerStartup.LogDebug("Done creating forks in manifest DB!"); - return true; + var scheduler = await initScope.ServiceProvider.GetRequiredService().GetScheduler(); + foreach (var fork in manifestOptions.Forks.Keys) + { + await scheduler.TriggerJob(IngestNewCdnContentJob.Key, IngestNewCdnContentJob.Data(fork)); } - private static void SetupDependencies(IServiceCollection services, IConfiguration configuration) + return true; +} + +static void SetupDependencies(IServiceCollection services, IConfiguration configuration) +{ + services.Configure(configuration.GetSection(CdnOptions.Position)); + services.Configure(configuration.GetSection(ManifestOptions.Position)); + + services.AddControllersWithViews(); + services.AddScoped(); + services.AddSingleton(); + services.AddHostedService(s => s.GetRequiredService()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(TimeProvider.System); + services.AddQuartz(q => { - services.Configure(configuration.GetSection(CdnOptions.Position)); - services.Configure(configuration.GetSection(ManifestOptions.Position)); - - services.AddControllersWithViews(); - services.AddScoped(); - services.AddSingleton(); - services.AddHostedService(s => s.GetRequiredService()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddSingleton(TimeProvider.System); - services.AddQuartz(q => + q.AddJob(j => j.WithIdentity(IngestNewCdnContentJob.Key).StoreDurably()); + q.AddJob(j => { - q.AddJob(j => j.WithIdentity(IngestNewCdnContentJob.Key).StoreDurably()); - q.AddJob(j => - { - j.WithIdentity(MakeNewManifestVersionsAvailableJob.Key).StoreDurably(); - }); - q.AddJob(j => j.WithIdentity(NotifyWatchdogUpdateJob.Key).StoreDurably()); - q.AddJob(j => j.WithIdentity(UpdateForkManifestJob.Key).StoreDurably()); - q.ScheduleJob(trigger => trigger.WithSimpleSchedule(schedule => - { - schedule.RepeatForever().WithIntervalInHours(24); - })); - q.ScheduleJob(t => - t.WithSimpleSchedule(s => s.RepeatForever().WithIntervalInHours(24))); + j.WithIdentity(MakeNewManifestVersionsAvailableJob.Key).StoreDurably(); }); - - services.AddQuartzHostedService(q => + q.AddJob(j => j.WithIdentity(NotifyWatchdogUpdateJob.Key).StoreDurably()); + q.AddJob(j => j.WithIdentity(UpdateForkManifestJob.Key).StoreDurably()); + q.ScheduleJob(trigger => trigger.WithSimpleSchedule(schedule => { - q.WaitForJobsToComplete = true; - }); + schedule.RepeatForever().WithIntervalInHours(24); + })); + q.ScheduleJob(t => + t.WithSimpleSchedule(s => s.RepeatForever().WithIntervalInHours(24))); + }); - const string userAgent = "Robust.Cdn"; + services.AddQuartzHostedService(q => + { + q.WaitForJobsToComplete = true; + }); - services.AddHttpClient(ForkPublishController.PublishFetchHttpClient, c => - { - c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); - }); - services.AddHttpClient(NotifyWatchdogUpdateJob.HttpClientName, c => - { - c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); - }); + const string userAgent = "Robust.Cdn"; + + services.AddHttpClient(ForkPublishController.PublishFetchHttpClient, c => + { + c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); + }); + services.AddHttpClient(NotifyWatchdogUpdateJob.HttpClientName, c => + { + c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(userAgent, null)); + }); - services.AddScoped(); - services.AddScoped(); - services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(); + services.AddHttpContextAccessor(); - /* + /* // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); */ - } } + +// required for visibility of entry-point to test framework +public partial class Program { } From 3112be7219743a4e149e9251d9e66994b95924a9 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Sat, 18 Jul 2026 13:34:25 +0300 Subject: [PATCH 03/19] refactor: method for polling data from server, support for different fork name per test --- .../Controllers/StatusControllerTests.cs | 29 +++++++------- Robust.Cdn.Tests/TestBase.cs | 38 ++++++++++++++++++- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs index 3dfaa68..cedabaa 100644 --- a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs @@ -13,9 +13,11 @@ namespace Robust.Cdn.Tests.Controllers; public sealed class StatusControllerTests(WebApplicationFactory factory, DatabaseFixture database) : TestBase(factory, database) { + protected override string ForkName => "testfork"; + protected override Dictionary GetConfigurationOverrides() { - var baseDir = Database.CreateTestVersionOnDisk("testfork", "1.0.0"); + var baseDir = Database.CreateTestVersionOnDisk(ForkName, "1.0.0"); return new() { ["Manifest:FileDiskPath"] = baseDir, @@ -40,21 +42,16 @@ public async Task GetControlStatus_WithExistingVersion_ReturnsNonZeroCount() { var client = Factory.CreateClient(); - // The ingest job runs asynchronously at startup. Poll until we see some version info uploaded. - const int maxAttempts = 20; - for (var i = 0; i < maxAttempts; i++) + // The ingest job runs asynchronously at startup. Poll until we see version info. + await PollUntilCondition(() => client.GetAsync("/control/status"), async response => { - var response = await client.GetAsync("/control/status"); - var content = await response.Content.ReadAsStringAsync(); - var deserialized = JsonSerializer.Deserialize>(content); - if (deserialized != null - && int.TryParse(deserialized["contentVersions"].ToString(), out var versionsCount) - && versionsCount > 0) - return; - - await Task.Delay(200); - } - - Assert.Fail("Version was not ingested after startup"); + var body = await response.Content.ReadAsStringAsync(); + var deserialized = JsonSerializer.Deserialize>(body); + return deserialized != null + && deserialized.TryGetValue("contentVersions", out var raw) + && int.TryParse(raw?.ToString(), out var count) + && count > 0; + }); } } + diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs index 4f2bf4a..cbca5f5 100644 --- a/Robust.Cdn.Tests/TestBase.cs +++ b/Robust.Cdn.Tests/TestBase.cs @@ -1,3 +1,4 @@ +using System.Net; using Microsoft.AspNetCore.Mvc.Testing; namespace Robust.Cdn.Tests; @@ -12,6 +13,8 @@ public abstract class TestBase : IClassFixture protected WebApplicationFactory Factory { get; } protected DatabaseFixture Database { get; } + protected abstract string ForkName { get; } + protected TestBase(WebApplicationFactory factory, DatabaseFixture database) { Database = database; @@ -30,6 +33,7 @@ protected TestBase(WebApplicationFactory factory, DatabaseFixture datab }); }); } + /// /// Provides default configuration for all tests. /// Override to change defaults globally. @@ -41,8 +45,8 @@ protected TestBase(WebApplicationFactory factory, DatabaseFixture datab ["Cdn:DatabaseFileName"] = Database.CreateTempDb(), ["Manifest:DatabaseFileName"] = Database.CreateTempDb(), ["Manifest:FileDiskPath"] = Path.GetTempPath(), - ["Manifest:Forks:testfork:ClientZipName"] = "test", - ["Manifest:Forks:testfork:BuildsPageLinkText"] = "test", + [$"Manifest:Forks:{ForkName}:ClientZipName"] = "test", + [$"Manifest:Forks:{ForkName}:BuildsPageLinkText"] = "test", }; } @@ -53,5 +57,35 @@ protected TestBase(WebApplicationFactory factory, DatabaseFixture datab /// in c-tor or base class and factory won't be set up yet (config is required for factory). /// protected virtual Dictionary GetConfigurationOverrides() => new(); + + /// + /// Polls a request until it returns OK or the timeout is reached. + /// Useful for waiting on async jobs triggered during startup (e.g. version ingestion). + /// + protected static async Task PollUntilCondition( + Func> factory, + Func> condition, + int maxAttempts = 20, + int delayMs = 200) + { + for (var i = 0; i < maxAttempts; i++) + { + var response = await factory(); + if (await condition(response)) + return response; + + await Task.Delay(delayMs); + } + + throw new TimeoutException($"Request did not return OK after {maxAttempts * delayMs}ms"); + } + + /// + /// Polls a GET request until it returns OK. + /// + protected static Task PollUntilOk(Func> factory, int maxAttempts = 20, int delayMs = 200) + { + return PollUntilCondition(factory, response => Task.FromResult(response.StatusCode == HttpStatusCode.OK), maxAttempts, delayMs); + } } From b65fc3bddd6e763d7ba5239c5f95bffaeb71dee4 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Sat, 18 Jul 2026 21:18:55 +0300 Subject: [PATCH 04/19] refactor: ForkDownloadController tests --- .../ForkDownloadControllerTests.cs | 210 ++++++++++++++++++ .../Controllers/ForkDownloadController.cs | 6 +- 2 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs new file mode 100644 index 0000000..3bbcd02 --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs @@ -0,0 +1,210 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Testing; +using SharpZstd; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for DownloadController. +/// +/// Endpoints: +/// GET /fork/{fork}/version/{version}/manifest +/// OPTIONS /fork/{fork}/version/{version}/download +/// POST /fork/{fork}/version/{version}/download +/// +[Trait("Category", "IntegrationTest")] +public sealed class ForkDownloadControllerTests(WebApplicationFactory factory, DatabaseFixture database) + : TestBase(factory, database) +{ + protected override string ForkName => "testfork2"; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(ForkName, "1.0.0"), + }; + } + + [Theory] + [InlineData("nonexistent", "1.0.0")] + [InlineData("nonexistent", "0.0.0")] + [InlineData("testfork2", "0.0.0")] + public async Task GetManifest_NonExistentForkOrVersion_ReturnsNotFound(string fork, string version) + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{fork}/version/{version}/manifest"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetManifest_ExistingVersion_ReturnsOkWithManifestHash() + { + var client = Factory.CreateClient(); + var response = await PollUntilOk(() => client.GetAsync($"/fork/{ForkName}/version/1.0.0/manifest")); + + Assert.True(response.Headers.Contains("X-Manifest-Hash")); + Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType?.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.Equal(2, lines.Length); + Assert.StartsWith("Robust Content Manifest", lines[0]); + Assert.Equal("8D3BD9BFD005F4179699B9212273284C6DE851AE910EEE2FD1BB0D96EDE65A3D test.txt", lines[1]); + } + + [Fact] + public async Task GetManifest_WithZstdEncoding_ReturnsCompressedManifest() + { + var client = Factory.CreateClient(); + var response = await PollUntilOk(() => + { + var req = new HttpRequestMessage(HttpMethod.Get, $"/fork/{ForkName}/version/1.0.0/manifest"); + req.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); + return client.SendAsync(req); + }); + + Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); + Assert.True(response.Headers.Contains("X-Manifest-Hash")); + + // Decompress the zstd body and verify manifest contents + await using var bodyStream = await response.Content.ReadAsStreamAsync(); + await using var decompressStream = new ZstdDecodeStream(bodyStream, leaveOpen: false); + using var reader = new StreamReader(decompressStream); + var body = await reader.ReadToEndAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.Equal(2, lines.Length); + Assert.StartsWith("Robust Content Manifest", lines[0]); + Assert.Equal("8D3BD9BFD005F4179699B9212273284C6DE851AE910EEE2FD1BB0D96EDE65A3D test.txt", lines[1]); + } + + [Fact] + public async Task DownloadOptions_ReturnsNoContentWithProtocolHeaders() + { + var client = Factory.CreateClient(); + var response = await client.SendAsync(new(HttpMethod.Options, $"/fork/{ForkName}/version/1.0.0/download")); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var minProtocol = int.Parse(response.Headers.GetValues("X-Robust-Download-Min-Protocol").Single()); + var maxProtocol = int.Parse(response.Headers.GetValues("X-Robust-Download-Max-Protocol").Single()); + + Assert.True(minProtocol <= maxProtocol, + $"Min protocol ({minProtocol}) must be less than or equal to max protocol ({maxProtocol})"); + } + + [Fact] + public async Task DownloadPost_WrongContentType_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + var content = new StringContent("test"); + var response = await client.PostAsync($"/fork/{ForkName}/version/1.0.0/download", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData("0")] + [InlineData("99")] + [InlineData("abc")] + [InlineData("")] + public async Task DownloadPost_InvalidProtocol_ReturnsBadRequest(string protocol) + { + var client = Factory.CreateClient(); + var content = new ByteArrayContent(new byte[4]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", protocol); + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DownloadPost_ValidRequest_ReturnsOkWithStreamContent() + { + var client = Factory.CreateClient(); + + // Build request body: a single 4-byte LE int with value 0 (index of test.txt in manifest) + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(request); + }); + + // Read the response body and parse the download stream format + var responseBody = await response.Content.ReadAsByteArrayAsync(); + Assert.True(responseBody.Length >= 4, "Response must contain at least the stream header"); + + // Remaining: per-file headers + data + Assert.True(responseBody.Length > 4, "Response must contain file data after stream header"); + + // Next 4 bytes: file header (uncompressed size) + Assert.True(responseBody.Length >= 8, "Response must contain file header"); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(4, 4)); + Assert.True(fileSize > 0, "File size must be positive"); + + // Rest of body: file data + var fileData = await response.Content.ReadAsStringAsync(); + Assert.Contains("test content", fileData); + } + + [Fact] + public async Task DownloadPost_WithZstdStreamCompression_ReturnsCompressedStream() + { + var client = Factory.CreateClient(); + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + + // Since the default AutoStreamCompressRatio is 0.5 and we request 1 of 1 file, + // the ratio 1.0 > 0.5 triggers stream compression automatically. + // We need Accept-Encoding: zstd for the server to actually use it. + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") + { + Content = new ByteArrayContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } + } + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); + return client.SendAsync(request); + }); + + // Verify the response is zstd-compressed at the stream level + Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); + + // Decompress the entire response body + await using var compressedStream = await response.Content.ReadAsStreamAsync(); + await using var decompressStream = new ZstdDecodeStream(compressedStream, leaveOpen: false); + using var memStream = new MemoryStream(); + await decompressStream.CopyToAsync(memStream); + var decompressedBody = memStream.ToArray(); + + // Parse the decompressed download stream format + Assert.True(decompressedBody.Length >= 8, "Decompressed stream must contain header + file header"); + + // Next 4 bytes: file header (uncompressed size) + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(4, 4)); + Assert.True(fileSize > 0, "File size must be positive"); + + // Rest: file data + var fileData = decompressedBody[8..]; + var fileContent = System.Text.Encoding.UTF8.GetString(fileData); + Assert.Equal("test content", fileContent); + } +} + diff --git a/Robust.Cdn/Controllers/ForkDownloadController.cs b/Robust.Cdn/Controllers/ForkDownloadController.cs index 9001003..08c2eb2 100644 --- a/Robust.Cdn/Controllers/ForkDownloadController.cs +++ b/Robust.Cdn/Controllers/ForkDownloadController.cs @@ -1,4 +1,4 @@ -using System.Buffers.Binary; +using System.Buffers.Binary; using System.Collections; using System.Diagnostics; using Dapper; @@ -94,7 +94,9 @@ public async Task Download(string fork, string version) var protocol = 1; // TODO: this request limiting logic is pretty bad. - HttpContext.Features.Get()!.MaxRequestBodySize = MaxDownloadRequestSize; + var maxRequestBodySize = HttpContext.Features.Get(); + if (maxRequestBodySize != null) + maxRequestBodySize.MaxRequestBodySize = MaxDownloadRequestSize; var con = db.Connection; con.BeginTransaction(deferred: true); From 7022f69461e9b3326c1204532a30813e60a602b4 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 00:12:40 +0300 Subject: [PATCH 05/19] refactor: tests for multi-file ForkDownloadController download call --- .../ForkDownloadControllerMultiFileTests.cs | 318 ++++++++++++++++++ .../ForkDownloadControllerTests.cs | 133 +++++++- Robust.Cdn.Tests/DatabaseFixture.cs | 46 ++- Robust.Cdn.Tests/TestBase.cs | 14 + 4 files changed, 493 insertions(+), 18 deletions(-) create mode 100644 Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs new file mode 100644 index 0000000..3773b8c --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs @@ -0,0 +1,318 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Testing; +using SharpZstd; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for DownloadController with multiple files in the version. +/// +/// Endpoint: POST /fork/{fork}/version/{version}/download +/// +[Trait("Category", "IntegrationTest")] +public sealed class ForkDownloadControllerMultiFileTests(WebApplicationFactory factory, DatabaseFixture database) + : TestBase(factory, database) +{ + private const string MultiFileFork = "testfork-multi"; + private const string MultiFileVersion = "1.0.0"; + private readonly Func _contentTextFactory = fn => fn + " test content!"; + + private static readonly string[] FileNames = ["alpha.txt", "beta.txt", "gamma.txt"]; + + protected override string ForkName => MultiFileFork; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + ["Manifest:FileDiskPath"] = Database.CreateTestVersionWithMultipleFilesOnDisk( + MultiFileFork, + MultiFileVersion, + contentFactory: _contentTextFactory, + fileNames: FileNames + ), + }; + } + + [Fact] + public async Task DownloadPost_ValidRequestWithMultipleFiles_ReturnsAllFiles() + { + var client = Factory.CreateClient(); + + // Request all 3 files + var body = BuildDownloadRequestBody(0, 1, 2); + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(request); + }); + + var responseBody = await response.Content.ReadAsByteArrayAsync(); + + Assert.True(responseBody.Length >= 4, "Response must contain stream header"); + var streamFlags = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4)); + Assert.Equal(0, streamFlags); // No flags (no pre-compression, no stream compression) + + var offset = 4; + + for (var i = 0; i < FileNames.Length; i++) + { + // File header: 4 bytes for uncompressed size + Assert.True(responseBody.Length >= offset + 4, $"Response too short for file {i} header"); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(offset, 4)); + offset += 4; + + var expected = _contentTextFactory(FileNames[i]); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(expected); + Assert.Equal(expectedSize, fileSize); + + // File data + Assert.True(responseBody.Length >= offset + fileSize, $"Response too short for file {i} data"); + var fileContent = System.Text.Encoding.UTF8.GetString(responseBody, offset, fileSize); + Assert.Equal(expected, fileContent); + offset += fileSize; + } + } + + [Fact] + public async Task DownloadPost_MultipleFilesWithPreCompression_ReturnsPreCompressedStream() + { + // Request 2 out of 3 files. With AutoStreamCompressRatio = 2.0, + // ratio 2/3 = 0.67 <= 2.0 -> else branch (pre-compression). + var factory = Factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + ["Cdn:AutoStreamCompressRatio"] = "2.0", + }); + }); + }); + + var client = factory.CreateClient(); + var body = BuildDownloadRequestBody(0, 2); // First and third files + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(request); + }); + + var responseBody = await response.Content.ReadAsByteArrayAsync(); + + // Stream header: PreCompressed flag (1) + Assert.True(responseBody.Length >= 4); + var streamFlags = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4)); + Assert.Equal(1, streamFlags); // PreCompressed = 1 + + var offset = 4; + var expectedFileNames = new[] { "alpha.txt", "gamma.txt" }; + + for (var i = 0; i < expectedFileNames.Length; i++) + { + // File header: 8 bytes + Assert.True(responseBody.Length >= offset + 8, $"Response too short for file {i} header"); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(offset, 4)); + var compInfo = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(offset + 4, 4)); + offset += 8; + + var expected = _contentTextFactory(expectedFileNames[i]); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(expected); + Assert.Equal(expectedSize, fileSize); + Assert.Equal(0, compInfo); // Not ZStd compressed in DB + + // File data + Assert.True(responseBody.Length >= offset + fileSize, $"Response too short for file {i} data"); + var fileContent = System.Text.Encoding.UTF8.GetString(responseBody, offset, fileSize); + Assert.Equal(expected, fileContent); + offset += fileSize; + } + } + + [Fact] + public async Task DownloadPost_MultipleFilesWithStreamCompression_ReturnsCompressedStream() + { + var client = Factory.CreateClient(); + var body = BuildDownloadRequestBody(0, 1, 2); // All 3 files + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); + return client.SendAsync(request); + }); + + // Verify stream-level compression + Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); + + // Decompress + var decompressedBody = await DecompressBody(response); + + // Stream header (4 bytes) — no pre-compression flag + Assert.True(decompressedBody.Length >= 4); + var streamFlags = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(0, 4)); + Assert.Equal(0, streamFlags); // No pre-compression since stream compression chosen + + // Parse each file (file header 4 bytes + file data) + var offset = 4; + var expectedFileNames = new[] { "alpha.txt", "beta.txt", "gamma.txt" }; + + for (var i = 0; i < expectedFileNames.Length; i++) + { + Assert.True(decompressedBody.Length >= offset + 4, $"Response too short for file {i} header"); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(offset, 4)); + offset += 4; + + var expected = _contentTextFactory(expectedFileNames[i]); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(expected); + Assert.Equal(expectedSize, fileSize); + + Assert.True(decompressedBody.Length >= offset + fileSize, $"Response too short for file {i} data"); + var fileContent = System.Text.Encoding.UTF8.GetString(decompressedBody, offset, fileSize); + Assert.Equal(expected, fileContent); + offset += fileSize; + } + } + + /// + /// Tests that requesting a duplicate file index returns BadRequest. + /// First waits for the version to be ingested (by performing a valid request). + /// + [Fact] + public async Task DownloadPost_DuplicateFileIndex_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + + // First ensure the version is ingested + var validBody = BuildDownloadRequestBody(0); + var validContent = new ByteArrayContent(validBody); + validContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + await PollUntilOk(() => + { + var req = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = validContent + }; + req.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(req); + }); + + // Now request file 0 twice + var body = BuildDownloadRequestBody(0, 0); + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DownloadPost_OutOfBoundsIndex_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + + // First ensure the version is ingested + var validBody = BuildDownloadRequestBody(0); + var validContent = new ByteArrayContent(validBody); + validContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + await PollUntilOk(() => + { + var req = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = validContent + }; + req.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(req); + }); + + // Request file index 99 which is out of bounds (only 3 files) + var body = BuildDownloadRequestBody(99); + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DownloadPost_NegativeIndex_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + + // First ensure the version is ingested + var validBody = BuildDownloadRequestBody(0); + var validContent = new ByteArrayContent(validBody); + validContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + await PollUntilOk(() => + { + var req = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = validContent + }; + req.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(req); + }); + + // Now request with negative index + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, -1); + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/{MultiFileVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + /// + /// Builds a download request body with a list of file indices (each as a 4-byte LE int). + /// + private static byte[] BuildDownloadRequestBody(params int[] indices) + { + var body = new byte[indices.Length * 4]; + for (var i = 0; i < indices.Length; i++) + { + BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(i * 4, 4), indices[i]); + } + return body; + } +} diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs index 3bbcd02..dbd5c56 100644 --- a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Headers; using Microsoft.AspNetCore.Mvc.Testing; +using Robust.Cdn.Config; using SharpZstd; namespace Robust.Cdn.Tests.Controllers; @@ -20,11 +21,13 @@ public sealed class ForkDownloadControllerTests(WebApplicationFactory f { protected override string ForkName => "testfork2"; + const string FileContent = "test content"; + protected override Dictionary GetConfigurationOverrides() { return new() { - ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(ForkName, "1.0.0"), + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(ForkName, "1.0.0", content: FileContent), }; } @@ -147,17 +150,14 @@ public async Task DownloadPost_ValidRequest_ReturnsOkWithStreamContent() var responseBody = await response.Content.ReadAsByteArrayAsync(); Assert.True(responseBody.Length >= 4, "Response must contain at least the stream header"); - // Remaining: per-file headers + data - Assert.True(responseBody.Length > 4, "Response must contain file data after stream header"); - // Next 4 bytes: file header (uncompressed size) - Assert.True(responseBody.Length >= 8, "Response must contain file header"); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(4, 4)); - Assert.True(fileSize > 0, "File size must be positive"); + Assert.Equal(expectedSize, fileSize); // Rest of body: file data - var fileData = await response.Content.ReadAsStringAsync(); - Assert.Contains("test content", fileData); + var fileData = System.Text.Encoding.UTF8.GetString(responseBody, 8, fileSize); + Assert.Equal(FileContent, fileData); } [Fact] @@ -188,23 +188,124 @@ public async Task DownloadPost_WithZstdStreamCompression_ReturnsCompressedStream Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); // Decompress the entire response body - await using var compressedStream = await response.Content.ReadAsStreamAsync(); - await using var decompressStream = new ZstdDecodeStream(compressedStream, leaveOpen: false); - using var memStream = new MemoryStream(); - await decompressStream.CopyToAsync(memStream); - var decompressedBody = memStream.ToArray(); + var decompressedBody = await DecompressBody(response); - // Parse the decompressed download stream format Assert.True(decompressedBody.Length >= 8, "Decompressed stream must contain header + file header"); // Next 4 bytes: file header (uncompressed size) + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(4, 4)); - Assert.True(fileSize > 0, "File size must be positive"); + Assert.Equal(expectedSize, fileSize); // Rest: file data var fileData = decompressedBody[8..]; var fileContent = System.Text.Encoding.UTF8.GetString(fileData); - Assert.Equal("test content", fileContent); + Assert.Equal(FileContent, fileContent); + } + + [Fact] + public async Task DownloadPost_WithAutoRatioOptingForPreCompression_ReturnsPreCompressedStream() + { + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + + // Use default AutoStreamCompressRatio (0.5) and request 1 file out of 1 distinct blob. + // The ratio 1.0 > 0.5 would go to stream compression branch, so we set ratio high (e.g. 2.0) + // so 1.0 <= 2.0 triggers the else branch: pre-compression. + var factory = Factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + [$"Cdn:{nameof(CdnOptions.AutoStreamCompressRatio)}"] = "2.0", + }); + }); + }); + + var client = factory.CreateClient(); + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") + { + Content = new ByteArrayContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } + } + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(request); + }); + + // No stream-level compression since else branch sets optStreamCompression = false. + // Pre-compression is active, so response body is raw with 8-byte per-file headers. + var responseBody = await response.Content.ReadAsByteArrayAsync(); + + // Stream header (4 bytes) + Assert.True(responseBody.Length >= 4, "Response must contain stream header"); + var streamHeaderFlags = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4)); + // StreamHeaderFlags.PreCompressed = 1, so flags should be non-zero + Assert.Equal(1, streamHeaderFlags); + + // File header: 8 bytes (4 size + 4 compression info) since pre-compressed + Assert.True(responseBody.Length >= 12, "Response must contain full file header"); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(4, 4)); + Assert.Equal(expectedSize, fileSize); + var compInfo = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(8, 4)); + Assert.Equal(0, compInfo); // No compression for this test data + + // File data + var fileContent = System.Text.Encoding.UTF8.GetString(responseBody[12..]); + Assert.Equal(FileContent, fileContent); + } + + [Fact] + public async Task DownloadPost_WithAutoStreamCompressRatioDisabled_StreamCompressFallback() + { + var factory = Factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + [$"Cdn:{nameof(CdnOptions.AutoStreamCompressRatio)}"] = "-1", + [$"Cdn:{nameof(CdnOptions.StreamCompress)}"] = "true", + [$"Cdn:{nameof(CdnOptions.SendPreCompressed)}"] = "false", + }); + }); + }); + + var client = factory.CreateClient(); + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") + { + Content = new ByteArrayContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } + } + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); + return client.SendAsync(request); + }); + + Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); + + var decompressedBody = await DecompressBody(response); + + Assert.True(decompressedBody.Length >= 8, "Decompressed stream must contain header + file header"); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(4, 4)); + Assert.Equal(expectedSize, fileSize); + + var fileData = decompressedBody[8..]; + var fileContent = System.Text.Encoding.UTF8.GetString(fileData); + Assert.Equal(FileContent, fileContent); } } diff --git a/Robust.Cdn.Tests/DatabaseFixture.cs b/Robust.Cdn.Tests/DatabaseFixture.cs index d8a82a2..7bda2e8 100644 --- a/Robust.Cdn.Tests/DatabaseFixture.cs +++ b/Robust.Cdn.Tests/DatabaseFixture.cs @@ -22,8 +22,14 @@ public string CreateTempDb() /// Creates a fork directory with a version directory and a client zip for the ingest job to find. /// Returns the base path that should be used as FileDiskPath. /// - public string CreateTestVersionOnDisk(string fork, string version, string clientZipName = "test") + public string CreateTestVersionOnDisk( + string fork, + string version, + string? content = null, + string clientZipName = "test" + ) { + content ??= "test content"; var baseDir = Path.Combine(Path.GetTempPath(), "RobustCdnTests", Guid.NewGuid().ToString()); var versionDir = Path.Combine(baseDir, fork, version); Directory.CreateDirectory(versionDir); @@ -35,7 +41,42 @@ public string CreateTestVersionOnDisk(string fork, string version, string client { var entry = zip.CreateEntry("test.txt"); using var writer = new StreamWriter(entry.Open()); - writer.Write("test content"); + writer.Write(content); + } + + return baseDir; + } + + /// + /// Creates a fork directory with a version containing a client zip that has multiple files. + /// Each file has unique content (file name as content). + /// Returns the base path that should be used as FileDiskPath. + /// + public string CreateTestVersionWithMultipleFilesOnDisk( + string fork, + string version, + Func? contentFactory = null, + string clientZipName = "test", + IReadOnlyList? fileNames = null) + { + contentFactory ??= (fn) => fn + " test content1"; + fileNames ??= ["alpha.txt", "beta.txt", "gamma.txt"]; + + var baseDir = Path.Combine(Path.GetTempPath(), "RobustCdnTests", Guid.NewGuid().ToString()); + var versionDir = Path.Combine(baseDir, fork, version); + Directory.CreateDirectory(versionDir); + _tempDirs.Add(baseDir); + + var zipPath = Path.Combine(versionDir, $"{clientZipName}.zip"); + using (var zipStream = File.Create(zipPath)) + using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create)) + { + foreach (var fileName in fileNames) + { + var entry = zip.CreateEntry(fileName); + using var writer = new StreamWriter(entry.Open()); + writer.Write(contentFactory(fileName)); + } } return baseDir; @@ -54,3 +95,4 @@ public void Dispose() } } } + diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs index cbca5f5..acdf100 100644 --- a/Robust.Cdn.Tests/TestBase.cs +++ b/Robust.Cdn.Tests/TestBase.cs @@ -1,5 +1,6 @@ using System.Net; using Microsoft.AspNetCore.Mvc.Testing; +using SharpZstd; namespace Robust.Cdn.Tests; @@ -87,5 +88,18 @@ protected static Task PollUntilOk(Func Task.FromResult(response.StatusCode == HttpStatusCode.OK), maxAttempts, delayMs); } + + /// + /// Decompresses response content using zstd. + /// Returns stream as bytes array. + /// + protected static async Task DecompressBody(HttpResponseMessage response) + { + await using var compressedStream = await response.Content.ReadAsStreamAsync(); + await using var decompressStream = new ZstdDecodeStream(compressedStream, leaveOpen: false); + using var memStream = new MemoryStream(); + await decompressStream.CopyToAsync(memStream); + return memStream.ToArray(); + } } From 90fb01c833e0e521aa26cdaa3265c0f7a6c59f05 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 15:40:32 +0300 Subject: [PATCH 06/19] refactor: ForkManifestControllerTests, publishing one-shot added as way to test new endpoints, probably will be reused later (but not now) --- .../ForkManifestControllerTests.cs | 213 ++++++++++++++++++ Robust.Cdn.Tests/TestBase.cs | 73 ++++++ 2 files changed, 286 insertions(+) create mode 100644 Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs new file mode 100644 index 0000000..36cbf4e --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs @@ -0,0 +1,213 @@ +using System.IO.Compression; +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests.Controllers; + +[Trait("Category", "IntegrationTest")] +public sealed class ForkManifestControllerTests(WebApplicationFactory factory, DatabaseFixture database) + : TestBase(factory, database) +{ + private static readonly DateTimeOffset _fixedLastWriteTime = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + + protected override string ForkName => "testfork2"; + private const string Token = "s3cret"; + private string? _fileDiskPath; + + protected override Dictionary GetConfigurationOverrides() + { + _fileDiskPath ??= Database.CreateTestVersionOnDisk(ForkName, "1.0.0"); + return new() + { + ["Manifest:FileDiskPath"] = _fileDiskPath, + [$"Manifest:Forks:{ForkName}:Private"] = "false", + [$"Manifest:Forks:{ForkName}:UpdateToken"] = Token, + ["BaseUrl"] = "http://localhost/", + }; + } + + [Fact] + public async Task GetManifest_ForkNotInConfiguration_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync("/fork/nonexistent/manifest"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetManifest_PublicForkNoManifestCache_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{ForkName}/manifest"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetManifest_PublicForkWithCache_FromPublishFlow_ReturnsJson() + { + var client = Factory.CreateClient(); + + const string postVersion = "2.0.3"; + await SetupPublishedBuild(client, postVersion); + + // Poll for the manifest to appear with build data. + var manifestResponse = await PollUntilCondition( + () => client.GetAsync($"/fork/{ForkName}/manifest"), + async response => + { + if (response.StatusCode != HttpStatusCode.OK) + return false; + + var body = await response.Content.ReadAsStringAsync(); + return body.Contains(postVersion); + } + ); + + Assert.Equal("application/json", manifestResponse.Content.Headers.ContentType?.MediaType); + + var body = await manifestResponse.Content.ReadAsStringAsync(); + var bodyElements = JsonSerializer.Deserialize(body); + Assert.True(bodyElements.TryGetProperty("builds", out var builds)); + Assert.True(builds.TryGetProperty(postVersion, out var version)); + Assert.True(version.TryGetProperty("client", out var c)); + + Assert.True(c.TryGetProperty("url", out var clientUrl)); + Assert.Equal($"http://localhost/fork/{ForkName}/version/{postVersion}/file/test.zip", clientUrl.GetString()); + + Assert.True(c.TryGetProperty("sha256", out var clientHash)); + Assert.Matches("^[0-9A-F]{64}$", clientHash.GetString()); + + Assert.True(version.TryGetProperty("server", out var s)); + Assert.Equal(2, s.GetPropertyCount()); + + Assert.True(s.TryGetProperty("linux-x64", out var linux)); + Assert.True(linux.TryGetProperty("url", out var linuxUrl)); + Assert.Equal($"http://localhost/fork/{ForkName}/version/{postVersion}/file/SS14.Server_linux-x64.zip", linuxUrl.GetString()); + + Assert.True(linux.TryGetProperty("sha256", out var linuxHash)); + // Server zip hashes cannot be verified with pre-computed values because the publish flow + // injects build.json into server zips after extraction, modifying their content and hash. + Assert.Matches("^[0-9A-F]{64}$", linuxHash.GetString()); + + Assert.True(linux.TryGetProperty("size", out var linuxSize)); + Assert.Equal(535, linuxSize.GetInt64()); + + Assert.True(s.TryGetProperty("win-x64", out var win)); + Assert.True(win.TryGetProperty("url", out var winUrl)); + Assert.Equal($"http://localhost/fork/{ForkName}/version/{postVersion}/file/SS14.Server_win-x64.zip", winUrl.GetString()); + + Assert.True(win.TryGetProperty("sha256", out var winHash)); + // Same as linux: hash is modified by build.json injection, only format is verified. + Assert.Matches("^[0-9A-F]{64}$", winHash.GetString()); + + Assert.True(win.TryGetProperty("size", out var winSize)); + Assert.Equal(533, winSize.GetInt64()); + } + + [Fact] + public async Task GetFile_ForkNotInConfiguration_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync("/fork/nonexistent/version/1.0.0/file/test.zip"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetFile_VersionDoesNotExist_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{ForkName}/version/9.9.9/file/test.zip"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetFile_FileOnDiskMissing_ReturnsInternalServerError() + { + var client = Factory.CreateClient(); + const string version = "2.0.1"; + await SetupPublishedBuild(client, version); + + var response = await client.GetAsync($"/fork/{ForkName}/version/{version}/file/nonexistent.zip"); + // PhysicalFile throws when file doesn't exist, resulting in a 500 + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Theory] + [InlineData("test.zip", "data.txt", "hello from publish")] + [InlineData("SS14.Server_linux-x64.zip", "Robust.Server.dll", "fake server binary for SS14.Server_linux-x64.zip")] + [InlineData("SS14.Server_win-x64.zip", "Robust.Server.dll", "fake server binary for SS14.Server_win-x64.zip")] + public async Task GetFile_Success_ReturnsZipFile(string fileName, string expectedEntry, string expectedContent) + { + var client = Factory.CreateClient(); + + const string version = "2.0.2"; + await SetupPublishedBuild(client, version); + + var response = await client.GetAsync($"/fork/{ForkName}/version/{version}/file/{fileName}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType); + + var data = await response.Content.ReadAsByteArrayAsync(); + using var zipStream = new MemoryStream(data); + using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read); + + var entry = zip.GetEntry(expectedEntry); + Assert.NotNull(entry); + + using var reader = new StreamReader(entry.Open()); + var actualContent = await reader.ReadToEndAsync(); + Assert.Equal(expectedContent, actualContent); + } + + private async Task SetupPublishedBuild(HttpClient client, string? version = null) + { + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreatePublishArchive(archivePath, clientZipName: "test", content: "hello from publish"); + await PublishOneShotRelease(client, archivePath, Token, version: version, basePort: 18765); + } + + private static void CreatePublishArchive(string archivePath, string clientZipName, string content) + { + using (var archiveStream = File.Create(archivePath)) + using (var archive = new ZipArchive(archiveStream, ZipArchiveMode.Create)) + { + // Client zip (required by publish) + var clientEntry = archive.CreateEntry($"{clientZipName}.zip"); + clientEntry.LastWriteTime = _fixedLastWriteTime; + using (var clientEntryStream = clientEntry.Open()) + using (var clientZip = new ZipArchive(clientEntryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var fileEntry = clientZip.CreateEntry("data.txt"); + fileEntry.LastWriteTime = _fixedLastWriteTime; + using var writer = new StreamWriter(fileEntry.Open()); + writer.Write(content); + } + + // Server zips (exercises more of the publish flow) + var serverZipNames = new[] { "SS14.Server_win-x64.zip", "SS14.Server_linux-x64.zip" }; + foreach (var serverName in serverZipNames) + { + var serverEntry = archive.CreateEntry(serverName); + serverEntry.LastWriteTime = _fixedLastWriteTime; + using var serverEntryStream = serverEntry.Open(); + using var serverZip = new ZipArchive(serverEntryStream, ZipArchiveMode.Create, leaveOpen: true); + var serverFile = serverZip.CreateEntry("Robust.Server.dll"); + serverFile.LastWriteTime = _fixedLastWriteTime; + using var serverWriter = new StreamWriter(serverFile.Open()); + serverWriter.Write($"fake server binary for {serverName}"); + } + + // A regular file that should be skipped by ClassifyEntries (not matching client/server pattern) + // but will not break upload process + var extraEntry = archive.CreateEntry("readme.txt"); + extraEntry.LastWriteTime = _fixedLastWriteTime; + using (var extraWriter = new StreamWriter(extraEntry.Open())) + { + extraWriter.Write("this file should be ignored by publish"); + } + } + } +} diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs index acdf100..fc3c832 100644 --- a/Robust.Cdn.Tests/TestBase.cs +++ b/Robust.Cdn.Tests/TestBase.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using Microsoft.AspNetCore.Mvc.Testing; using SharpZstd; @@ -13,6 +14,7 @@ public abstract class TestBase : IClassFixture { protected WebApplicationFactory Factory { get; } protected DatabaseFixture Database { get; } + protected string ManifestDbPath { get; } protected abstract string ForkName { get; } @@ -26,6 +28,8 @@ protected TestBase(WebApplicationFactory factory, DatabaseFixture datab config[key] = value; } + ManifestDbPath = config["Manifest:DatabaseFileName"]!; + Factory = factory.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, configBuilder) => @@ -101,5 +105,74 @@ protected static async Task DecompressBody(HttpResponseMessage response) await decompressStream.CopyToAsync(memStream); return memStream.ToArray(); } + + /// Start host so CDN can download release fils then poke CDN to publish new release. + protected async Task PublishOneShotRelease( + HttpClient client, + string archivePath, + string bearerToken, + string? version = null, + int basePort = 18765 + ) + { + await using var serverTask = new FileProvidingTemporaryHost(basePort, archivePath); + + var publishRequest = new + { + Version = version ?? "2.0.0", + EngineVersion = "0.1.2", + Archive = $"http://127.0.0.1:{basePort}/archive.zip" + }; + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/publish") + { + Content = JsonContent.Create(publishRequest) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken); + + var publishResponse = await client.SendAsync(request); + publishResponse.EnsureSuccessStatusCode(); + } + + /// + /// Simple listener for providing static file on request. + /// Is used due to publish process requiring a URL to download new version as archive. + /// + private class FileProvidingTemporaryHost : IAsyncDisposable + { + private readonly string _filePath; + private readonly CancellationTokenSource _cts = new(); + private readonly HttpListener _listener; + private readonly Task _task; + + public FileProvidingTemporaryHost(int port, string filePath) + { + _filePath = filePath; + _listener = new HttpListener(); + _listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + _listener.Start(); + _task = Task.Factory.StartNew(Loop); + } + + private async Task Loop() + { + var token = _cts.Token; + while (!token.IsCancellationRequested) + { + var ctx = await _listener.GetContextAsync().WaitAsync(token); + await using var file = File.OpenRead(_filePath); + ctx.Response.ContentType = "application/zip"; + ctx.Response.ContentLength64 = file.Length; + await file.CopyToAsync(ctx.Response.OutputStream, token); + ctx.Response.Close(); + } + } + + public async ValueTask DisposeAsync() + { + _cts.Dispose(); + _listener.Stop(); + await _task; + } + } } From 6cd22f6198eb8f3a7d423b63180ddb29a60eb404 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 18:49:07 +0300 Subject: [PATCH 07/19] refactor: minor cleanups --- .editorconfig | 1 + .../ForkDownloadControllerMultiFileTests.cs | 1 - .../Controllers/ForkManifestControllerTests.cs | 12 ++++++------ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.editorconfig b/.editorconfig index dba7ad9..77652f7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,6 +7,7 @@ indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true +end_of_line = crlf [*.{csproj,xml,yml,dll.config,msbuildproj,targets,props,json}] indent_size = 2 diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs index 3773b8c..29f4f0d 100644 --- a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Http.Headers; using Microsoft.AspNetCore.Mvc.Testing; -using SharpZstd; namespace Robust.Cdn.Tests.Controllers; diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs index 36cbf4e..2248677 100644 --- a/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs @@ -9,7 +9,7 @@ namespace Robust.Cdn.Tests.Controllers; public sealed class ForkManifestControllerTests(WebApplicationFactory factory, DatabaseFixture database) : TestBase(factory, database) { - private static readonly DateTimeOffset _fixedLastWriteTime = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset FixedLastWriteTime = new(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); protected override string ForkName => "testfork2"; private const string Token = "s3cret"; @@ -176,12 +176,12 @@ private static void CreatePublishArchive(string archivePath, string clientZipNam { // Client zip (required by publish) var clientEntry = archive.CreateEntry($"{clientZipName}.zip"); - clientEntry.LastWriteTime = _fixedLastWriteTime; + clientEntry.LastWriteTime = FixedLastWriteTime; using (var clientEntryStream = clientEntry.Open()) using (var clientZip = new ZipArchive(clientEntryStream, ZipArchiveMode.Create, leaveOpen: true)) { var fileEntry = clientZip.CreateEntry("data.txt"); - fileEntry.LastWriteTime = _fixedLastWriteTime; + fileEntry.LastWriteTime = FixedLastWriteTime; using var writer = new StreamWriter(fileEntry.Open()); writer.Write(content); } @@ -191,11 +191,11 @@ private static void CreatePublishArchive(string archivePath, string clientZipNam foreach (var serverName in serverZipNames) { var serverEntry = archive.CreateEntry(serverName); - serverEntry.LastWriteTime = _fixedLastWriteTime; + serverEntry.LastWriteTime = FixedLastWriteTime; using var serverEntryStream = serverEntry.Open(); using var serverZip = new ZipArchive(serverEntryStream, ZipArchiveMode.Create, leaveOpen: true); var serverFile = serverZip.CreateEntry("Robust.Server.dll"); - serverFile.LastWriteTime = _fixedLastWriteTime; + serverFile.LastWriteTime = FixedLastWriteTime; using var serverWriter = new StreamWriter(serverFile.Open()); serverWriter.Write($"fake server binary for {serverName}"); } @@ -203,7 +203,7 @@ private static void CreatePublishArchive(string archivePath, string clientZipNam // A regular file that should be skipped by ClassifyEntries (not matching client/server pattern) // but will not break upload process var extraEntry = archive.CreateEntry("readme.txt"); - extraEntry.LastWriteTime = _fixedLastWriteTime; + extraEntry.LastWriteTime = FixedLastWriteTime; using (var extraWriter = new StreamWriter(extraEntry.Open())) { extraWriter.Write("this file should be ignored by publish"); From 20a7f4d6bedf26051233f0dd5c81bd9c2df66300 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 20:25:56 +0300 Subject: [PATCH 08/19] refactor: base DownloadController tests class, compatibility controller tests --- .../DownloadCompatibilityControllerTests.cs | 94 ++++++ .../Controllers/DownloadControllerTestBase.cs | 282 +++++++++++++++++ .../ForkDownloadControllerTests.cs | 283 +----------------- 3 files changed, 382 insertions(+), 277 deletions(-) create mode 100644 Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs create mode 100644 Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs diff --git a/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs b/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs new file mode 100644 index 0000000..34c1f1a --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for DownloadCompatibilityController (legacy /version/{version}/... routes). +/// +/// Endpoints: +/// GET /version/{version}/manifest +/// OPTIONS /version/{version}/download +/// POST /version/{version}/download +/// +public sealed class DownloadCompatibilityControllerTests( + WebApplicationFactory factory, + DatabaseFixture database) + : DownloadControllerTestBase(factory, database) +{ + private const string CompatFork = "testfork-compat"; + + protected override string ForkName => CompatFork; + protected override string RoutePrefix => "/version"; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + ["Cdn:DefaultFork"] = CompatFork, + ["Cdn:DatabaseFileName"] = Database.CreateTempDb(), + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(CompatFork, ExistingVersion, content: FileContent), + }; + } + + [Fact] + public async Task GetManifest_VersionNotFound_ReturnsNotFound() + { + var client = Factory.CreateClient(); + + var response = await PollUntilCondition( + () => client.GetAsync("/version/0.0.0/manifest"), + r => Task.FromResult(r.StatusCode == HttpStatusCode.NotFound)); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task GetManifest_DefaultForkNull_ReturnsNotFound() + { + var client = FactoryWithNoDefaultFork().CreateClient(); + var response = await client.GetAsync($"/version/{ExistingVersion}/manifest"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DownloadOptions_DefaultForkNull_ReturnsNotFound() + { + var client = FactoryWithNoDefaultFork().CreateClient(); + var response = await client.SendAsync(new(HttpMethod.Options, $"/version/{ExistingVersion}/download")); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task DownloadPost_DefaultForkNull_ReturnsNotFound() + { + var client = FactoryWithNoDefaultFork().CreateClient(); + + var content = new ByteArrayContent(new byte[4]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/version/{ExistingVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + private WebApplicationFactory FactoryWithNoDefaultFork() + { + return Factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + ["Cdn:DefaultFork"] = null, + }); + }); + }); + } +} diff --git a/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs b/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs new file mode 100644 index 0000000..ff2e648 --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs @@ -0,0 +1,282 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Testing; +using Robust.Cdn.Config; +using SharpZstd; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Base class for testing both DownloadController (via /fork/{fork}/version/{version}/...) +/// and DownloadCompatibilityController (via /version/{version}/...). +/// +[Trait("Category", "IntegrationTest")] +public abstract class DownloadControllerTestBase(WebApplicationFactory factory, DatabaseFixture database) + : TestBase(factory, database) +{ + protected const string FileContent = "test content"; + protected const string ExistingVersion = "1.0.0"; + + /// + /// URL prefix for the controller under test, without trailing slash and without version. + /// + protected abstract string RoutePrefix { get; } + + #region Get Manifest + + [Fact] + public async Task GetManifest_ExistingVersion_ReturnsOkWithManifestHash() + { + var client = Factory.CreateClient(); + var response = await PollUntilOk(() => client.GetAsync($"{RoutePrefix}/{ExistingVersion}/manifest")); + + Assert.True(response.Headers.Contains("X-Manifest-Hash")); + Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType?.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.Equal(2, lines.Length); + Assert.StartsWith("Robust Content Manifest", lines[0]); + Assert.Equal("8D3BD9BFD005F4179699B9212273284C6DE851AE910EEE2FD1BB0D96EDE65A3D test.txt", lines[1]); + } + + [Fact] + public async Task GetManifest_WithZstdEncoding_ReturnsCompressedManifest() + { + var client = Factory.CreateClient(); + var response = await PollUntilOk(() => + { + var req = new HttpRequestMessage(HttpMethod.Get, $"{RoutePrefix}/{ExistingVersion}/manifest"); + req.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); + return client.SendAsync(req); + }); + + Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); + Assert.True(response.Headers.Contains("X-Manifest-Hash")); + + await using var bodyStream = await response.Content.ReadAsStreamAsync(); + await using var decompressStream = new ZstdDecodeStream(bodyStream, leaveOpen: false); + using var reader = new StreamReader(decompressStream); + var body = await reader.ReadToEndAsync(); + var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.Equal(2, lines.Length); + Assert.StartsWith("Robust Content Manifest", lines[0]); + Assert.Equal("8D3BD9BFD005F4179699B9212273284C6DE851AE910EEE2FD1BB0D96EDE65A3D test.txt", lines[1]); + } + + #endregion + + #region Options Download + + [Fact] + public async Task DownloadOptions_ReturnsNoContentWithProtocolHeaders() + { + var client = Factory.CreateClient(); + var response = await client.SendAsync(new(HttpMethod.Options, $"{RoutePrefix}/{ExistingVersion}/download")); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var minProtocol = int.Parse(response.Headers.GetValues("X-Robust-Download-Min-Protocol").Single()); + var maxProtocol = int.Parse(response.Headers.GetValues("X-Robust-Download-Max-Protocol").Single()); + + Assert.True(minProtocol <= maxProtocol, + $"Min protocol ({minProtocol}) must be less than or equal to max protocol ({maxProtocol})"); + } + + #endregion + + #region Post Download + + [Fact] + public async Task DownloadPost_WrongContentType_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + var content = new StringContent("test"); + var response = await client.PostAsync($"{RoutePrefix}/{ExistingVersion}/download", content); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Theory] + [InlineData("0")] + [InlineData("99")] + [InlineData("abc")] + [InlineData("")] + public async Task DownloadPost_InvalidProtocol_ReturnsBadRequest(string protocol) + { + var client = Factory.CreateClient(); + var content = new ByteArrayContent(new byte[4]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", protocol); + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DownloadPost_ValidRequest_ReturnsOkWithStreamContent() + { + var client = Factory.CreateClient(); + + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + var content = new ByteArrayContent(body); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(request); + }); + + var responseBody = await response.Content.ReadAsByteArrayAsync(); + Assert.True(responseBody.Length >= 4, "Response must contain at least the stream header"); + + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(4, 4)); + Assert.Equal(expectedSize, fileSize); + + var fileData = System.Text.Encoding.UTF8.GetString(responseBody, 8, fileSize); + Assert.Equal(FileContent, fileData); + } + + [Fact] + public async Task DownloadPost_WithZstdStreamCompression_ReturnsCompressedStream() + { + var client = Factory.CreateClient(); + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + { + Content = new ByteArrayContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } + } + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); + return client.SendAsync(request); + }); + + Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); + + var decompressedBody = await DecompressBody(response); + + Assert.True(decompressedBody.Length >= 8, "Decompressed stream must contain header + file header"); + + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(4, 4)); + Assert.Equal(expectedSize, fileSize); + + var fileData = decompressedBody[8..]; + var fileContent = System.Text.Encoding.UTF8.GetString(fileData); + Assert.Equal(FileContent, fileContent); + } + + [Fact] + public async Task DownloadPost_WithAutoRatioOptingForPreCompression_ReturnsPreCompressedStream() + { + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + + var factory = Factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + [$"Cdn:{nameof(CdnOptions.AutoStreamCompressRatio)}"] = "2.0", + }); + }); + }); + + var client = factory.CreateClient(); + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + { + Content = new ByteArrayContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } + } + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(request); + }); + + var responseBody = await response.Content.ReadAsByteArrayAsync(); + + Assert.True(responseBody.Length >= 4, "Response must contain stream header"); + var streamHeaderFlags = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4)); + Assert.Equal(1, streamHeaderFlags); + + Assert.True(responseBody.Length >= 12, "Response must contain full file header"); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(4, 4)); + Assert.Equal(expectedSize, fileSize); + var compInfo = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(8, 4)); + Assert.Equal(0, compInfo); + + var fileContent = System.Text.Encoding.UTF8.GetString(responseBody[12..]); + Assert.Equal(FileContent, fileContent); + } + + [Fact] + public async Task DownloadPost_WithAutoStreamCompressRatioDisabled_StreamCompressFallback() + { + var factory = Factory.WithWebHostBuilder(builder => + { + builder.ConfigureAppConfiguration((_, configBuilder) => + { + configBuilder.AddInMemoryCollection(new Dictionary + { + [$"Cdn:{nameof(CdnOptions.AutoStreamCompressRatio)}"] = "-1", + [$"Cdn:{nameof(CdnOptions.StreamCompress)}"] = "true", + [$"Cdn:{nameof(CdnOptions.SendPreCompressed)}"] = "false", + }); + }); + }); + + var client = factory.CreateClient(); + var body = new byte[4]; + BinaryPrimitives.WriteInt32LittleEndian(body, 0); + + var response = await PollUntilOk(() => + { + var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + { + Content = new ByteArrayContent(body) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } + } + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); + return client.SendAsync(request); + }); + + Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); + + var decompressedBody = await DecompressBody(response); + + Assert.True(decompressedBody.Length >= 8, "Decompressed stream must contain header + file header"); + var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); + var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(4, 4)); + Assert.Equal(expectedSize, fileSize); + + var fileData = decompressedBody[8..]; + var fileContent = System.Text.Encoding.UTF8.GetString(fileData); + Assert.Equal(FileContent, fileContent); + } + + #endregion +} diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs index dbd5c56..0ed6e74 100644 --- a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs @@ -1,33 +1,28 @@ -using System.Buffers.Binary; using System.Net; -using System.Net.Http.Headers; using Microsoft.AspNetCore.Mvc.Testing; -using Robust.Cdn.Config; -using SharpZstd; namespace Robust.Cdn.Tests.Controllers; /// -/// Integration tests for DownloadController. +/// Integration tests for DownloadController via the standard /fork/{fork}/version/{version}/... route. /// /// Endpoints: -/// GET /fork/{fork}/version/{version}/manifest +/// GET /fork/{fork}/version/{version}/manifest /// OPTIONS /fork/{fork}/version/{version}/download -/// POST /fork/{fork}/version/{version}/download +/// POST /fork/{fork}/version/{version}/download /// -[Trait("Category", "IntegrationTest")] public sealed class ForkDownloadControllerTests(WebApplicationFactory factory, DatabaseFixture database) - : TestBase(factory, database) + : DownloadControllerTestBase(factory, database) { protected override string ForkName => "testfork2"; - const string FileContent = "test content"; + protected override string RoutePrefix => $"/fork/{ForkName}/version"; protected override Dictionary GetConfigurationOverrides() { return new() { - ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(ForkName, "1.0.0", content: FileContent), + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(ForkName, ExistingVersion, content: FileContent), }; } @@ -41,271 +36,5 @@ public async Task GetManifest_NonExistentForkOrVersion_ReturnsNotFound(string fo var response = await client.GetAsync($"/fork/{fork}/version/{version}/manifest"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - - [Fact] - public async Task GetManifest_ExistingVersion_ReturnsOkWithManifestHash() - { - var client = Factory.CreateClient(); - var response = await PollUntilOk(() => client.GetAsync($"/fork/{ForkName}/version/1.0.0/manifest")); - - Assert.True(response.Headers.Contains("X-Manifest-Hash")); - Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType?.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - Assert.Equal(2, lines.Length); - Assert.StartsWith("Robust Content Manifest", lines[0]); - Assert.Equal("8D3BD9BFD005F4179699B9212273284C6DE851AE910EEE2FD1BB0D96EDE65A3D test.txt", lines[1]); - } - - [Fact] - public async Task GetManifest_WithZstdEncoding_ReturnsCompressedManifest() - { - var client = Factory.CreateClient(); - var response = await PollUntilOk(() => - { - var req = new HttpRequestMessage(HttpMethod.Get, $"/fork/{ForkName}/version/1.0.0/manifest"); - req.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); - return client.SendAsync(req); - }); - - Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); - Assert.True(response.Headers.Contains("X-Manifest-Hash")); - - // Decompress the zstd body and verify manifest contents - await using var bodyStream = await response.Content.ReadAsStreamAsync(); - await using var decompressStream = new ZstdDecodeStream(bodyStream, leaveOpen: false); - using var reader = new StreamReader(decompressStream); - var body = await reader.ReadToEndAsync(); - var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - Assert.Equal(2, lines.Length); - Assert.StartsWith("Robust Content Manifest", lines[0]); - Assert.Equal("8D3BD9BFD005F4179699B9212273284C6DE851AE910EEE2FD1BB0D96EDE65A3D test.txt", lines[1]); - } - - [Fact] - public async Task DownloadOptions_ReturnsNoContentWithProtocolHeaders() - { - var client = Factory.CreateClient(); - var response = await client.SendAsync(new(HttpMethod.Options, $"/fork/{ForkName}/version/1.0.0/download")); - - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - var minProtocol = int.Parse(response.Headers.GetValues("X-Robust-Download-Min-Protocol").Single()); - var maxProtocol = int.Parse(response.Headers.GetValues("X-Robust-Download-Max-Protocol").Single()); - - Assert.True(minProtocol <= maxProtocol, - $"Min protocol ({minProtocol}) must be less than or equal to max protocol ({maxProtocol})"); - } - - [Fact] - public async Task DownloadPost_WrongContentType_ReturnsBadRequest() - { - var client = Factory.CreateClient(); - var content = new StringContent("test"); - var response = await client.PostAsync($"/fork/{ForkName}/version/1.0.0/download", content); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Theory] - [InlineData("0")] - [InlineData("99")] - [InlineData("abc")] - [InlineData("")] - public async Task DownloadPost_InvalidProtocol_ReturnsBadRequest(string protocol) - { - var client = Factory.CreateClient(); - var content = new ByteArrayContent(new byte[4]); - content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") - { - Content = content - }; - request.Headers.Add("X-Robust-Download-Protocol", protocol); - var response = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } - - [Fact] - public async Task DownloadPost_ValidRequest_ReturnsOkWithStreamContent() - { - var client = Factory.CreateClient(); - - // Build request body: a single 4-byte LE int with value 0 (index of test.txt in manifest) - var body = new byte[4]; - BinaryPrimitives.WriteInt32LittleEndian(body, 0); - var content = new ByteArrayContent(body); - content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - - var response = await PollUntilOk(() => - { - var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") - { - Content = content - }; - request.Headers.Add("X-Robust-Download-Protocol", "1"); - return client.SendAsync(request); - }); - - // Read the response body and parse the download stream format - var responseBody = await response.Content.ReadAsByteArrayAsync(); - Assert.True(responseBody.Length >= 4, "Response must contain at least the stream header"); - - // Next 4 bytes: file header (uncompressed size) - var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); - var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(4, 4)); - Assert.Equal(expectedSize, fileSize); - - // Rest of body: file data - var fileData = System.Text.Encoding.UTF8.GetString(responseBody, 8, fileSize); - Assert.Equal(FileContent, fileData); - } - - [Fact] - public async Task DownloadPost_WithZstdStreamCompression_ReturnsCompressedStream() - { - var client = Factory.CreateClient(); - var body = new byte[4]; - BinaryPrimitives.WriteInt32LittleEndian(body, 0); - - // Since the default AutoStreamCompressRatio is 0.5 and we request 1 of 1 file, - // the ratio 1.0 > 0.5 triggers stream compression automatically. - // We need Accept-Encoding: zstd for the server to actually use it. - var response = await PollUntilOk(() => - { - var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") - { - Content = new ByteArrayContent(body) - { - Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } - } - }; - request.Headers.Add("X-Robust-Download-Protocol", "1"); - request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); - return client.SendAsync(request); - }); - - // Verify the response is zstd-compressed at the stream level - Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); - - // Decompress the entire response body - var decompressedBody = await DecompressBody(response); - - Assert.True(decompressedBody.Length >= 8, "Decompressed stream must contain header + file header"); - - // Next 4 bytes: file header (uncompressed size) - var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); - var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(4, 4)); - Assert.Equal(expectedSize, fileSize); - - // Rest: file data - var fileData = decompressedBody[8..]; - var fileContent = System.Text.Encoding.UTF8.GetString(fileData); - Assert.Equal(FileContent, fileContent); - } - - [Fact] - public async Task DownloadPost_WithAutoRatioOptingForPreCompression_ReturnsPreCompressedStream() - { - var body = new byte[4]; - BinaryPrimitives.WriteInt32LittleEndian(body, 0); - - // Use default AutoStreamCompressRatio (0.5) and request 1 file out of 1 distinct blob. - // The ratio 1.0 > 0.5 would go to stream compression branch, so we set ratio high (e.g. 2.0) - // so 1.0 <= 2.0 triggers the else branch: pre-compression. - var factory = Factory.WithWebHostBuilder(builder => - { - builder.ConfigureAppConfiguration((_, configBuilder) => - { - configBuilder.AddInMemoryCollection(new Dictionary - { - [$"Cdn:{nameof(CdnOptions.AutoStreamCompressRatio)}"] = "2.0", - }); - }); - }); - - var client = factory.CreateClient(); - var response = await PollUntilOk(() => - { - var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") - { - Content = new ByteArrayContent(body) - { - Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } - } - }; - request.Headers.Add("X-Robust-Download-Protocol", "1"); - return client.SendAsync(request); - }); - - // No stream-level compression since else branch sets optStreamCompression = false. - // Pre-compression is active, so response body is raw with 8-byte per-file headers. - var responseBody = await response.Content.ReadAsByteArrayAsync(); - - // Stream header (4 bytes) - Assert.True(responseBody.Length >= 4, "Response must contain stream header"); - var streamHeaderFlags = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4)); - // StreamHeaderFlags.PreCompressed = 1, so flags should be non-zero - Assert.Equal(1, streamHeaderFlags); - - // File header: 8 bytes (4 size + 4 compression info) since pre-compressed - Assert.True(responseBody.Length >= 12, "Response must contain full file header"); - var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); - var fileSize = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(4, 4)); - Assert.Equal(expectedSize, fileSize); - var compInfo = BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(8, 4)); - Assert.Equal(0, compInfo); // No compression for this test data - - // File data - var fileContent = System.Text.Encoding.UTF8.GetString(responseBody[12..]); - Assert.Equal(FileContent, fileContent); - } - - [Fact] - public async Task DownloadPost_WithAutoStreamCompressRatioDisabled_StreamCompressFallback() - { - var factory = Factory.WithWebHostBuilder(builder => - { - builder.ConfigureAppConfiguration((_, configBuilder) => - { - configBuilder.AddInMemoryCollection(new Dictionary - { - [$"Cdn:{nameof(CdnOptions.AutoStreamCompressRatio)}"] = "-1", - [$"Cdn:{nameof(CdnOptions.StreamCompress)}"] = "true", - [$"Cdn:{nameof(CdnOptions.SendPreCompressed)}"] = "false", - }); - }); - }); - - var client = factory.CreateClient(); - var body = new byte[4]; - BinaryPrimitives.WriteInt32LittleEndian(body, 0); - - var response = await PollUntilOk(() => - { - var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{ForkName}/version/1.0.0/download") - { - Content = new ByteArrayContent(body) - { - Headers = { ContentType = new MediaTypeHeaderValue("application/octet-stream") } - } - }; - request.Headers.Add("X-Robust-Download-Protocol", "1"); - request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("zstd")); - return client.SendAsync(request); - }); - - Assert.Equal("zstd", response.Content.Headers.GetValues("Content-Encoding").Single()); - - var decompressedBody = await DecompressBody(response); - - Assert.True(decompressedBody.Length >= 8, "Decompressed stream must contain header + file header"); - var expectedSize = System.Text.Encoding.UTF8.GetByteCount(FileContent); - var fileSize = BinaryPrimitives.ReadInt32LittleEndian(decompressedBody.AsSpan(4, 4)); - Assert.Equal(expectedSize, fileSize); - - var fileData = decompressedBody[8..]; - var fileContent = System.Text.Encoding.UTF8.GetString(fileData); - Assert.Equal(FileContent, fileContent); - } } From 8cf9d910bb6d1a3881440c73c802646e03201066 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 20:57:05 +0300 Subject: [PATCH 09/19] refactor: extracted publish into TestBase --- .../ForkManifestControllerPrivateForkTests.cs | 154 ++++++++++++++++++ .../ForkManifestControllerTests.cs | 44 ----- Robust.Cdn.Tests/TestBase.cs | 51 ++++++ 3 files changed, 205 insertions(+), 44 deletions(-) create mode 100644 Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs new file mode 100644 index 0000000..caf8822 --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs @@ -0,0 +1,154 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests.Controllers; + +[Trait("Category", "IntegrationTest")] +public sealed class ForkManifestControllerPrivateForkTests( + WebApplicationFactory factory, + DatabaseFixture database) + : TestBase(factory, database) +{ + private const string PrivateForkName = "testfork-private"; + private const string Token = "s3cret"; + private const string ValidUser = "admin"; + private const string ValidPassword = "hunter2"; + + protected override string ForkName => PrivateForkName; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + [$"Manifest:Forks:{PrivateForkName}:Private"] = "true", + [$"Manifest:Forks:{PrivateForkName}:UpdateToken"] = Token, + [$"Manifest:Forks:{PrivateForkName}:PrivateUsers:{ValidUser}"] = ValidPassword, + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(PrivateForkName, "1.0.0"), + ["BaseUrl"] = "http://localhost/", + }; + } + + #region Get Manifest — Auth + + [Fact] + public async Task GetManifest_PrivateForkNoAuth_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{PrivateForkName}/manifest"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("Basic", response.Headers.WwwAuthenticate.ToString()); + } + + [Fact] + public async Task GetManifest_PrivateForkWrongPassword_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/fork/{PrivateForkName}/manifest"); + request.Headers.Authorization = BasicAuthHeader(ValidUser, "wrongpassword"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetManifest_PrivateForkUnknownUser_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/fork/{PrivateForkName}/manifest"); + request.Headers.Authorization = BasicAuthHeader("unknown", ValidPassword); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetManifest_PrivateForkValidAuth_ReturnsJson() + { + var client = Factory.CreateClient(); + + // Publish a build first so there's manifest cache to read. + const string version = "2.0.0"; + await SetupPublishedBuild(client, version); + + var response = await PollUntilCondition( + () => + { + var req = new HttpRequestMessage(HttpMethod.Get, $"/fork/{PrivateForkName}/manifest"); + req.Headers.Authorization = BasicAuthHeader(ValidUser, ValidPassword); + return client.SendAsync(req); + }, + async r => r.StatusCode == HttpStatusCode.OK && (await r.Content.ReadAsStringAsync()).Contains(version)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + } + + + #endregion + + #region Get File — Auth + + [Fact] + public async Task GetFile_PrivateForkNoAuth_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{PrivateForkName}/version/1.0.0/file/test.zip"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("Basic", response.Headers.WwwAuthenticate.ToString()); + } + + [Fact] + public async Task GetFile_PrivateForkWrongPassword_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/fork/{PrivateForkName}/version/1.0.0/file/test.zip"); + request.Headers.Authorization = BasicAuthHeader(ValidUser, "wrongpassword"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetFile_PrivateForkValidAuth_ReturnsZip() + { + var client = Factory.CreateClient(); + + const string version = "2.0.1"; + await SetupPublishedBuild(client, version); + + var response = await PollUntilCondition( + () => + { + var req = new HttpRequestMessage(HttpMethod.Get, $"/fork/{PrivateForkName}/version/{version}/file/test.zip"); + req.Headers.Authorization = BasicAuthHeader(ValidUser, ValidPassword); + return client.SendAsync(req); + }, + r => Task.FromResult(r.StatusCode == HttpStatusCode.OK)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType); + } + + #endregion + + #region Helpers + + private async Task SetupPublishedBuild(HttpClient client, string? version = null) + { + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreatePublishArchive(archivePath, clientZipName: "test", content: "hello from publish"); + await PublishOneShotRelease(client, archivePath, Token, version: version, basePort: 18766); + } + + private static AuthenticationHeaderValue BasicAuthHeader(string user, string password) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{password}")); + return new AuthenticationHeaderValue("Basic", credentials); + } + + #endregion +} diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs index 2248677..53d79b5 100644 --- a/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs @@ -9,8 +9,6 @@ namespace Robust.Cdn.Tests.Controllers; public sealed class ForkManifestControllerTests(WebApplicationFactory factory, DatabaseFixture database) : TestBase(factory, database) { - private static readonly DateTimeOffset FixedLastWriteTime = new(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); - protected override string ForkName => "testfork2"; private const string Token = "s3cret"; private string? _fileDiskPath; @@ -168,46 +166,4 @@ private async Task SetupPublishedBuild(HttpClient client, string? version = null CreatePublishArchive(archivePath, clientZipName: "test", content: "hello from publish"); await PublishOneShotRelease(client, archivePath, Token, version: version, basePort: 18765); } - - private static void CreatePublishArchive(string archivePath, string clientZipName, string content) - { - using (var archiveStream = File.Create(archivePath)) - using (var archive = new ZipArchive(archiveStream, ZipArchiveMode.Create)) - { - // Client zip (required by publish) - var clientEntry = archive.CreateEntry($"{clientZipName}.zip"); - clientEntry.LastWriteTime = FixedLastWriteTime; - using (var clientEntryStream = clientEntry.Open()) - using (var clientZip = new ZipArchive(clientEntryStream, ZipArchiveMode.Create, leaveOpen: true)) - { - var fileEntry = clientZip.CreateEntry("data.txt"); - fileEntry.LastWriteTime = FixedLastWriteTime; - using var writer = new StreamWriter(fileEntry.Open()); - writer.Write(content); - } - - // Server zips (exercises more of the publish flow) - var serverZipNames = new[] { "SS14.Server_win-x64.zip", "SS14.Server_linux-x64.zip" }; - foreach (var serverName in serverZipNames) - { - var serverEntry = archive.CreateEntry(serverName); - serverEntry.LastWriteTime = FixedLastWriteTime; - using var serverEntryStream = serverEntry.Open(); - using var serverZip = new ZipArchive(serverEntryStream, ZipArchiveMode.Create, leaveOpen: true); - var serverFile = serverZip.CreateEntry("Robust.Server.dll"); - serverFile.LastWriteTime = FixedLastWriteTime; - using var serverWriter = new StreamWriter(serverFile.Open()); - serverWriter.Write($"fake server binary for {serverName}"); - } - - // A regular file that should be skipped by ClassifyEntries (not matching client/server pattern) - // but will not break upload process - var extraEntry = archive.CreateEntry("readme.txt"); - extraEntry.LastWriteTime = FixedLastWriteTime; - using (var extraWriter = new StreamWriter(extraEntry.Open())) - { - extraWriter.Write("this file should be ignored by publish"); - } - } - } } diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs index fc3c832..15a061f 100644 --- a/Robust.Cdn.Tests/TestBase.cs +++ b/Robust.Cdn.Tests/TestBase.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Net; using System.Net.Http.Headers; using Microsoft.AspNetCore.Mvc.Testing; @@ -12,6 +13,11 @@ namespace Robust.Cdn.Tests; [Collection("ControllerTests")] public abstract class TestBase : IClassFixture { + /// + /// Fixed timestamp for archive entries to produce deterministic content. + /// + private static readonly DateTimeOffset FixedLastWriteTime = new(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + protected WebApplicationFactory Factory { get; } protected DatabaseFixture Database { get; } protected string ManifestDbPath { get; } @@ -133,6 +139,51 @@ protected async Task PublishOneShotRelease( publishResponse.EnsureSuccessStatusCode(); } + /// + /// Creates a ZIP archive on disk containing a client zip, server zips, and an extra file. + /// Used to simulate a CI build artifact for publish tests. + /// + protected static void CreatePublishArchive(string archivePath, string clientZipName, string content) + { + using (var archiveStream = File.Create(archivePath)) + using (var archive = new ZipArchive(archiveStream, ZipArchiveMode.Create)) + { + // Client zip (required by publish) + var clientEntry = archive.CreateEntry($"{clientZipName}.zip"); + clientEntry.LastWriteTime = FixedLastWriteTime; + using (var clientEntryStream = clientEntry.Open()) + using (var clientZip = new ZipArchive(clientEntryStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var fileEntry = clientZip.CreateEntry("data.txt"); + fileEntry.LastWriteTime = FixedLastWriteTime; + using var writer = new StreamWriter(fileEntry.Open()); + writer.Write(content); + } + + // Server zips + var serverZipNames = new[] { "SS14.Server_win-x64.zip", "SS14.Server_linux-x64.zip" }; + foreach (var serverName in serverZipNames) + { + var serverEntry = archive.CreateEntry(serverName); + serverEntry.LastWriteTime = FixedLastWriteTime; + using var serverEntryStream = serverEntry.Open(); + using var serverZip = new ZipArchive(serverEntryStream, ZipArchiveMode.Create, leaveOpen: true); + var serverFile = serverZip.CreateEntry("Robust.Server.dll"); + serverFile.LastWriteTime = FixedLastWriteTime; + using var serverWriter = new StreamWriter(serverFile.Open()); + serverWriter.Write($"fake server binary for {serverName}"); + } + + // Extra file that should be skipped by ClassifyEntries + var extraEntry = archive.CreateEntry("readme.txt"); + extraEntry.LastWriteTime = FixedLastWriteTime; + using (var extraWriter = new StreamWriter(extraEntry.Open())) + { + extraWriter.Write("this file should be ignored by publish"); + } + } + } + /// /// Simple listener for providing static file on request. /// Is used due to publish process requiring a URL to download new version as archive. From f8c46c676db1040b88d872d7c5082714ceaf04fa Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 20:57:31 +0300 Subject: [PATCH 10/19] refactor: whitespaces --- .../Controllers/ForkManifestControllerPrivateForkTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs index caf8822..cf401ec 100644 --- a/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs @@ -135,7 +135,7 @@ public async Task GetFile_PrivateForkValidAuth_ReturnsZip() #endregion - #region Helpers + #region Helpers private async Task SetupPublishedBuild(HttpClient client, string? version = null) { From 4da92a24c55eb8f00ef21bbc5c0c8eb27e3eec39 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 21:22:14 +0300 Subject: [PATCH 11/19] refactor: one-shot publish tests --- .../ForkPublishControllerOneShotTests.cs | 256 ++++++++++++++++++ Robust.Cdn.Tests/TestBase.cs | 21 +- 2 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs b/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs new file mode 100644 index 0000000..b2c185f --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs @@ -0,0 +1,256 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for ForkPublishController.PostPublish (one-shot publish). +/// +/// Endpoint: POST /fork/{fork}/publish +/// +[Trait("Category", "IntegrationTest")] +public sealed class ForkPublishControllerOneShotTests( + WebApplicationFactory factory, + DatabaseFixture database) + : TestBase(factory, database) +{ + private const string PublishFork = "testfork-publish"; + private const string Token = "s3cret"; + + protected override string ForkName => PublishFork; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + [$"Manifest:Forks:{PublishFork}:UpdateToken"] = Token, + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(PublishFork, "1.0.0"), + ["BaseUrl"] = "http://localhost/", + }; + } + + #region Auth + + [Fact] + public async Task PostPublish_NoAuthHeader_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var content = PublishRequestContent(); + var response = await client.PostAsync($"/fork/{PublishFork}/publish", content); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostPublish_WrongAuthType_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{PublishFork}/publish") + { + Content = PublishRequestContent() + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", "token"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostPublish_WrongToken_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{PublishFork}/publish") + { + Content = PublishRequestContent() + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "wrong-token"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + #endregion + + #region Validation — Version + + [Fact] + public async Task PostPublish_EmptyVersion_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{PublishFork}/publish") + { + Content = PublishRequestContent(version: "") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostPublish_VersionAlreadyExists_ReturnsConflict() + { + var client = Factory.CreateClient(); + + // Publish a build to create the version + const string version = "2.0.0"; + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreatePublishArchive(archivePath, clientZipName: "test", content: "hello"); + await PublishOneShotRelease(client, archivePath, Token, version: version, basePort: 18990); + + // Try to publish the same version again + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{PublishFork}/publish") + { + Content = PublishRequestContent(version: version) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + #endregion + + #region Validation — Archive + + [Fact] + public async Task PostPublish_EmptyArchiveUrl_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{PublishFork}/publish") + { + Content = PublishRequestContent(archive: string.Empty) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostPublish_UnreachableArchiveUrl_ReturnsInternalServerError() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{PublishFork}/publish") + { + Content = PublishRequestContent(archive: "http://127.0.0.1:18998/nonexistent.zip") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + // The HTTP client can't reach the URL, resulting in an exception → 500 + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task PostPublish_MissingClientZip_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreateServerOnlyArchive(archivePath); + + const string version = "2.0.1"; + await using var host = new FileProvidingTemporaryHost(18997, archivePath); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{PublishFork}/publish") + { + Content = PublishRequestContent( + version: version, + archive: "http://127.0.0.1:18997/archive.zip") + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Client zip is missing", body); + } + + #endregion + + #region Fork not configured + + [Fact] + public async Task PostPublish_ForkNotConfigured_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "/fork/nonexistent/publish") + { + Content = PublishRequestContent() + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + #endregion + + #region Success + + [Fact] + public async Task PostPublish_Success_ReturnsNoContent() + { + var client = Factory.CreateClient(); + + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreatePublishArchive(archivePath, clientZipName: "test", content: "hello from publish"); + + const string version = "3.0.0"; + await PublishOneShotRelease(client, archivePath, Token, version: version, basePort: 18995); + + // Verify the version is accessible via GetFile + var getResponse = await client.GetAsync($"/fork/{PublishFork}/version/{version}/file/test.zip"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + Assert.Equal("application/zip", getResponse.Content.Headers.ContentType?.MediaType); + + // Verify the zip content is correct + var data = await getResponse.Content.ReadAsByteArrayAsync(); + using var zipStream = new MemoryStream(data); + using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read); + var entry = zip.GetEntry("data.txt"); + Assert.NotNull(entry); + using var reader = new StreamReader(entry.Open()); + var actualContent = await reader.ReadToEndAsync(); + Assert.Equal("hello from publish", actualContent); + } + + [Fact] + public async Task PostPublish_Success_TriggersIngestAndPopulatesManifest() + { + var client = Factory.CreateClient(); + + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreatePublishArchive(archivePath, clientZipName: "test", content: "from success test"); + + const string version = "4.0.0"; + await PublishOneShotRelease(client, archivePath, Token, version: version, basePort: 18994); + + // Poll until manifest cache appears for that version + var manifestResponse = await PollUntilCondition( + () => client.GetAsync($"/fork/{PublishFork}/manifest"), + async r => r.StatusCode == HttpStatusCode.OK && (await r.Content.ReadAsStringAsync()).Contains(version)); + + Assert.Equal(HttpStatusCode.OK, manifestResponse.StatusCode); + Assert.Equal("application/json", manifestResponse.Content.Headers.ContentType?.MediaType); + } + + #endregion + + #region Helpers + + private static JsonContent PublishRequestContent(string? version = null, string? engineVersion = null, string? archive = null) + { + return JsonContent.Create(new + { + Version = version ?? "2.0.0", + EngineVersion = engineVersion ?? "0.1.2", + Archive = archive ?? "http://127.0.0.1:18999/archive.zip" + }); + } + + #endregion +} diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs index 15a061f..88d8d97 100644 --- a/Robust.Cdn.Tests/TestBase.cs +++ b/Robust.Cdn.Tests/TestBase.cs @@ -184,11 +184,30 @@ protected static void CreatePublishArchive(string archivePath, string clientZipN } } + /// + /// Creates a ZIP archive containing only a server zip (no client zip). + /// Used to test the "Client zip is missing" error in publish. + /// + protected static void CreateServerOnlyArchive(string archivePath) + { + using var archiveStream = File.Create(archivePath); + using var archive = new ZipArchive(archiveStream, ZipArchiveMode.Create); + + var serverEntry = archive.CreateEntry("SS14.Server_linux-x64.zip"); + serverEntry.LastWriteTime = FixedLastWriteTime; + using var serverEntryStream = serverEntry.Open(); + using var serverZip = new ZipArchive(serverEntryStream, ZipArchiveMode.Create, leaveOpen: true); + var serverFile = serverZip.CreateEntry("Robust.Server.dll"); + serverFile.LastWriteTime = FixedLastWriteTime; + using var serverWriter = new StreamWriter(serverFile.Open()); + serverWriter.Write("fake server binary"); + } + /// /// Simple listener for providing static file on request. /// Is used due to publish process requiring a URL to download new version as archive. /// - private class FileProvidingTemporaryHost : IAsyncDisposable + protected class FileProvidingTemporaryHost : IAsyncDisposable { private readonly string _filePath; private readonly CancellationTokenSource _cts = new(); From 8db6bda872f628efc87cdbe27f8479f991d0a7ab Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 23:03:26 +0300 Subject: [PATCH 12/19] refactor: test output for server logs attached --- .../DownloadCompatibilityControllerTests.cs | 8 +++-- .../Controllers/DownloadControllerTestBase.cs | 8 +++-- .../ForkDownloadControllerMultiFileTests.cs | 10 ++++-- .../ForkDownloadControllerTests.cs | 10 ++++-- .../ForkManifestControllerPrivateForkTests.cs | 8 +++-- .../ForkManifestControllerTests.cs | 10 ++++-- .../ForkPublishControllerOneShotTests.cs | 8 +++-- .../Controllers/StatusControllerTests.cs | 8 +++-- Robust.Cdn.Tests/TestBase.cs | 17 +++++---- Robust.Cdn.Tests/XUnitLoggerProvider.cs | 35 +++++++++++++++++++ .../DeleteTimedOutInProgressPublishesJob.cs | 7 ++-- 11 files changed, 97 insertions(+), 32 deletions(-) create mode 100644 Robust.Cdn.Tests/XUnitLoggerProvider.cs diff --git a/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs b/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs index 34c1f1a..27dc7d2 100644 --- a/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs @@ -1,6 +1,7 @@ +using Microsoft.AspNetCore.Mvc.Testing; using System.Net; using System.Net.Http.Headers; -using Microsoft.AspNetCore.Mvc.Testing; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; @@ -14,8 +15,9 @@ namespace Robust.Cdn.Tests.Controllers; /// public sealed class DownloadCompatibilityControllerTests( WebApplicationFactory factory, - DatabaseFixture database) - : DownloadControllerTestBase(factory, database) + DatabaseFixture database, + ITestOutputHelper testOutput +) : DownloadControllerTestBase(factory, database, testOutput) { private const string CompatFork = "testfork-compat"; diff --git a/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs b/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs index ff2e648..cb6a7eb 100644 --- a/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs +++ b/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc.Testing; using Robust.Cdn.Config; using SharpZstd; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; @@ -12,8 +13,11 @@ namespace Robust.Cdn.Tests.Controllers; /// and DownloadCompatibilityController (via /version/{version}/...). /// [Trait("Category", "IntegrationTest")] -public abstract class DownloadControllerTestBase(WebApplicationFactory factory, DatabaseFixture database) - : TestBase(factory, database) +public abstract class DownloadControllerTestBase( + WebApplicationFactory factory, + DatabaseFixture database, + ITestOutputHelper testOutput +) : TestBase(factory, database, testOutput) { protected const string FileContent = "test content"; protected const string ExistingVersion = "1.0.0"; diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs index 29f4f0d..20f64a8 100644 --- a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs @@ -1,7 +1,8 @@ +using Microsoft.AspNetCore.Mvc.Testing; using System.Buffers.Binary; using System.Net; using System.Net.Http.Headers; -using Microsoft.AspNetCore.Mvc.Testing; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; @@ -11,8 +12,11 @@ namespace Robust.Cdn.Tests.Controllers; /// Endpoint: POST /fork/{fork}/version/{version}/download /// [Trait("Category", "IntegrationTest")] -public sealed class ForkDownloadControllerMultiFileTests(WebApplicationFactory factory, DatabaseFixture database) - : TestBase(factory, database) +public sealed class ForkDownloadControllerMultiFileTests( + WebApplicationFactory factory, + DatabaseFixture database, + ITestOutputHelper testOutput +) : TestBase(factory, database, testOutput) { private const string MultiFileFork = "testfork-multi"; private const string MultiFileVersion = "1.0.0"; diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs index 0ed6e74..30f689d 100644 --- a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs @@ -1,5 +1,6 @@ -using System.Net; using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; @@ -11,8 +12,11 @@ namespace Robust.Cdn.Tests.Controllers; /// OPTIONS /fork/{fork}/version/{version}/download /// POST /fork/{fork}/version/{version}/download /// -public sealed class ForkDownloadControllerTests(WebApplicationFactory factory, DatabaseFixture database) - : DownloadControllerTestBase(factory, database) +public sealed class ForkDownloadControllerTests( + WebApplicationFactory factory, + DatabaseFixture database, + ITestOutputHelper testOutput +) : DownloadControllerTestBase(factory, database, testOutput) { protected override string ForkName => "testfork2"; diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs index cf401ec..e7e13e8 100644 --- a/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs @@ -1,15 +1,17 @@ +using Microsoft.AspNetCore.Mvc.Testing; using System.Net; using System.Net.Http.Headers; using System.Text; -using Microsoft.AspNetCore.Mvc.Testing; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; [Trait("Category", "IntegrationTest")] public sealed class ForkManifestControllerPrivateForkTests( WebApplicationFactory factory, - DatabaseFixture database) - : TestBase(factory, database) + DatabaseFixture database, + ITestOutputHelper testOutput +) : TestBase(factory, database, testOutput) { private const string PrivateForkName = "testfork-private"; private const string Token = "s3cret"; diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs index 53d79b5..743faed 100644 --- a/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs @@ -1,13 +1,17 @@ +using Microsoft.AspNetCore.Mvc.Testing; using System.IO.Compression; using System.Net; using System.Text.Json; -using Microsoft.AspNetCore.Mvc.Testing; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; [Trait("Category", "IntegrationTest")] -public sealed class ForkManifestControllerTests(WebApplicationFactory factory, DatabaseFixture database) - : TestBase(factory, database) +public sealed class ForkManifestControllerTests( + WebApplicationFactory factory, + DatabaseFixture database, + ITestOutputHelper testOutput +) : TestBase(factory, database, testOutput) { protected override string ForkName => "testfork2"; private const string Token = "s3cret"; diff --git a/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs b/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs index b2c185f..afcdf74 100644 --- a/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs @@ -1,7 +1,8 @@ +using Microsoft.AspNetCore.Mvc.Testing; using System.IO.Compression; using System.Net; using System.Net.Http.Headers; -using Microsoft.AspNetCore.Mvc.Testing; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; @@ -13,8 +14,9 @@ namespace Robust.Cdn.Tests.Controllers; [Trait("Category", "IntegrationTest")] public sealed class ForkPublishControllerOneShotTests( WebApplicationFactory factory, - DatabaseFixture database) - : TestBase(factory, database) + DatabaseFixture database, + ITestOutputHelper testOutput +) : TestBase(factory, database, testOutput) { private const string PublishFork = "testfork-publish"; private const string Token = "s3cret"; diff --git a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs index cedabaa..00001b6 100644 --- a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json; using Microsoft.AspNetCore.Mvc.Testing; +using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; @@ -10,8 +11,11 @@ namespace Robust.Cdn.Tests.Controllers; /// Endpoint: GET /control/status /// [Trait("Category", "Integration")] -public sealed class StatusControllerTests(WebApplicationFactory factory, DatabaseFixture database) - : TestBase(factory, database) +public sealed class StatusControllerTests( + WebApplicationFactory factory, + DatabaseFixture database, + ITestOutputHelper testOutput +) : TestBase(factory, database, testOutput) { protected override string ForkName => "testfork"; diff --git a/Robust.Cdn.Tests/TestBase.cs b/Robust.Cdn.Tests/TestBase.cs index 88d8d97..8095c08 100644 --- a/Robust.Cdn.Tests/TestBase.cs +++ b/Robust.Cdn.Tests/TestBase.cs @@ -1,8 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using SharpZstd; using System.IO.Compression; using System.Net; using System.Net.Http.Headers; -using Microsoft.AspNetCore.Mvc.Testing; -using SharpZstd; +using Xunit.Abstractions; namespace Robust.Cdn.Tests; @@ -13,10 +14,7 @@ namespace Robust.Cdn.Tests; [Collection("ControllerTests")] public abstract class TestBase : IClassFixture { - /// - /// Fixed timestamp for archive entries to produce deterministic content. - /// - private static readonly DateTimeOffset FixedLastWriteTime = new(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); + protected static readonly DateTimeOffset FixedLastWriteTime = new(2020, 1, 1, 0, 0, 0, TimeSpan.Zero); protected WebApplicationFactory Factory { get; } protected DatabaseFixture Database { get; } @@ -24,7 +22,7 @@ public abstract class TestBase : IClassFixture protected abstract string ForkName { get; } - protected TestBase(WebApplicationFactory factory, DatabaseFixture database) + protected TestBase(WebApplicationFactory factory, DatabaseFixture database, ITestOutputHelper testOutput) { Database = database; @@ -42,6 +40,11 @@ protected TestBase(WebApplicationFactory factory, DatabaseFixture datab { configBuilder.AddInMemoryCollection(config); }); + + builder.ConfigureLogging(logging => + { + logging.AddProvider(new XUnitLoggerProvider(testOutput)); + }); }); } diff --git a/Robust.Cdn.Tests/XUnitLoggerProvider.cs b/Robust.Cdn.Tests/XUnitLoggerProvider.cs new file mode 100644 index 0000000..bbb202c --- /dev/null +++ b/Robust.Cdn.Tests/XUnitLoggerProvider.cs @@ -0,0 +1,35 @@ +using Xunit.Abstractions; + +namespace Robust.Cdn.Tests; + +public sealed class XUnitLoggerProvider(ITestOutputHelper testOutput) : ILoggerProvider +{ + public ILogger CreateLogger(string categoryName) => new XUnitLogger(categoryName, testOutput); + + public void Dispose() + { + } + + private sealed class XUnitLogger(string categoryName, ITestOutputHelper testOutput) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + return; + + var message = formatter(state, exception); + testOutput.WriteLine($"[{logLevel}] [{categoryName}] {message}"); + if (exception != null) + testOutput.WriteLine($" Exception: {exception}"); + } + } +} diff --git a/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs b/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs index 1183fa4..f179a51 100644 --- a/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs +++ b/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs @@ -1,4 +1,4 @@ -using Dapper; +using Dapper; using Microsoft.Extensions.Options; using Quartz; using Robust.Cdn.Config; @@ -42,10 +42,11 @@ FROM PublishInProgress foreach (var (_, name, forkName, startTime) in inProgress) { - if (startTime >= deleteBefore) + var utcStartTime = DateTime.SpecifyKind(startTime, DateTimeKind.Utc); + if (utcStartTime >= deleteBefore) continue; - logger.LogInformation("Deleting timed out publish for fork {Fork} version {Version}", forkName, name); + logger.LogInformation("Deleting timed out publish for fork {Fork} version {Version}, {startTime}, {deleteBefore}", forkName, name, utcStartTime, deleteBefore); publishManager.AbortMultiPublish(forkName, name, tx, commit: false); From d6eb0d555243c446e1c026eccd960aaa944f3314 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 23:11:24 +0300 Subject: [PATCH 13/19] refactor: multi step publish process tests --- .../ForkPublishControllerMultiPublishTests.cs | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs new file mode 100644 index 0000000..c202ef2 --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs @@ -0,0 +1,386 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using System.IO.Compression; +using System.Net; +using System.Net.Http.Headers; +using Xunit.Abstractions; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for the multi-request publish workflow: +/// POST /fork/{fork}/publish/start +/// POST /fork/{fork}/publish/file (with headers Robust-Cdn-Publish-File, Robust-Cdn-Publish-Version) +/// POST /fork/{fork}/publish/finish +/// +[Trait("Category", "IntegrationTest")] +public sealed class ForkPublishControllerMultiPublishTests( + WebApplicationFactory factory, + DatabaseFixture database, ITestOutputHelper testOutput) + : TestBase(factory, database , testOutput) +{ + private const string MultiForkSuccesses= "testfork-multi-successes"; + private const string MultiFork = "testfork-multi-failures"; + private const string Token = "s3cret"; + + protected override string ForkName => MultiFork; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + [$"Manifest:Forks:{MultiForkSuccesses}:ClientZipName"] = "test", + [$"Manifest:Forks:{MultiForkSuccesses}:BuildsPageLinkText"] = "test", + [$"Manifest:Forks:{MultiForkSuccesses}:UpdateToken"] = Token, + [$"Manifest:Forks:{MultiFork}:UpdateToken"] = Token, + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(MultiFork, "1.0.0"), + ["BaseUrl"] = "http://localhost/", + }; + } + + #region Auth + + [Fact] + public async Task MultiPublishStart_NoAuth_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var content = JsonContent.Create(new { Version = "2.0.0", EngineVersion = "0.1.2" }); + var response = await client.PostAsync($"/fork/{MultiFork}/publish/start", content); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task MultiPublishStart_WrongToken_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/start") + { + Content = JsonContent.Create(new { Version = "2.0.0", EngineVersion = "0.1.2" }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "wrong-token"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task MultiPublishFile_NoAuth_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var content = new ByteArrayContent([]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/file") + { + Content = content + }; + request.Headers.Add("Robust-Cdn-Publish-File", "test.zip"); + request.Headers.Add("Robust-Cdn-Publish-Version", "2.0.0"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task MultiPublishFinish_NoAuth_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var content = JsonContent.Create(new { Version = "2.0.0" }); + var response = await client.PostAsync($"/fork/{MultiFork}/publish/finish", content); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + #endregion + + #region Start — Validation + + [Fact] + public async Task MultiPublishStart_InvalidVersion_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/start") + { + Content = JsonContent.Create(new { Version = "", EngineVersion = "0.1.2" }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task MultiPublishStart_VersionAlreadyExists_ReturnsConflict() + { + var client = Factory.CreateClient(); + + // First complete publish of version 2.0.0 via one-shot + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreatePublishArchive(archivePath, clientZipName: "test", content: "hello"); + await PublishOneShotRelease(client, archivePath, Token, version: "2.0.0", basePort: 18980); + + // Try to start multi-publish for the same version + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/start") + { + Content = JsonContent.Create(new { Version = "2.0.0", EngineVersion = "0.1.2" }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task MultiPublishStart_ForkNotConfigured_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "/fork/nonexistent/publish/start") + { + Content = JsonContent.Create(new { Version = "2.0.0", EngineVersion = "0.1.2" }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + #endregion + + #region File — Validation + + [Fact] + public async Task MultiPublishFile_NoVersionHeader_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + var content = new ByteArrayContent([]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/file") + { + Content = content + }; + request.Headers.Add("Robust-Cdn-Publish-File", "test.zip"); + // Missing Robust-Cdn-Publish-Version header + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + // Without the version header, the parameter is null; model binding or validation produces BadRequest + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task MultiPublishFile_InvalidFileName_ReturnsBadRequest() + { + var client = Factory.CreateClient(); + + // Start a publish first so the DB lookup succeeds + const string version = "103.0.0"; + var startRequest = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/start") + { + Content = JsonContent.Create(new { Version = version, EngineVersion = "0.1.2" }) + }; + startRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + await client.SendAsync(startRequest); + + var content = new ByteArrayContent([]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/file") + { + Content = content + }; + request.Headers.Add("Robust-Cdn-Publish-File", ".."); + request.Headers.Add("Robust-Cdn-Publish-Version", version); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + // ".." doesn't match ValidFileRegex (first char must be [a-zA-Z0-9\-_]) + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + #endregion + + #region Full Workflow + + [Fact] + public async Task MultiPublish_FullSuccessFlow_CompletesPublish() + { + var client = Factory.CreateClient(); + + const string version = "100.0.0"; + const string engineVersion = "0.1.2"; + + // 1. Start + var startRequest = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiForkSuccesses}/publish/start") + { + Content = JsonContent.Create(new { Version = version, EngineVersion = engineVersion }) + }; + startRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var startResponse = await client.SendAsync(startRequest); + Assert.Equal(HttpStatusCode.NoContent, startResponse.StatusCode); + + // 2. Upload client zip + await UploadMultiPublishFile(client, version, "test.zip", MultiForkSuccesses, CreateClientZip("hello from multi-publish")); + + // 3. Upload server zips + await UploadMultiPublishFile(client, version, "SS14.Server_linux-x64.zip", MultiForkSuccesses, CreateServerZip("linux")); + await UploadMultiPublishFile(client, version, "SS14.Server_win-x64.zip", MultiForkSuccesses, CreateServerZip("win")); + + // 4. Finish + var finishRequest = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiForkSuccesses}/publish/finish") + { + Content = JsonContent.Create(new { Version = version }) + }; + finishRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var finishResponse = await client.SendAsync(finishRequest); + Assert.Equal(HttpStatusCode.NoContent, finishResponse.StatusCode); + + // 5. Verify — manifest cache should appear + await PollUntilCondition( + () => client.GetAsync($"/fork/{MultiForkSuccesses}/manifest"), + async r => r.StatusCode == HttpStatusCode.OK && (await r.Content.ReadAsStringAsync()).Contains(version), + maxAttempts: 30 + ); + + // 6. Verify — file is accessible + var getResponse = await client.GetAsync($"/fork/{MultiForkSuccesses}/version/{version}/file/test.zip"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var data = await getResponse.Content.ReadAsByteArrayAsync(); + using var zipStream = new MemoryStream(data); + using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read); + var entry = zip.GetEntry("data.txt"); + Assert.NotNull(entry); + using var reader = new StreamReader(entry.Open()); + var actualContent = await reader.ReadToEndAsync(); + Assert.Equal("hello from multi-publish", actualContent); + } + + [Fact] + public async Task MultiPublishFinish_NoClientZip_ReturnsUnprocessableEntity() + { + var client = Factory.CreateClient(); + + const string version = "101.0.0"; + + // Start + var startRequest = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiForkSuccesses}/publish/start") + { + Content = JsonContent.Create(new { Version = version, EngineVersion = "0.1.2" }) + }; + startRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + await client.SendAsync(startRequest); + + // Upload only a server zip, no client zip + await UploadMultiPublishFile(client, version, "SS14.Server_linux-x64.zip", MultiForkSuccesses, CreateServerZip("linux-only")); + + // Finish → should fail + var finishRequest = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiForkSuccesses}/publish/finish") + { + Content = JsonContent.Create(new { Version = version }) + }; + finishRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var finishResponse = await client.SendAsync(finishRequest); + Assert.Equal(HttpStatusCode.UnprocessableEntity, finishResponse.StatusCode); + var body = await finishResponse.Content.ReadAsStringAsync(); + Assert.Contains("no client zip was provided", body); + } + + [Fact] + public async Task MultiPublishFinish_VersionNotStarted_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiForkSuccesses}/publish/finish") + { + Content = JsonContent.Create(new { Version = "5.0.0" }) + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task MultiPublishFile_DuplicateFile_ReturnsConflict() + { + var client = Factory.CreateClient(); + + const string version = "102.0.0"; + + // Start + var startRequest = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiForkSuccesses}/publish/start") + { + Content = JsonContent.Create(new { Version = version, EngineVersion = "0.1.2" }) + }; + startRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + await client.SendAsync(startRequest); + + // Upload file + await UploadMultiPublishFile(client, version, "test.zip", MultiForkSuccesses, CreateClientZip("first")); + + // Upload same file again + var content = new ByteArrayContent(CreateClientZip("duplicate")); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiForkSuccesses}/publish/file") + { + Content = content + }; + request.Headers.Add("Robust-Cdn-Publish-File", "test.zip"); + request.Headers.Add("Robust-Cdn-Publish-Version", version); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + #endregion + + #region Helpers + + private async Task UploadMultiPublishFile(HttpClient client, string version, string fileName, string forkName, byte[] fileContent) + { + var content = new ByteArrayContent(fileContent); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{forkName}/publish/file") + { + Content = content + }; + request.Headers.Add("Robust-Cdn-Publish-File", fileName); + request.Headers.Add("Robust-Cdn-Publish-Version", version); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + } + + private static byte[] CreateClientZip(string content) + { + using var memStream = new MemoryStream(); + using (var zip = new ZipArchive(memStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("data.txt"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } + return memStream.ToArray(); + } + + private static byte[] CreateServerZip(string platform) + { + using var memStream = new MemoryStream(); + using (var zip = new ZipArchive(memStream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = zip.CreateEntry("Robust.Server.dll"); + using var writer = new StreamWriter(entry.Open()); + writer.Write($"fake server binary for {platform}"); + } + return memStream.ToArray(); + } + + #endregion +} From 4862ed62af18175e84ce57fa7a83798d1708af2c Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 23:20:41 +0300 Subject: [PATCH 14/19] refactor: ForkUpdateController tests --- .../Controllers/ForkUpdateControllerTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 Robust.Cdn.Tests/Controllers/ForkUpdateControllerTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkUpdateControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkUpdateControllerTests.cs new file mode 100644 index 0000000..b91696c --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkUpdateControllerTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit.Abstractions; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for UpdateController.PostControlUpdate. +/// +/// Endpoint: POST /fork/{fork}/control/update +/// +[Trait("Category", "IntegrationTest")] +public sealed class ForkUpdateControllerTests( + WebApplicationFactory factory, + DatabaseFixture database, + ITestOutputHelper testOutput) + : TestBase(factory, database, testOutput) +{ + private const string UpdateFork = "testfork-update"; + private const string Token = "s3cret"; + + protected override string ForkName => UpdateFork; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + [$"Manifest:Forks:{UpdateFork}:UpdateToken"] = Token, + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(UpdateFork, "1.0.0"), + ["BaseUrl"] = "http://localhost/", + }; + } + + [Fact] + public async Task PostControlUpdate_NoAuthHeader_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var response = await client.PostAsync($"/fork/{UpdateFork}/control/update", null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostControlUpdate_WrongToken_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{UpdateFork}/control/update"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "wrong-token"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostControlUpdate_ForkNotConfigured_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, "/fork/nonexistent/control/update"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + // Sadly it is too much of pain to actually test if job was triggered and completed properly. + // Much easier to just test job logic separately. + [Fact] + public async Task PostControlUpdate_Success_ReturnsAccepted() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{UpdateFork}/control/update"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + } +} From a64a3b51251f217169aff98268b4c1be3500a1e7 Mon Sep 17 00:00:00 2001 From: Fildrance Date: Mon, 20 Jul 2026 23:48:56 +0300 Subject: [PATCH 15/19] refactor: build razor page tests --- .../ForkBuildPageControllerTests.cs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 Robust.Cdn.Tests/Controllers/ForkBuildPageControllerTests.cs diff --git a/Robust.Cdn.Tests/Controllers/ForkBuildPageControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkBuildPageControllerTests.cs new file mode 100644 index 0000000..86ee1ed --- /dev/null +++ b/Robust.Cdn.Tests/Controllers/ForkBuildPageControllerTests.cs @@ -0,0 +1,198 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using Xunit.Abstractions; + +namespace Robust.Cdn.Tests.Controllers; + +/// +/// Integration tests for ForkBuildPageController.Index. +/// +/// Endpoint: GET /fork/{fork} +/// +[Trait("Category", "IntegrationTest")] +public sealed class ForkBuildPageControllerTests( + WebApplicationFactory factory, + DatabaseFixture database, + ITestOutputHelper testOutput) + : TestBase(factory, database, testOutput) +{ + private readonly string? _buildsPageLink = "https://example-ss14-build.com"; + private const string PublicFork = "testfork-buildpage-public"; + private const string PrivateFork = "testfork-buildpage-private"; + private const string Token = "s3cret"; + private const string ValidUser = "admin"; + private const string ValidPassword = "hunter2"; + + protected override string ForkName => PublicFork; + + protected override Dictionary GetConfigurationOverrides() + { + return new() + { + [$"Manifest:Forks:{PublicFork}:DisplayName"] = "Public Builds", + [$"Manifest:Forks:{PublicFork}:BuildsPageLink"] = _buildsPageLink, + [$"Manifest:Forks:{PublicFork}:BuildsPageLinkText"] = "example.com", + [$"Manifest:Forks:{PublicFork}:UpdateToken"] = Token, + [$"Manifest:Forks:{PrivateFork}:Private"] = "true", + [$"Manifest:Forks:{PrivateFork}:PrivateUsers:{ValidUser}"] = ValidPassword, + [$"Manifest:Forks:{PrivateFork}:UpdateToken"] = Token, + ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(PublicFork, "0.0.1"), + ["BaseUrl"] = "http://localhost/", + }; + } + + [Fact] + public async Task Index_ForkNotConfigured_ReturnsNotFound() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync("/fork/nonexistent"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Index_PrivateForkNoAuth_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{PrivateFork}"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("Basic", response.Headers.WwwAuthenticate.ToString()); + } + + [Fact] + public async Task Index_PrivateForkWrongPassword_ReturnsUnauthorized() + { + var client = Factory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, $"/fork/{PrivateFork}"); + request.Headers.Authorization = BasicAuthHeader(ValidUser, "wrongpassword"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Index_PrivateForkValidAuth_ReturnsHtml() + { + var client = Factory.CreateClient(); + + // Publish a build first so there's data to show + await SetupPublishedBuild(client, "2.0.0"); + + var request = new HttpRequestMessage(HttpMethod.Get, $"/fork/{PrivateFork}"); + request.Headers.Authorization = BasicAuthHeader(ValidUser, ValidPassword); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType?.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("testfork-buildpage-private builds", body); + } + + [Fact] + public async Task Index_PublicFork_ReturnsHtml() + { + var client = Factory.CreateClient(); + + // Publish a build first so there's data to show + await SetupPublishedBuild(client, "2.0.0"); + + var response = await client.GetAsync($"/fork/{PublicFork}"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/html; charset=utf-8", response.Content.Headers.ContentType?.ToString()); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("

Public Builds builds

", body); + } + + [Fact] + public async Task Index_ZeroBuilds_ShowsNoBuildsYetMessage() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{PublicFork}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("There are no server builds yet", body); + } + + [Fact] + public async Task Index_OneBuild_ShowsSingleBuild() + { + var client = Factory.CreateClient(); + await SetupPublishedBuild(client, "3.0.0"); + + var response = await PollUntilCondition( + () => client.GetAsync($"/fork/{PublicFork}"), + async r => + { + if (r.StatusCode != HttpStatusCode.OK) + return false; + var body = await r.Content.ReadAsStringAsync(); + return body.Contains("Latest build"); + }); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Latest build", body); + Assert.Contains("3.0.0", body); + Assert.Matches(@"

Old builds<\/h2>\s*<\/main>", body); + } + + [Fact] + public async Task Index_MultipleBuilds_ShowsLatestAndOldBuildsSections() + { + var client = Factory.CreateClient(); + + // Publish 2+ builds + await SetupPublishedBuild(client, "4.0.0", basePort: 18770); + await SetupPublishedBuild(client, "4.1.0", basePort: 18771); + + var response = await PollUntilCondition( + () => client.GetAsync($"/fork/{PublicFork}"), + async r => + { + if (r.StatusCode != HttpStatusCode.OK) + return false; + var body = await r.Content.ReadAsStringAsync(); + return body.Contains("4.1.0"); + }); + + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("Latest build", body); + Assert.Matches(@"

Latest build<\/h2>[\s\S]*?4.1.0<\/span>[\s\S]*?

Old builds<\/h2>", body); + Assert.Contains("Old builds", body); + Assert.Matches(@"

Old builds<\/h2>[\s\S]*?4.0.0<\/span>", body); + } + + [Fact] + public async Task Index_HasBuildsPageLink_ShowsLinkWithConfiguredText() + { + var client = Factory.CreateClient(); + var response = await client.GetAsync($"/fork/{PublicFork}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains($"href=\"{_buildsPageLink}\"", body); + } + + #region Helpers + + private async Task SetupPublishedBuild(HttpClient client, string? version = null, int basePort = 18765) + { + var archivePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + CreatePublishArchive(archivePath, clientZipName: "test", content: "hello from build page"); + await PublishOneShotRelease(client, archivePath, Token, version: version, basePort: basePort); + } + + private static AuthenticationHeaderValue BasicAuthHeader(string user, string password) + { + var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{password}")); + return new AuthenticationHeaderValue("Basic", credentials); + } + + #endregion +} From 40b9774b2c71778da7fda75d9713579af08bfcfd Mon Sep 17 00:00:00 2001 From: Fildrance Date: Tue, 21 Jul 2026 00:02:30 +0300 Subject: [PATCH 16/19] refactor: restart publish test --- .../ForkPublishControllerMultiPublishTests.cs | 81 ++++++++++++++++--- Robust.Cdn.Tests/XUnitLoggerProvider.cs | 10 ++- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs index c202ef2..9028737 100644 --- a/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs @@ -15,8 +15,9 @@ namespace Robust.Cdn.Tests.Controllers; [Trait("Category", "IntegrationTest")] public sealed class ForkPublishControllerMultiPublishTests( WebApplicationFactory factory, - DatabaseFixture database, ITestOutputHelper testOutput) - : TestBase(factory, database , testOutput) + DatabaseFixture database, + ITestOutputHelper testOutput +) : TestBase(factory, database , testOutput) { private const string MultiForkSuccesses= "testfork-multi-successes"; private const string MultiFork = "testfork-multi-failures"; @@ -144,9 +145,60 @@ public async Task MultiPublishStart_ForkNotConfigured_ReturnsNotFound() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - #endregion + [Fact] + public async Task MultiPublishStart_VersionAlreadyInProgress_AbortsAndRestarts() + { + var client = Factory.CreateClient(); + + const string version = "200.0.0"; + + var start1Req = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/start") + { + Content = JsonContent.Create(new { Version = version, EngineVersion = "0.1.2" }) + }; + start1Req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var start1Resp = await client.SendAsync(start1Req); + Assert.Equal(HttpStatusCode.NoContent, start1Resp.StatusCode); + + await UploadMultiPublishFile(client, version, "SS14.Server_linux-x64.zip", MultiFork, CreateServerZip("first attempt")); + + var start2Req = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/start") + { + Content = JsonContent.Create(new { Version = version, EngineVersion = "0.1.3" }) + }; + start2Req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var start2Resp = await client.SendAsync(start2Req); + Assert.Equal(HttpStatusCode.NoContent, start2Resp.StatusCode); + + const string expected = "restarted"; + var clientZipData = CreateClientZip(expected); + await UploadMultiPublishFile(client, version, "test.zip", MultiFork, clientZipData); + await UploadMultiPublishFile(client, version, "SS14.Server_linux-x64.zip", MultiFork, CreateServerZip(expected)); + + var finishRequest = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/finish") + { + Content = JsonContent.Create(new { Version = version }) + }; + finishRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var finishResponse = await client.SendAsync(finishRequest); + Assert.Equal(HttpStatusCode.NoContent, finishResponse.StatusCode); + + await PollUntilCondition( + () => client.GetAsync($"/fork/{MultiFork}/manifest"), + async r => r.StatusCode == HttpStatusCode.OK && (await r.Content.ReadAsStringAsync()).Contains(version), + maxAttempts: 30 + ); - #region File — Validation + var getResponse = await client.GetAsync($"/fork/{MultiFork}/version/{version}/file/test.zip"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + + var actualContent = await ReadFileContent(getResponse); + Assert.Equal(expected, actualContent); + + } [Fact] public async Task MultiPublishFile_NoVersionHeader_ReturnsBadRequest() @@ -168,7 +220,7 @@ public async Task MultiPublishFile_NoVersionHeader_ReturnsBadRequest() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } - [Fact] + [Fact] public async Task MultiPublishFile_InvalidFileName_ReturnsBadRequest() { var client = Factory.CreateClient(); @@ -258,7 +310,7 @@ await PollUntilCondition( Assert.Equal("hello from multi-publish", actualContent); } - [Fact] + [Fact] public async Task MultiPublishFinish_NoClientZip_ReturnsUnprocessableEntity() { var client = Factory.CreateClient(); @@ -303,7 +355,7 @@ public async Task MultiPublishFinish_VersionNotStarted_ReturnsNotFound() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - [Fact] + [Fact] public async Task MultiPublishFile_DuplicateFile_ReturnsConflict() { var client = Factory.CreateClient(); @@ -341,13 +393,13 @@ public async Task MultiPublishFile_DuplicateFile_ReturnsConflict() #region Helpers - private async Task UploadMultiPublishFile(HttpClient client, string version, string fileName, string forkName, byte[] fileContent) + private async Task UploadMultiPublishFile(HttpClient client, string version, string fileName, string forkName, byte[] fileContent) { var content = new ByteArrayContent(fileContent); content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{forkName}/publish/file") - { + { Content = content }; request.Headers.Add("Robust-Cdn-Publish-File", fileName); @@ -382,5 +434,16 @@ private static byte[] CreateServerZip(string platform) return memStream.ToArray(); } + private static async Task ReadFileContent(HttpResponseMessage getResponse) + { + var data = await getResponse.Content.ReadAsByteArrayAsync(); + using var zipStream = new MemoryStream(data); + using var zip = new ZipArchive(zipStream, ZipArchiveMode.Read); + var entry = zip.GetEntry("data.txt"); + Assert.NotNull(entry); + using var reader = new StreamReader(entry.Open()); + return await reader.ReadToEndAsync(); + } + #endregion } diff --git a/Robust.Cdn.Tests/XUnitLoggerProvider.cs b/Robust.Cdn.Tests/XUnitLoggerProvider.cs index bbb202c..c62f774 100644 --- a/Robust.Cdn.Tests/XUnitLoggerProvider.cs +++ b/Robust.Cdn.Tests/XUnitLoggerProvider.cs @@ -27,9 +27,13 @@ public void Log( return; var message = formatter(state, exception); - testOutput.WriteLine($"[{logLevel}] [{categoryName}] {message}"); - if (exception != null) - testOutput.WriteLine($" Exception: {exception}"); + try + { + testOutput.WriteLine($"[{logLevel}] [{categoryName}] {message}"); + if (exception != null) + testOutput.WriteLine($" Exception: {exception}"); + } + catch { /* ignore */ } } } } From 5fc6fb1679b1867795edce81a582d68dbaddb54b Mon Sep 17 00:00:00 2001 From: Fildrance Date: Tue, 21 Jul 2026 00:42:33 +0300 Subject: [PATCH 17/19] refactor: POST Download with empty request body test --- .../ForkDownloadControllerTests.cs | 30 +++++++++++++++++-- .../ForkPublishControllerMultiPublishTests.cs | 22 +++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs index 30f689d..3ffd699 100644 --- a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs @@ -1,5 +1,7 @@ +using System.Buffers.Binary; using Microsoft.AspNetCore.Mvc.Testing; using System.Net; +using System.Net.Http.Headers; using Xunit.Abstractions; namespace Robust.Cdn.Tests.Controllers; @@ -13,8 +15,8 @@ namespace Robust.Cdn.Tests.Controllers; /// POST /fork/{fork}/version/{version}/download /// public sealed class ForkDownloadControllerTests( - WebApplicationFactory factory, - DatabaseFixture database, + WebApplicationFactory factory, + DatabaseFixture database, ITestOutputHelper testOutput ) : DownloadControllerTestBase(factory, database, testOutput) { @@ -40,5 +42,29 @@ public async Task GetManifest_NonExistentForkOrVersion_ReturnsNotFound(string fo var response = await client.GetAsync($"/fork/{fork}/version/{version}/manifest"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + + [Fact] + public async Task DownloadPost_EmptyRequestBody_ReturnsStreamHeaderOnly() + { + var client = Factory.CreateClient(); + var content = new ByteArrayContent([]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var responseBody = await response.Content.ReadAsByteArrayAsync(); + // Just the 4-byte stream header, no files + Assert.Equal(4, responseBody.Length); + + // Stream header flags = 0 (no PreCompressed) + Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4))); + } } diff --git a/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs index 9028737..73bd880 100644 --- a/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs @@ -15,7 +15,7 @@ namespace Robust.Cdn.Tests.Controllers; [Trait("Category", "IntegrationTest")] public sealed class ForkPublishControllerMultiPublishTests( WebApplicationFactory factory, - DatabaseFixture database, + DatabaseFixture database, ITestOutputHelper testOutput ) : TestBase(factory, database , testOutput) { @@ -220,6 +220,26 @@ public async Task MultiPublishFile_NoVersionHeader_ReturnsBadRequest() Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + [Fact] + public async Task MultiPublishFile_NoInProgressPublish_ReturnsNotFound() + { + var client = Factory.CreateClient(); + + var content = new ByteArrayContent([]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/fork/{MultiFork}/publish/file") + { + Content = content + }; + request.Headers.Add("Robust-Cdn-Publish-File", "test.zip"); + request.Headers.Add("Robust-Cdn-Publish-Version", "999.0.0"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token); + + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + [Fact] public async Task MultiPublishFile_InvalidFileName_ReturnsBadRequest() { From 3975712feddd3a90613a5e079d87e4b37954eeff Mon Sep 17 00:00:00 2001 From: Fildrance Date: Tue, 21 Jul 2026 12:37:06 +0300 Subject: [PATCH 18/19] refactor: fix DownloadPost_EmptyRequestBody_ReturnsStreamHeaderOnly --- .../ForkDownloadControllerTests.cs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs index 3ffd699..dce0309 100644 --- a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs +++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs @@ -47,24 +47,26 @@ public async Task GetManifest_NonExistentForkOrVersion_ReturnsNotFound(string fo public async Task DownloadPost_EmptyRequestBody_ReturnsStreamHeaderOnly() { var client = Factory.CreateClient(); - var content = new ByteArrayContent([]); - content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + var response = await PollUntilOk(() => { - Content = content - }; - request.Headers.Add("X-Robust-Download-Protocol", "1"); + var content = new ByteArrayContent([]); + content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - var response = await client.SendAsync(request); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var request = new HttpRequestMessage(HttpMethod.Post, $"{RoutePrefix}/{ExistingVersion}/download") + { + Content = content + }; + request.Headers.Add("X-Robust-Download-Protocol", "1"); + return client.SendAsync(request); + }); var responseBody = await response.Content.ReadAsByteArrayAsync(); // Just the 4-byte stream header, no files Assert.Equal(4, responseBody.Length); - // Stream header flags = 0 (no PreCompressed) - Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4))); + // Stream header flags = 1 (PreCompressed by default) + Assert.Equal(1, BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4))); } } From 0c5879e8ce1e823f0ec9c21f180dbc23c60a4d2d Mon Sep 17 00:00:00 2001 From: Fildrance Date: Tue, 21 Jul 2026 12:42:34 +0300 Subject: [PATCH 19/19] refactor: revert debug changes --- Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs b/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs index f179a51..796970c 100644 --- a/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs +++ b/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs @@ -46,7 +46,7 @@ FROM PublishInProgress if (utcStartTime >= deleteBefore) continue; - logger.LogInformation("Deleting timed out publish for fork {Fork} version {Version}, {startTime}, {deleteBefore}", forkName, name, utcStartTime, deleteBefore); + logger.LogInformation("Deleting timed out publish for fork {Fork} version {Version}", forkName, name); publishManager.AbortMultiPublish(forkName, name, tx, commit: false);