Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions csharp/src/HgResume.Api/Manage/ManageRepoEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,19 @@ private static async Task<Results<NoContent, ValidationProblem>> DeleteRepo(

private static Results<PushStreamHttpResult, NotFound, ValidationProblem> BackupRepo(
string code,
HttpContext httpContext,
IRepoManageService repos,
CancellationToken cancellationToken)
{
if (!TryCode(code, out var projectCode, out var error)) return error;
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<IHttpBodyControlFeature>();
if (bodyControl is not null) bodyControl.AllowSynchronousIO = true;

return TypedResults.Stream(
stream => executor.ExecuteBackup(stream, cancellationToken),
contentType: "application/zip",
Expand Down
19 changes: 12 additions & 7 deletions csharp/src/HgResume.Api/Manage/RepoManageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -216,15 +215,21 @@ public async Task<string[]> CleanupResetBackups(bool dryRun = false, Cancellatio
public static partial Regex ResetProjectsRegex();

/// <summary>
/// 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.
/// </summary>
private static async Task CleanupRepoFolder(DirectoryInfo repoDir, CancellationToken cancellationToken)
{
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();
}
Expand Down
1 change: 0 additions & 1 deletion csharp/src/HgResume.Api/Middleware/ApiExceptionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ public async ValueTask<bool> 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),
Expand Down
1 change: 1 addition & 0 deletions csharp/test/HgResume.Api.Tests/HgResume.Api.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
Expand Down
60 changes: 60 additions & 0 deletions csharp/test/HgResume.Api.Tests/ManageEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System.IO.Compression;
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

namespace HgResume.Api.Tests;

/// <summary>
/// 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.
/// </summary>
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<Program> _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<Program>();
_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);
}
}
24 changes: 23 additions & 1 deletion csharp/test/HgResume.Api.Tests/RepoManageServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<InvalidOperationException>(() => _svc.CopyRepo("source-repo", "dest-repo"));
await Assert.ThrowsAsync<AlreadyExistsException>(() => _svc.CopyRepo("source-repo", "dest-repo"));
}

[Fact]
Expand Down
26 changes: 26 additions & 0 deletions csharp/test/HgResume.IntegrationTests/ManageFacts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Xunit;

namespace HgResume.IntegrationTests;

/// <summary>/api/manage endpoints against the real container, i.e. behind Kestrel.</summary>
[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);
}
}
25 changes: 21 additions & 4 deletions csharp/test/HgResume.IntegrationTests/ServerFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>Replaces the repo with the contents of a zip, via POST /api/manage/repos/{code}/finish-reset.</summary>
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;
}

/// <summary>Downloads the repo as a zip, via GET /api/manage/repos/{code}/backup.</summary>
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)
Expand Down
Loading