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 { }