Skip to content

Commit

Permalink
Fix 404 during project reset test (#740)
Browse files Browse the repository at this point in the history
* Remove race-condition window in ResetProject

* Remove race-condition window in FinishReset

* Faster SendReceiveAfterProjectReset test

Now that we've removed the race condition in ResetProject, we no longer
need to wait for the hgweb refresh interval in the middle of the
SendReceiveAfterProjectReset tests. (We still do need to wait for it at
the start of the test, after project creation).

* Delete invalid reset-project zip file contents

If the zip file uploaded during the reset project step had no .hg
directory anywhere inside it, we want to delete it entirely before
presenting the error message to the user. Otherwise we might leave quite
large amounts of useless data lying around in the repos volume.

* Add project reset 404 integration test

* Proper temp folder, restore metadata after reset

* Fix primary-key error on FlexProjectMetadata

* Address review comments

* simplify moving folders around for reset, introduce helper method to get a temp repo path

* Use ShouldAllBe for better error messages

* Keep test repo zip in Git instead of downloading it

This saves seeral seconds of test setup time.

* Move TusDotNetClient reference to correct location

This allows the project to build when MercurialVersion is set to 6.

* write tests for the integration fixture

* Make user typeahead safer for Playwright tests

The user typeahead had two debounces, one in the input and one in the
typeahead store. The debounce in the input was causing Playwright tests
to fail because they were filling in the input and expecting the form to
be valid immediately, but it wasn't valid until the debounce triggered.
The debounce on the input now serves no purpose; removing it.

* Make email-handling more robust in Playwright tests

Many times the Playwright code gets to the email page before the email
has actually shown up. So we need to make it wait for up to 10 seconds,
refreshing the email list periodically (because mailDev, at least,
doesn't auto-refresh: you have to click the refresh button).

* Just use template repo from orig location, no copy

* Nicer way to set TemplateRepoZip

* Simply use relative path for TemplateRepoZip

* Check email subjects and bump playwright timeout

* Fix IntegrationFixtureTests

---------

Co-authored-by: Tim Haasdyk <[email protected]>
Co-authored-by: Kevin Hahn <[email protected]>
  • Loading branch information
3 people authored Apr 26, 2024
1 parent e1b2f7d commit ec3b763
Show file tree
Hide file tree
Showing 20 changed files with 346 additions and 131 deletions.
2 changes: 1 addition & 1 deletion backend/LexBoxApi/Jobs/UpdateProjectMetadataJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ await QueueJob(schedulerFactory,
protected override async Task ExecuteJob(IJobExecutionContext context)
{
ArgumentException.ThrowIfNullOrEmpty(ProjectCode);
await projectService.UpdateProjectMetadata(ProjectCode);
await projectService.UpdateProjectMetadataForCode(ProjectCode);
}
}
50 changes: 30 additions & 20 deletions backend/LexBoxApi/Services/HgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ namespace LexBoxApi.Services;
public partial class HgService : IHgService
{
private const string DELETED_REPO_FOLDER = "_____deleted_____";
private const string TEMP_REPO_FOLDER = "_____temp_____";

private readonly IOptions<HgConfig> _options;
private readonly Lazy<HttpClient> _hgClient;
Expand All @@ -38,6 +39,7 @@ public HgService(IOptions<HgConfig> options, IHttpClientFactory clientFactory, I

public static string PrefixRepoRequestPath(string code) => $"{code[0]}/{code}";
private string PrefixRepoFilePath(string code) => Path.Combine(_options.Value.RepoPath, code[0].ToString(), code);
private string GetTempRepoPath(string code, string reason) => Path.Combine(_options.Value.RepoPath, TEMP_REPO_FOLDER, $"{code}__{reason}__{FileUtils.ToTimestamp(DateTimeOffset.UtcNow)}");

private async Task<HttpResponseMessage> GetResponseMessage(string code, string requestPath)
{
Expand All @@ -61,12 +63,14 @@ public async Task InitRepo(string code)
AssertIsSafeRepoName(code);
if (Directory.Exists(PrefixRepoFilePath(code)))
throw new AlreadyExistsException($"Repo already exists: {code}.");
await Task.Run(() => InitRepoAt(code));
await Task.Run(() =>
{
InitRepoAt(new DirectoryInfo(PrefixRepoFilePath(code)));
});
}

private void InitRepoAt(string code)
private void InitRepoAt(DirectoryInfo repoDirectory)
{
var repoDirectory = new DirectoryInfo(PrefixRepoFilePath(code));
repoDirectory.Create();
FileUtils.CopyFilesRecursively(
new DirectoryInfo("Services/HgEmptyRepo"),
Expand Down Expand Up @@ -95,45 +99,51 @@ public async Task DeleteRepo(string code)

public async Task ResetRepo(string code)
{
string timestamp = FileUtils.ToTimestamp(DateTimeOffset.UtcNow);
await SoftDeleteRepo(code, $"{timestamp}__reset");
var tmpRepo = new DirectoryInfo(GetTempRepoPath(code, "reset"));
InitRepoAt(tmpRepo);
await SoftDeleteRepo(code, $"{FileUtils.ToTimestamp(DateTimeOffset.UtcNow)}__reset");
//we must init the repo as uploading a zip is optional
await InitRepo(code);
tmpRepo.MoveTo(PrefixRepoFilePath(code));
}

public async Task FinishReset(string code, Stream zipFile)
{
using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read);
await DeleteRepo(code);
var repoPath = PrefixRepoFilePath(code);
var dir = Directory.CreateDirectory(repoPath);
archive.ExtractToDirectory(repoPath);
var tempRepoPath = GetTempRepoPath(code, "upload");
var tempRepo = Directory.CreateDirectory(tempRepoPath);
// TODO: Is Task.Run superfluous here? Or a good idea? Don't know the ins and outs of what happens before the first await in an async method in ASP.NET Core...
await Task.Run(() =>
{
using var archive = new ZipArchive(zipFile, ZipArchiveMode.Read);
archive.ExtractToDirectory(tempRepoPath);
});

var hgPath = Path.Join(repoPath, ".hg");
var hgPath = Path.Join(tempRepoPath, ".hg");
if (!Directory.Exists(hgPath))
{
var hgFolder = Directory.EnumerateDirectories(repoPath, ".hg", SearchOption.AllDirectories)
var hgFolder = Directory.EnumerateDirectories(tempRepoPath, ".hg", SearchOption.AllDirectories)
.FirstOrDefault();
if (hgFolder is null)
{
await DeleteRepo(code);
await InitRepo(code); // we don't want 404s
// Don't want to leave invalid .zip contents lying around as they may have been quite large
Directory.Delete(tempRepoPath, true);
//not sure if this is the best way to handle this, might need to catch it further up to expose the error properly to tus
throw ProjectResetException.ZipMissingHgFolder();
}
//found the .hg folder, move it to the correct location and continue
Directory.Move(hgFolder, hgPath);
}
await CleanupRepoFolder(repoPath);
SetPermissionsRecursively(dir);
await CleanupRepoFolder(tempRepo);
SetPermissionsRecursively(tempRepo);
// Now we're ready to move the new repo into place, replacing the old one
await DeleteRepo(code);
tempRepo.MoveTo(PrefixRepoFilePath(code));
}

/// <summary>
/// deletes all files and folders in the repo folder except for .hg
/// </summary>
private async Task CleanupRepoFolder(string path)
private async Task CleanupRepoFolder(DirectoryInfo repoDir)
{
var repoDir = new DirectoryInfo(path);
await Task.Run(() =>
{
foreach (var info in repoDir.EnumerateFileSystemInfos())
Expand Down Expand Up @@ -275,7 +285,7 @@ private async Task<HttpContent> ExecuteHgCommandServerCommand(string code, strin
return response.Content;
}

private static readonly string[] InvalidRepoNames = { DELETED_REPO_FOLDER, "api" };
private static readonly string[] InvalidRepoNames = { DELETED_REPO_FOLDER, TEMP_REPO_FOLDER, "api" };

private void AssertIsSafeRepoName(string name)
{
Expand Down
38 changes: 30 additions & 8 deletions backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,35 +92,44 @@ public async ValueTask<Guid> LookupProjectId(string projectCode)
public async Task ResetProject(ResetProjectByAdminInput input)
{
var rowsAffected = await dbContext.Projects.Where(p => p.Code == input.Code && p.ResetStatus == ResetStatus.None)
.ExecuteUpdateAsync(u => u.SetProperty(p => p.ResetStatus, ResetStatus.InProgress));
.ExecuteUpdateAsync(u => u
.SetProperty(p => p.ResetStatus, ResetStatus.InProgress)
.SetProperty(p => p.LastCommit, null as DateTimeOffset?));
if (rowsAffected == 0) throw new NotFoundException($"project {input.Code} not ready for reset, either already reset or not found");
await ResetLexEntryCount(input.Code);
await hgService.ResetRepo(input.Code);
}

public async Task FinishReset(string code, Stream? zipFile = null)
{
var project = await dbContext.Projects.Where(p => p.Code == code).SingleOrDefaultAsync();
var project = await dbContext.Projects.Include(p => p.FlexProjectMetadata).Where(p => p.Code == code).SingleOrDefaultAsync();
if (project is null) throw new NotFoundException($"project {code} not found");
if (project.ResetStatus != ResetStatus.InProgress) throw ProjectResetException.ResetNotStarted(code);
if (zipFile is not null)
{
await hgService.FinishReset(code, zipFile);
project.LastCommit = await hgService.GetLastCommitTimeFromHg(project.Code);
await UpdateProjectMetadata(project);
}
project.ResetStatus = ResetStatus.None;
project.UpdateUpdatedDate();
await dbContext.SaveChangesAsync();
}

public async Task UpdateProjectMetadata(string projectCode)
public async Task UpdateProjectMetadataForCode(string projectCode)
{
var project = await dbContext.Projects
.Include(p => p.FlexProjectMetadata)
.FirstOrDefaultAsync(p => p.Code == projectCode);
if (project is null) return;
await UpdateProjectMetadata(project);
await dbContext.SaveChangesAsync();
}

public async Task UpdateProjectMetadata(Project project)
{
if (hgConfig.Value.AutoUpdateLexEntryCountOnSendReceive && project is { Type: ProjectType.FLEx } or { Type: ProjectType.WeSay })
{
var count = await hgService.GetLexEntryCount(projectCode, project.Type);
var count = await hgService.GetLexEntryCount(project.Code, project.Type);
if (project.FlexProjectMetadata is null)
{
project.FlexProjectMetadata = new FlexProjectMetadata { LexEntryCount = count };
Expand All @@ -131,8 +140,21 @@ public async Task UpdateProjectMetadata(string projectCode)
}
}

project.LastCommit = await hgService.GetLastCommitTimeFromHg(projectCode);
await dbContext.SaveChangesAsync();
project.LastCommit = await hgService.GetLastCommitTimeFromHg(project.Code);
// Caller is responsible for caling dbContext.SaveChangesAsync()
}

public async Task ResetLexEntryCount(string projectCode)
{
var project = await dbContext.Projects
.Include(p => p.FlexProjectMetadata)
.FirstOrDefaultAsync(p => p.Code == projectCode);
if (project is null) return;
if (project.FlexProjectMetadata is not null)
{
project.FlexProjectMetadata.LexEntryCount = null;
await dbContext.SaveChangesAsync();
}
}

public async Task<DateTimeOffset?> UpdateLastCommit(string projectCode)
Expand All @@ -149,7 +171,7 @@ public async Task UpdateProjectMetadata(string projectCode)
{
var project = await dbContext.Projects.Include(p => p.FlexProjectMetadata).FirstOrDefaultAsync(p => p.Code == projectCode);
if (project?.Type is not (ProjectType.FLEx or ProjectType.WeSay)) return null;
var count = await hgService.GetLexEntryCount(projectCode, project.Type);
var count = await hgService.GetLexEntryCount(project.Code, project.Type);
if (project.FlexProjectMetadata is null)
{
project.FlexProjectMetadata = new FlexProjectMetadata { LexEntryCount = count };
Expand Down
21 changes: 20 additions & 1 deletion backend/Testing/ApiTests/ApiTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public ApiTestBase()
};
}

public async Task<string> LoginAs(string user, string password)
public virtual async Task<string> LoginAs(string user, string password)
{
var response = await JwtHelper.ExecuteLogin(new SendReceiveAuth(user, password), HttpClient);
return JwtHelper.GetJwtFromLoginResponse(response);
Expand All @@ -41,4 +41,23 @@ public async Task<JsonObject> ExecuteGql([StringSyntax("graphql")] string gql, b
response.IsSuccessStatusCode.ShouldBeTrue($"code was {(int)response.StatusCode} ({response.ReasonPhrase})");
return jsonResponse;
}

public async Task<string?> GetProjectLastCommit(string projectCode)
{
var jsonResult = await ExecuteGql($$"""
query projectLastCommit {
projectByCode(code: "{{projectCode}}") {
lastCommit
}
}
""");
var project = jsonResult?["data"]?["projectByCode"].ShouldBeOfType<JsonObject>();
return project?["lastCommit"]?.ToString();
}

public async Task StartLexboxProjectReset(string projectCode)
{
var response = await HttpClient.PostAsync($"{BaseUrl}/api/project/resetProject/{projectCode}", null);
response.EnsureSuccessStatusCode();
}
}
76 changes: 76 additions & 0 deletions backend/Testing/ApiTests/ResetProjectRaceConditions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using Shouldly;
using Testing.Fixtures;
using static Testing.Services.Utils;

namespace Testing.ApiTests;

// Issue: https://github.com/sillsdev/languageforge-lexbox/issues/728
// Sadly, this does not reproduce the 404, but I still think it's a decent test
[Trait("Category", "Integration")]
public class ResetPojectRaceCondition : IClassFixture<IntegrationFixture>
{

private readonly IntegrationFixture _fixture;
private readonly ApiTestBase _adminApiTester;

public ResetPojectRaceCondition(IntegrationFixture fixture)
{
_fixture = fixture;
_adminApiTester = _fixture.AdminApiTester;
}

[Fact]
public async Task SimultaneousResetsDontResultIn404s()
{
// Create projects on server
var config1 = GetNewProjectConfig();
var config2 = GetNewProjectConfig();
var config3 = GetNewProjectConfig();

await using var project1 = await RegisterProjectInLexBox(config1, _adminApiTester);
await using var project2 = await RegisterProjectInLexBox(config2, _adminApiTester);
await using var project3 = await RegisterProjectInLexBox(config3, _adminApiTester);

var lastCommitBefore1 = await _adminApiTester.GetProjectLastCommit(config1.Code);
var lastCommitBefore2 = await _adminApiTester.GetProjectLastCommit(config2.Code);
var lastCommitBefore3 = await _adminApiTester.GetProjectLastCommit(config3.Code);

lastCommitBefore1.ShouldBeNullOrWhiteSpace();
lastCommitBefore2.ShouldBeNullOrWhiteSpace();
lastCommitBefore3.ShouldBeNullOrWhiteSpace();

// Reset and fill projects on server
var newLastCommits = await Task.WhenAll(
DoFullProjectResetAndVerifyLastCommit(config1.Code),
DoFullProjectResetAndVerifyLastCommit(config2.Code),
DoFullProjectResetAndVerifyLastCommit(config3.Code)
);

newLastCommits[0].ShouldNotBeNullOrWhiteSpace();
newLastCommits[0].ShouldBe(newLastCommits[1]);
newLastCommits[0].ShouldBe(newLastCommits[2]);

// we need a short delay between resets or we'll get naming collisions on the backups of the reset projects
await Task.Delay(1000);

// Reset and fill projects on server
var templateRepoLastCommit = newLastCommits[0];
newLastCommits = await Task.WhenAll(
DoFullProjectResetAndVerifyLastCommit(config1.Code, templateRepoLastCommit),
DoFullProjectResetAndVerifyLastCommit(config2.Code, templateRepoLastCommit),
DoFullProjectResetAndVerifyLastCommit(config3.Code, templateRepoLastCommit)
);
}

private async Task<string?> DoFullProjectResetAndVerifyLastCommit(string projectCode, string? expectedLastCommit = null)
{
await _adminApiTester.StartLexboxProjectReset(projectCode);
var lastCommitBefore = await _adminApiTester.GetProjectLastCommit(projectCode);
lastCommitBefore.ShouldBeNullOrWhiteSpace();
await _fixture.FinishLexboxProjectResetWithTemplateRepo(projectCode);
var lastCommit = await _adminApiTester.GetProjectLastCommit(projectCode);
if (expectedLastCommit is not null) lastCommit.ShouldBe(expectedLastCommit);
else lastCommit.ShouldNotBeNullOrWhiteSpace();
return lastCommit;
}
}
80 changes: 80 additions & 0 deletions backend/Testing/Fixtures/IntegrationFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System.IO.Compression;
using System.Net;
using System.Reflection;
using System.Runtime.CompilerServices;
using LexCore.Utils;
using Shouldly;
using Testing.ApiTests;
using Testing.Services;
using TusDotNetClient;
using static Testing.Services.Constants;

namespace Testing.Fixtures;

public class IntegrationFixture : IAsyncLifetime
{
private static readonly string _templateRepoName = "test-template-repo.zip";
public FileInfo TemplateRepoZip { get; } = new(_templateRepoName);
public DirectoryInfo TemplateRepo { get; } = new(Path.Join(BasePath, "_template-repo_"));
public ApiTestBase AdminApiTester { get; private set; } = new();
private string AdminJwt = string.Empty;

public async Task InitializeAsync(ApiTestBase apiTester)
{
AdminApiTester = apiTester;
await InitializeAsync();
}

public async Task InitializeAsync()
{
DeletePreviousTestFiles();
Directory.CreateDirectory(BasePath);
InitTemplateRepo();
AdminJwt = await AdminApiTester.LoginAs(AdminAuth.Username, AdminAuth.Password);
}

public Task DisposeAsync()
{
return Task.CompletedTask;
}

private static void DeletePreviousTestFiles()
{
if (Directory.Exists(BasePath)) Directory.Delete(BasePath, true);
}

private void InitTemplateRepo()
{
using var stream = TemplateRepoZip.OpenRead();
ZipFile.ExtractToDirectory(stream, TemplateRepo.FullName);
}

public ProjectConfig InitLocalFlexProjectWithRepo(HgProtocol? protocol = null, [CallerMemberName] string projectName = "")
{
var projectConfig = Utils.GetNewProjectConfig(protocol, projectName);
InitLocalFlexProjectWithRepo(projectConfig);
return projectConfig;
}

public void InitLocalFlexProjectWithRepo(ProjectPath projectPath)
{
var projectDir = Directory.CreateDirectory(projectPath.Dir);
FileUtils.CopyFilesRecursively(TemplateRepo, projectDir);
File.Move(Path.Join(projectPath.Dir, "kevin-test-01.fwdata"), projectPath.FwDataFile);
Directory.EnumerateFiles(projectPath.Dir).ShouldContain(projectPath.FwDataFile);
}

public async Task FinishLexboxProjectResetWithTemplateRepo(string projectCode)
{
await FinishLexboxProjectResetWithRepo(projectCode, TemplateRepoZip);
}

public async Task FinishLexboxProjectResetWithRepo(string projectCode, FileInfo repo)
{
var client = new TusClient();
client.AdditionalHeaders.Add("Cookie", $".LexBoxAuth={AdminJwt}");
var fileUrl = await client.CreateAsync($"{AdminApiTester.BaseUrl}/api/project/upload-zip/{projectCode}", repo.Length, [("filetype", "application/zip")]);
var responses = await client.UploadAsync(fileUrl, repo, chunkSize: 20);
responses.ShouldAllBe(r => r.StatusCode.ToString() == nameof(HttpStatusCode.NoContent));
}
}
Loading

0 comments on commit ec3b763

Please sign in to comment.