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/.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/DownloadCompatibilityControllerTests.cs b/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs
new file mode 100644
index 0000000..27dc7d2
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/DownloadCompatibilityControllerTests.cs
@@ -0,0 +1,96 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using System.Net;
+using System.Net.Http.Headers;
+using Xunit.Abstractions;
+
+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,
+ ITestOutputHelper testOutput
+) : DownloadControllerTestBase(factory, database, testOutput)
+{
+ 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..cb6a7eb
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/DownloadControllerTestBase.cs
@@ -0,0 +1,286 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Http.Headers;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Robust.Cdn.Config;
+using SharpZstd;
+using Xunit.Abstractions;
+
+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,
+ ITestOutputHelper testOutput
+) : TestBase(factory, database, testOutput)
+{
+ 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/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
+}
diff --git a/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs
new file mode 100644
index 0000000..20f64a8
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerMultiFileTests.cs
@@ -0,0 +1,321 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Http.Headers;
+using Xunit.Abstractions;
+
+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,
+ ITestOutputHelper testOutput
+) : TestBase(factory, database, testOutput)
+{
+ 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
new file mode 100644
index 0000000..dce0309
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/ForkDownloadControllerTests.cs
@@ -0,0 +1,72 @@
+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;
+
+///
+/// Integration tests for DownloadController via the standard /fork/{fork}/version/{version}/... route.
+///
+/// Endpoints:
+/// GET /fork/{fork}/version/{version}/manifest
+/// OPTIONS /fork/{fork}/version/{version}/download
+/// POST /fork/{fork}/version/{version}/download
+///
+public sealed class ForkDownloadControllerTests(
+ WebApplicationFactory factory,
+ DatabaseFixture database,
+ ITestOutputHelper testOutput
+) : DownloadControllerTestBase(factory, database, testOutput)
+{
+ protected override string ForkName => "testfork2";
+
+ protected override string RoutePrefix => $"/fork/{ForkName}/version";
+
+ protected override Dictionary GetConfigurationOverrides()
+ {
+ return new()
+ {
+ ["Manifest:FileDiskPath"] = Database.CreateTestVersionOnDisk(ForkName, ExistingVersion, content: FileContent),
+ };
+ }
+
+ [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 DownloadPost_EmptyRequestBody_ReturnsStreamHeaderOnly()
+ {
+ var client = Factory.CreateClient();
+
+ var response = await PollUntilOk(() =>
+ {
+ 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");
+ 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 = 1 (PreCompressed by default)
+ Assert.Equal(1, BinaryPrimitives.ReadInt32LittleEndian(responseBody.AsSpan(0, 4)));
+ }
+}
+
diff --git a/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs
new file mode 100644
index 0000000..e7e13e8
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerPrivateForkTests.cs
@@ -0,0 +1,156 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using Xunit.Abstractions;
+
+namespace Robust.Cdn.Tests.Controllers;
+
+[Trait("Category", "IntegrationTest")]
+public sealed class ForkManifestControllerPrivateForkTests(
+ WebApplicationFactory factory,
+ DatabaseFixture database,
+ ITestOutputHelper testOutput
+) : TestBase(factory, database, testOutput)
+{
+ 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
new file mode 100644
index 0000000..743faed
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/ForkManifestControllerTests.cs
@@ -0,0 +1,173 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using System.IO.Compression;
+using System.Net;
+using System.Text.Json;
+using Xunit.Abstractions;
+
+namespace Robust.Cdn.Tests.Controllers;
+
+[Trait("Category", "IntegrationTest")]
+public sealed class ForkManifestControllerTests(
+ WebApplicationFactory factory,
+ DatabaseFixture database,
+ ITestOutputHelper testOutput
+) : TestBase(factory, database, testOutput)
+{
+ 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);
+ }
+}
diff --git a/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs
new file mode 100644
index 0000000..73bd880
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/ForkPublishControllerMultiPublishTests.cs
@@ -0,0 +1,469 @@
+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);
+ }
+
+ [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
+ );
+
+ 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()
+ {
+ 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_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()
+ {
+ 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();
+ }
+
+ 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/Controllers/ForkPublishControllerOneShotTests.cs b/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs
new file mode 100644
index 0000000..afcdf74
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/ForkPublishControllerOneShotTests.cs
@@ -0,0 +1,258 @@
+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 ForkPublishController.PostPublish (one-shot publish).
+///
+/// Endpoint: POST /fork/{fork}/publish
+///
+[Trait("Category", "IntegrationTest")]
+public sealed class ForkPublishControllerOneShotTests(
+ WebApplicationFactory factory,
+ DatabaseFixture database,
+ ITestOutputHelper testOutput
+) : TestBase(factory, database, testOutput)
+{
+ 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/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);
+ }
+}
diff --git a/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs
new file mode 100644
index 0000000..00001b6
--- /dev/null
+++ b/Robust.Cdn.Tests/Controllers/StatusControllerTests.cs
@@ -0,0 +1,61 @@
+using System.Net;
+using System.Text.Json;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Xunit.Abstractions;
+
+namespace Robust.Cdn.Tests.Controllers;
+
+///
+/// Integration tests for StatusController.
+///
+/// Endpoint: GET /control/status
+///
+[Trait("Category", "Integration")]
+public sealed class StatusControllerTests(
+ WebApplicationFactory factory,
+ DatabaseFixture database,
+ ITestOutputHelper testOutput
+) : TestBase(factory, database, testOutput)
+{
+ protected override string ForkName => "testfork";
+
+ protected override Dictionary GetConfigurationOverrides()
+ {
+ var baseDir = Database.CreateTestVersionOnDisk(ForkName, "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 version info.
+ await PollUntilCondition(() => client.GetAsync("/control/status"), async response =>
+ {
+ 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/DatabaseFixture.cs b/Robust.Cdn.Tests/DatabaseFixture.cs
new file mode 100644
index 0000000..7bda2e8
--- /dev/null
+++ b/Robust.Cdn.Tests/DatabaseFixture.cs
@@ -0,0 +1,98 @@
+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? 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);
+ _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(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;
+ }
+
+ 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..8095c08
--- /dev/null
+++ b/Robust.Cdn.Tests/TestBase.cs
@@ -0,0 +1,251 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using SharpZstd;
+using System.IO.Compression;
+using System.Net;
+using System.Net.Http.Headers;
+using Xunit.Abstractions;
+
+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 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; }
+
+ protected abstract string ForkName { get; }
+
+ protected TestBase(WebApplicationFactory factory, DatabaseFixture database, ITestOutputHelper testOutput)
+ {
+ Database = database;
+
+ var config = new Dictionary(GetDefaultConfiguration());
+ foreach (var (key, value) in GetConfigurationOverrides())
+ {
+ config[key] = value;
+ }
+
+ ManifestDbPath = config["Manifest:DatabaseFileName"]!;
+
+ Factory = factory.WithWebHostBuilder(builder =>
+ {
+ builder.ConfigureAppConfiguration((_, configBuilder) =>
+ {
+ configBuilder.AddInMemoryCollection(config);
+ });
+
+ builder.ConfigureLogging(logging =>
+ {
+ logging.AddProvider(new XUnitLoggerProvider(testOutput));
+ });
+ });
+ }
+
+ ///
+ /// 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:{ForkName}:ClientZipName"] = "test",
+ [$"Manifest:Forks:{ForkName}: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();
+
+ ///
+ /// 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);
+ }
+
+ ///
+ /// 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();
+ }
+
+ /// 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();
+ }
+
+ ///
+ /// 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");
+ }
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ protected 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;
+ }
+ }
+}
+
diff --git a/Robust.Cdn.Tests/XUnitLoggerProvider.cs b/Robust.Cdn.Tests/XUnitLoggerProvider.cs
new file mode 100644
index 0000000..c62f774
--- /dev/null
+++ b/Robust.Cdn.Tests/XUnitLoggerProvider.cs
@@ -0,0 +1,39 @@
+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);
+ try
+ {
+ testOutput.WriteLine($"[{logLevel}] [{categoryName}] {message}");
+ if (exception != null)
+ testOutput.WriteLine($" Exception: {exception}");
+ }
+ catch { /* ignore */ }
+ }
+ }
+}
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/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);
diff --git a/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs b/Robust.Cdn/Jobs/DeleteTimedOutInProgressPublishesJob.cs
index 1183fa4..796970c 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,7 +42,8 @@ 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);
diff --git a/Robust.Cdn/Program.cs b/Robust.Cdn/Program.cs
index 53815b5..1b03d8a 100644
--- a/Robust.Cdn/Program.cs
+++ b/Robust.Cdn/Program.cs
@@ -14,59 +14,7 @@
// 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 =>
-{
- 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)));
-});
-
-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));
-});
-
-builder.Services.AddScoped();
-builder.Services.AddScoped();
-builder.Services.AddHttpContextAccessor();
-
-/*
-// 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();
@@ -83,7 +31,31 @@
app.Lifetime.ApplicationStopped.Register(SqliteConnection.ClearAllPools);
{
- using var initScope = app.Services.CreateScope();
+ if (!await TryInit(app.Services))
+ return 1;
+}
+/*
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+app.UseSwagger();
+app.UseSwaggerUI();
+}
+*/
+
+// app.UseHttpsRedirection();
+
+app.UseAuthorization();
+
+app.MapControllers();
+
+await app.RunAsync();
+
+return 0;
+
+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");
@@ -94,13 +66,13 @@
if (string.IsNullOrEmpty(manifestOptions.FileDiskPath))
{
loggerStartup.LogCritical("Manifest.FileDiskPath not set in configuration!");
- return 1;
+ return false;
}
if (manifestOptions.Forks.Count == 0)
{
loggerStartup.LogCritical("No forks defined in Manifest configuration!");
- return 1;
+ return false;
}
loggerStartup.LogDebug("Running migrations!");
@@ -109,7 +81,7 @@
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;
+ return false;
loggerStartup.LogDebug("Done running migrations!");
@@ -122,22 +94,66 @@
{
await scheduler.TriggerJob(IngestNewCdnContentJob.Key, IngestNewCdnContentJob.Data(fork));
}
+
+ return true;
}
-/*
-// Configure the HTTP request pipeline.
-if (app.Environment.IsDevelopment())
+
+static void SetupDependencies(IServiceCollection services, IConfiguration configuration)
{
- app.UseSwagger();
- app.UseSwaggerUI();
-}
-*/
+ 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)));
+ });
-// app.UseHttpsRedirection();
+ services.AddQuartzHostedService(q =>
+ {
+ q.WaitForJobsToComplete = true;
+ });
-app.UseAuthorization();
+ const string userAgent = "Robust.Cdn";
-app.MapControllers();
+ 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));
+ });
-await app.RunAsync();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddHttpContextAccessor();
-return 0;
+ /*
+ // 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 { }