diff --git a/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs b/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs index f94f678..51f5871 100644 --- a/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs +++ b/csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs @@ -128,6 +128,7 @@ private static async Task> DeleteRepo( private static Results BackupRepo( string code, + HttpContext httpContext, IRepoManageService repos, CancellationToken cancellationToken) { @@ -135,6 +136,11 @@ private static Results Backup var executor = repos.BackupRepo(projectCode); if (executor is null) return TypedResults.NotFound(); + // Even ZipFile.CreateFromDirectoryAsync flushes each entry's DeflateStream synchronously when it's disposed, + // which Kestrel rejects by default. The bulk of the zip is still written asynchronously. + var bodyControl = httpContext.Features.Get(); + if (bodyControl is not null) bodyControl.AllowSynchronousIO = true; + return TypedResults.Stream( stream => executor.ExecuteBackup(stream, cancellationToken), contentType: "application/zip", diff --git a/csharp/src/HgResume.Api/Manage/RepoManageService.cs b/csharp/src/HgResume.Api/Manage/RepoManageService.cs index 36a48d5..157de66 100644 --- a/csharp/src/HgResume.Api/Manage/RepoManageService.cs +++ b/csharp/src/HgResume.Api/Manage/RepoManageService.cs @@ -79,7 +79,7 @@ public async Task CopyRepo(ProjectCode sourceCode, ProjectCode destCode, Cancell var repoDirectory = new DirectoryInfo(PrefixRepoFilePath(destCode)); if (repoDirectory.Exists && RepoHasCommits(repoDirectory.FullName)) { - throw new InvalidOperationException($"Destination repo {destCode} already exists and is not empty"); + throw new AlreadyExistsException($"Destination repo {destCode} already exists and is not empty"); } await Task.Run(() => @@ -110,10 +110,9 @@ await Task.Run(() => return null; } - return new((stream, token) => Task.Run(() => - { - ZipFile.CreateFromDirectory(repoPath, stream, CompressionLevel.Fastest, includeBaseDirectory: false); - }, token)); + // The endpoint streams into Kestrel's response body, so keep the writes async (see ManageRepoEndpoints.BackupRepo) + return new((stream, token) => + ZipFile.CreateFromDirectoryAsync(repoPath, stream, CompressionLevel.Fastest, includeBaseDirectory: false, token)); } public async Task ResetRepo(ProjectCode code, CancellationToken cancellationToken = default) @@ -216,7 +215,7 @@ public async Task CleanupResetBackups(bool dryRun = false, Cancellatio public static partial Regex ResetProjectsRegex(); /// - /// Deletes all files and folders in the repo folder except for .hg. + /// Deletes all files and folders in the repo folder except for .hg, and drops .hg/hgrc. /// private static async Task CleanupRepoFolder(DirectoryInfo repoDir, CancellationToken cancellationToken) { @@ -224,7 +223,13 @@ await Task.Run(() => { foreach (var info in repoDir.EnumerateFileSystemInfos()) { - if (info.Name == ".hg") continue; + if (info.Name == ".hg") + { + // Mercurial would trust an uploaded hgrc (hooks, extensions) on the server, and nothing here needs one. + if (info is DirectoryInfo hg) + foreach (var hgrc in hg.EnumerateFiles("hgrc")) hgrc.Delete(); + continue; + } if (info is DirectoryInfo dir) dir.Delete(true); else info.Delete(); } diff --git a/csharp/src/HgResume.Api/Middleware/ApiExceptionHandler.cs b/csharp/src/HgResume.Api/Middleware/ApiExceptionHandler.cs index 0a4af98..2949d95 100644 --- a/csharp/src/HgResume.Api/Middleware/ApiExceptionHandler.cs +++ b/csharp/src/HgResume.Api/Middleware/ApiExceptionHandler.cs @@ -13,7 +13,6 @@ public async ValueTask TryHandleAsync( var (statusCode, title, detail) = exception switch { AlreadyExistsException => (StatusCodes.Status409Conflict, "Conflict", exception.Message), - InvalidOperationException => (StatusCodes.Status409Conflict, "Conflict", exception.Message), DirectoryNotFoundException => (StatusCodes.Status404NotFound, "Not Found", exception.Message), ProjectResetException reset => (StatusCodes.Status400BadRequest, reset.ErrorCode, reset.Message), ArgumentException => (StatusCodes.Status400BadRequest, "Bad Request", exception.Message), diff --git a/csharp/test/HgResume.Api.Tests/HgResume.Api.Tests.csproj b/csharp/test/HgResume.Api.Tests/HgResume.Api.Tests.csproj index 1bbcb3f..749dcd5 100644 --- a/csharp/test/HgResume.Api.Tests/HgResume.Api.Tests.csproj +++ b/csharp/test/HgResume.Api.Tests/HgResume.Api.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/csharp/test/HgResume.Api.Tests/ManageEndpointTests.cs b/csharp/test/HgResume.Api.Tests/ManageEndpointTests.cs new file mode 100644 index 0000000..d6aaf4a --- /dev/null +++ b/csharp/test/HgResume.Api.Tests/ManageEndpointTests.cs @@ -0,0 +1,60 @@ +using System.IO.Compression; +using System.Net; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace HgResume.Api.Tests; + +/// +/// Manage endpoints through the real ASP.NET Core pipeline. Like Kestrel, TestServer rejects synchronous +/// writes to the response body, which service-level tests writing into a MemoryStream can't catch. +/// +public sealed class ManageEndpointTests : IDisposable +{ + private const string Secret = "endpoint-test-secret"; + + private readonly string _basePath = Path.Join(Path.GetTempPath(), "ManageEndpointTests-" + Guid.NewGuid().ToString("N")); + private readonly WebApplicationFactory _factory; + private readonly HttpClient _client; + + public ManageEndpointTests() + { + // Program reads its config from the environment before the host is built + Environment.SetEnvironmentVariable("HGRESUME_REPO_PATHS", Path.Join(_basePath, "repos")); + Environment.SetEnvironmentVariable("HGRESUME_CACHE_PATH", Path.Join(_basePath, "cache")); + Environment.SetEnvironmentVariable("HGRESUME_MANAGE_SECRET", Secret); + _factory = new WebApplicationFactory(); + _client = _factory.CreateClient(); + _client.DefaultRequestHeaders.Add("X-Manage-Secret", Secret); + } + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + if (Directory.Exists(_basePath)) Directory.Delete(_basePath, true); + } + + [Fact] + public async Task BackupRepo_StreamsAZipOfTheRepo() + { + using (var init = await _client.PostAsync("/api/manage/repos/backup-test", null)) + { + Assert.Equal(HttpStatusCode.Created, init.StatusCode); + } + + using var response = await _client.GetAsync("/api/manage/repos/backup-test/backup"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/zip", response.Content.Headers.ContentType?.MediaType); + using var archive = new ZipArchive(new MemoryStream(await response.Content.ReadAsByteArrayAsync()), ZipArchiveMode.Read); + Assert.Contains(archive.Entries, e => e.FullName.Replace('\\', '/') == ".hg/requires"); + } + + [Fact] + public async Task BackupRepo_MissingRepoIsNotFound() + { + using var response = await _client.GetAsync("/api/manage/repos/no-such-repo/backup"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/csharp/test/HgResume.Api.Tests/RepoManageServiceTests.cs b/csharp/test/HgResume.Api.Tests/RepoManageServiceTests.cs index 3a41385..c6bf11b 100644 --- a/csharp/test/HgResume.Api.Tests/RepoManageServiceTests.cs +++ b/csharp/test/HgResume.Api.Tests/RepoManageServiceTests.cs @@ -89,6 +89,28 @@ public async Task CanFinishResetByUnZippingAnArchive(string filePath) Assert.Equal(Path.Join(".hg", "important-file.bin"), files[0]); } + [Theory] + [InlineData("")] + [InlineData("unzip-test/")] + public async Task FinishResetDropsUploadedHgrc(string prefix) + { + var code = new ProjectCode("unzip-test"); + await _svc.InitRepo(code); + + using var stream = new MemoryStream(); + using (var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, true)) + { + CreateSimpleEntry(zipArchive, prefix + ".hg/important-file.bin"); + CreateSimpleEntry(zipArchive, prefix + ".hg/hgrc"); + } + stream.Position = 0; + await _svc.FinishReset(code, stream); + + var hgPath = Path.Combine(RepoPath("unzip-test"), ".hg"); + Assert.True(File.Exists(Path.Combine(hgPath, "important-file.bin"))); + Assert.False(File.Exists(Path.Combine(hgPath, "hgrc"))); + } + [Fact] public async Task ThrowsIfNoHgFolderIsFound() { @@ -123,7 +145,7 @@ public async Task CopyRepo_ThrowsWhenDestinationHasChangelog() Directory.CreateDirectory(Path.Combine(RepoPath("dest-repo"), ".hg", "store")); File.WriteAllText(Path.Combine(RepoPath("dest-repo"), ".hg", "store", "00changelog.i"), "not-empty"); - await Assert.ThrowsAsync(() => _svc.CopyRepo("source-repo", "dest-repo")); + await Assert.ThrowsAsync(() => _svc.CopyRepo("source-repo", "dest-repo")); } [Fact] diff --git a/csharp/test/HgResume.IntegrationTests/ManageFacts.cs b/csharp/test/HgResume.IntegrationTests/ManageFacts.cs new file mode 100644 index 0000000..6a310de --- /dev/null +++ b/csharp/test/HgResume.IntegrationTests/ManageFacts.cs @@ -0,0 +1,26 @@ +using Xunit; + +namespace HgResume.IntegrationTests; + +/// /api/manage endpoints against the real container, i.e. behind Kestrel. +[Collection("server")] +public sealed class ManageFacts +{ + private readonly ServerFixture _fx; + + public ManageFacts(ServerFixture fx) => _fx = fx; + + [Fact] + public async Task Backup_RestoresTheSameRepo() + { + var source = _fx.SeedRepo("sample-hg-repo.zip", "backup-source"); + _fx.RemoveRepo("backup-restored"); + + var backup = _fx.DownloadBackup(source); + _fx.FinishReset("backup-restored", backup); + + var restoredRevisions = await _fx.GetServerRevisions("backup-restored"); + Assert.NotEqual("", await _fx.GetServerTip(source)); + Assert.Equal(await _fx.GetServerRevisions(source), restoredRevisions); + } +} diff --git a/csharp/test/HgResume.IntegrationTests/ServerFixture.cs b/csharp/test/HgResume.IntegrationTests/ServerFixture.cs index 14ac414..7759813 100644 --- a/csharp/test/HgResume.IntegrationTests/ServerFixture.cs +++ b/csharp/test/HgResume.IntegrationTests/ServerFixture.cs @@ -149,14 +149,31 @@ public string SeedRepo(string zipName, string? repoId = null) return repoId; } - using var content = new ByteArrayContent(File.ReadAllBytes(localZip)); - using var resp = ManageHttp.PostAsync($"/api/manage/repos/{repoId}/finish-reset", content) + FinishReset(repoId, File.ReadAllBytes(localZip)); + return repoId; + } + + /// Replaces the repo with the contents of a zip, via POST /api/manage/repos/{code}/finish-reset. + public void FinishReset(string code, byte[] zip) + { + using var content = new ByteArrayContent(zip); + using var resp = ManageHttp.PostAsync($"/api/manage/repos/{code}/finish-reset", content) .GetAwaiter().GetResult(); if (!resp.IsSuccessStatusCode) { - throw new Exception($"finish-reset for {repoId} failed: {(int)resp.StatusCode} {resp.ReasonPhrase}"); + throw new Exception($"finish-reset for {code} failed: {(int)resp.StatusCode} {resp.ReasonPhrase}"); } - return repoId; + } + + /// Downloads the repo as a zip, via GET /api/manage/repos/{code}/backup. + public byte[] DownloadBackup(string code) + { + using var resp = ManageHttp.GetAsync($"/api/manage/repos/{code}/backup").GetAwaiter().GetResult(); + if (!resp.IsSuccessStatusCode) + { + throw new Exception($"backup of {code} failed: {(int)resp.StatusCode} {resp.ReasonPhrase}"); + } + return resp.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult(); } private void SeedRepoViaFilesystem(string localZip, string repoId)