diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f018e62..50026984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,8 @@ jobs: gitlab: # Keep in sync with the version in GitLabDockerContainer.cs # Available tags: https://hub.docker.com/r/gitlab/gitlab-ee/tags?name=-ee.0 - - "gitlab/gitlab-ee:16.11.10-ee.0" - "gitlab/gitlab-ee:17.1.8-ee.0" + - "gitlab/gitlab-ee:18.1.6-ee.0" configuration: [Release] fail-fast: false services: diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index a660398b..54290949 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -34,7 +34,7 @@ public class GitLabDockerContainer /// Keep in sync with .github/workflows/ci.yml, use the lowest supported version /// List of available versions: https://hub.docker.com/r/gitlab/gitlab-ee/tags/ /// - private const string LocalGitLabDockerVersion = "17.1.8-ee.0"; + private const string LocalGitLabDockerVersion = "18.1.6-ee.0"; /// /// Resolved GitLab version taken from the help page once logged in @@ -208,8 +208,41 @@ private async Task SpawnDockerContainerAsync() { { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", new List { new PortBinding { HostPort = HttpPort.ToString(CultureInfo.InvariantCulture) } } }, }, + + // Update size of /dev/shm to to 512mb (default: 64mb) + // Avoids intermittent crashes of GitLab + ShmSize = 512 * 1024 * 1024, }; + // Disables non-useful features + // See https://gitlab.com/gitlab-org/omnibus-gitlab/blob/master/files/gitlab-config-template/gitlab.rb.template + string[] omnibusConfig = + [ + $"external_url 'http://localhost:{HttpPort.ToString(CultureInfo.InvariantCulture)}/'", + "gitlab_rails['gitlab_email_enabled'] = false", + "gitlab_rails['incoming_email_enabled'] = false", + "gitlab_rails['lfs_enabled'] = false", + "gitlab_rails['terraform_state_enabled'] = false", + "gitlab_rails['pages_object_store_enabled'] = false", + "gitlab_rails['usage_ping_enabled'] = false", + "gitlab_rails['registry_enabled'] = false", + "registry['enable'] = false", + "sidekiq['metrics_enabled'] = false", + "logrotate['enable'] = false", + "gitlab_pages['enable'] = false", + "gitlab_rails['gitlab_kas_enabled'] = false", + "mattermost['enable'] = false", + "alertmanager['enable'] = false", + "node_exporter['enable'] = false", + "redis_exporter['enable'] = false", + "postgres_exporter['enable'] = false", + "pgbouncer_exporter['enable'] = false", + "gitlab_exporter['enable'] = false", + "gitlab_rails['kerberos_enabled'] = false", + "gitlab_rails['packages_enabled'] = false", + "gitlab_rails['dependency_proxy_enabled'] = false", + ]; + var response = await client.Containers.CreateContainerAsync(new CreateContainerParameters { Hostname = "localhost", @@ -223,8 +256,8 @@ private async Task SpawnDockerContainerAsync() }, Env = [ - "GITLAB_OMNIBUS_CONFIG=external_url 'http://localhost:" + HttpPort.ToString(CultureInfo.InvariantCulture) + "/'", - "GITLAB_ROOT_PASSWORD=" + AdminPassword, + $"GITLAB_ROOT_PASSWORD={AdminPassword}", + $"GITLAB_OMNIBUS_CONFIG={string.Join("; ", omnibusConfig)}", ], }).ConfigureAwait(false); @@ -303,21 +336,28 @@ async Task GenerateAdminToken(GitLabCredential credentials) var gitLabVersionAsNuGetVersion = NuGetVersion.Parse(ResolvedGitLabVersion); var isMajorVersion15 = VersionRange.Parse("[15.0,16.0)").Satisfies(gitLabVersionAsNuGetVersion); var isMajorVersionAtLeast16 = VersionRange.Parse("[16.0,)").Satisfies(gitLabVersionAsNuGetVersion); + var isMajorVersionAtLeast18 = VersionRange.Parse("[18.0,)").Satisfies(gitLabVersionAsNuGetVersion); TestContext.Progress.WriteLine("Creating root token"); + var accessTokenRelativeUri = "/-/profile/personal_access_tokens"; + if (isMajorVersionAtLeast18) + { + accessTokenRelativeUri = "/-/user_settings/personal_access_tokens"; + } + var page = await browserContext.NewPageAsync(); - await page.GotoAsync(GitLabUrl + "/-/profile/personal_access_tokens"); + await page.GotoAsync(new Uri(GitLabUrl, accessTokenRelativeUri).ToString()); var formLocator = page.Locator("main#content-body form"); var tokenName = "GitLabClientTest-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); - if (isMajorVersion15) + if (isMajorVersionAtLeast18) { - // Try the "old" 15.x.y way - formLocator = page.Locator("main#content-body form"); - await formLocator.GetByLabel("Token name").FillAsync(tokenName); + await page.Locator("main[id='content-body'] button[data-testid='add-new-token-button']").ClickAsync(new LocatorClickOptions { Timeout = 5_000 }); + formLocator = page.Locator("form[id='token-create-form']"); + await formLocator.Locator("input[data-testid='access-token-name-field']").FillAsync(tokenName); } else if (isMajorVersionAtLeast16) { @@ -327,6 +367,12 @@ async Task GenerateAdminToken(GitLabCredential credentials) formLocator = page.Locator("main[id='content-body'] form[id='js-new-access-token-form']"); await formLocator.Locator("input[data-testid='access-token-name-field']").FillAsync(tokenName); } + else if (isMajorVersion15) + { + // Try the "old" 15.x.y way + formLocator = page.Locator("main#content-body form"); + await formLocator.GetByLabel("Token name").FillAsync(tokenName); + } else { s_creationErrorMessage = $"Unable to generate an admin token: resolved GitLab version '{ResolvedGitLabVersion}' doesn't match any supported range in '{nameof(GenerateCredentialsAsync)}'."; @@ -338,9 +384,19 @@ async Task GenerateAdminToken(GitLabCredential credentials) await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); } - await formLocator.GetByRole(AriaRole.Button, new() { Name = "Create personal access token" }).ClickAsync(); + string token = null; + if (isMajorVersionAtLeast18) + { + await formLocator.GetByTestId("create-token-button").ClickAsync(); + await page.GetByRole(AriaRole.Alert).GetByLabel("Click to reveal").ClickAsync(); + token = await page.GetByTestId("created-access-token-field").InputValueAsync(); + } + else + { + await formLocator.GetByRole(AriaRole.Button, new() { Name = "Create personal access token" }).ClickAsync(); + token = await page.Locator("button[title='Copy personal access token']").GetAttributeAsync("data-clipboard-text"); + } - var token = await page.Locator("button[title='Copy personal access token']").GetAttributeAsync("data-clipboard-text"); credentials.AdminUserToken = token; // Get admin login cookie @@ -508,6 +564,8 @@ private async Task ResolveGitLabVersionAsync(IBrowserContext browserContext) ResolvedGitLabVersion = version.Trim().TrimStart('v'); Console.WriteLine($"GitLab resolved version is '{ResolvedGitLabVersion}'"); + + await CloseRedesignModal(page); } private async Task LoginAsync(IBrowserContext browserContext) @@ -552,5 +610,14 @@ await page.RunAndWaitForResponseAsync(async () => }, response => response.Status == 200); } + private async Task CloseRedesignModal(IPage page) + { + var isModalVisible = await page.IsVisibleAsync("div#dap_welcome_modal button[aria-label='Close']"); + if (isModalVisible) + { + await page.Locator("div#dap_welcome_modal button[aria-label='Close']").ClickAsync(); + } + } + private static Task GetCurrentUrl(IPage page) => page.EvaluateAsync("window.location.pathname"); } diff --git a/NGitLab.Tests/ProjectsTests.cs b/NGitLab.Tests/ProjectsTests.cs index 0ab5ccd6..28fabd21 100644 --- a/NGitLab.Tests/ProjectsTests.cs +++ b/NGitLab.Tests/ProjectsTests.cs @@ -535,11 +535,18 @@ public async Task UpdateAsync_WhenProjectNotFound_ItThrows() Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); } + /// + /// On versions prior to v18, projects where deleted immediately. + /// On v18 and above, it is by default "marked for deletion" and deleted after 7 days. + /// Although the default behavior can be changed (admin settings), a new test has been created to validate the "mark for deletion behavior". See . + /// [Test] [NGitLabRetry] public async Task DeleteAsync_WhenProjectExists_ItIsDeleted() { using var context = await GitLabTestContext.CreateAsync(); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[,18.0)")); + var group = context.CreateGroup(); var project = context.CreateProject(group.Id); var projectClient = context.Client.Projects; @@ -551,6 +558,26 @@ public async Task DeleteAsync_WhenProjectExists_ItIsDeleted() Assert.ThrowsAsync(() => projectClient.GetAsync(project.Id)); } + [Test] + [NGitLabRetry] + public async Task DeleteAsync_WhenProjectExists_ItIsMarkedForDeletion() + { + using var context = await GitLabTestContext.CreateAsync(); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[18.0,)")); + + var group = context.CreateGroup(); + var project = context.CreateProject(group.Id); + var projectClient = context.Client.Projects; + + // Act + await projectClient.DeleteAsync(project.Id); + + // Assert + var projectMarkedForDeletion = await projectClient.GetAsync(project.Id); + Assert.That(projectMarkedForDeletion.MarkedForDeletionOn, Is.Not.Null); + Assert.That(projectMarkedForDeletion.MarkedForDeletionOn.Value.Date, Is.EqualTo(DateTime.UtcNow.Date)); + } + [Test] [NGitLabRetry] public async Task DeleteAsync_WhenProjectNotFound_ItThrows() @@ -791,14 +818,17 @@ public async Task Test_project_groups_query_returns_ancestor_groups() Assert.That(groups.Select(g => g.Id), Is.EquivalentTo(new[] { group.Id, subgroup.Id })); } + /// + /// On v18 and above, Job Token Permissions are enforced by default. + /// Although the default behavior can be changed (admin settings), we should create a new test to toggle the job token allow list on admin section to validate the old behavior (. + /// [Test] [NGitLabRetry] public async Task GetAndSetProjectJobTokenScope() { // Arrange using var context = await GitLabTestContext.CreateAsync(); - - context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[16.1,)")); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[16.1,18.0)")); var project = context.CreateProject(); var gitLabClient = context.Client; diff --git a/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs b/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs index 305f0626..8cf7d790 100644 --- a/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs +++ b/NGitLab.Tests/RepositoryClient/RepositoryClientTests.cs @@ -5,9 +5,11 @@ using System.IO; using System.IO.Compression; using System.Linq; +using System.Net; using System.Threading.Tasks; using NGitLab.Models; using NGitLab.Tests.Docker; +using NuGet.Versioning; using NUnit.Framework; namespace NGitLab.Tests.RepositoryClient; @@ -268,16 +270,34 @@ public async Task GetAllTreeObjectsInPathWith100ElementsByPage() Assert.That(treeObjects, Is.Not.Empty); } + /// + /// See . + /// [Test] [NGitLabRetry] - public async Task GetAllTreeObjectsAtInvalidPath() + public async Task GetAllTreeObjectsAtInvalidPathReturnsEmpty() { using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2); + context.Context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[,17.7)")); - var treeObjects = context.RepositoryClient.GetTree("Fakepath"); + var treeObjects = context.RepositoryClient.GetTree("Fakepath").ToArray(); Assert.That(treeObjects, Is.Empty); } + /// + /// See . + /// + [Test] + [NGitLabRetry] + public async Task GetAllTreeObjectsAtInvalidPathReturnsNotFound() + { + using var context = await RepositoryClientTestsContext.CreateAsync(commitCount: 2); + context.Context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[17.7,)")); + + var exception = Assert.Throws(() => context.RepositoryClient.GetTree("Fakepath").ToArray()); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + [TestCase(CommitRefType.All)] [TestCase(CommitRefType.Branch)] [TestCase(CommitRefType.Tag)] diff --git a/NGitLab.Tests/RunnerTests.cs b/NGitLab.Tests/RunnerTests.cs index fe43d248..8b8f51a4 100644 --- a/NGitLab.Tests/RunnerTests.cs +++ b/NGitLab.Tests/RunnerTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using NGitLab.Models; using NGitLab.Tests.Docker; +using NuGet.Versioning; using NUnit.Framework; using Polly; using Polly.Retry; @@ -12,12 +13,21 @@ namespace NGitLab.Tests; public class RunnerTests { + /// + /// Project runner ownership is enforced on v18. + /// It is not possible to unassign a runner from the owner project. Runner should be deleted instead. + /// See . + /// See for a scenario with the runner deletion on owner project. + /// See for a scenario that validates the restriction on owner project. + /// [Test] [NGitLabRetry] public async Task Test_can_enable_and_disable_a_runner_on_a_project() { // We need 2 projects associated to a runner to disable a runner using var context = await GitLabTestContext.CreateAsync(); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[,18.0)")); + var project1 = context.CreateProject(initializeWithCommits: true); var project2 = context.CreateProject(initializeWithCommits: true); @@ -34,6 +44,57 @@ public async Task Test_can_enable_and_disable_a_runner_on_a_project() bool IsEnabled() => runnersClient[runner.Id].Projects.Any(x => x.Id == project1.Id); } + [Test] + [NGitLabRetry] + public async Task Test_can_enable_disable_and_delete_a_runner_on_projects() + { + using var context = await GitLabTestContext.CreateAsync(); + + var project1 = context.CreateProject(initializeWithCommits: true); + var project2 = context.CreateProject(initializeWithCommits: true); + + var runnersClient = context.Client.Runners; + + // Register a runner on project 1 (owner of the runner) + var runner = runnersClient.Register(new RunnerRegister { Token = project1.RunnersToken }); + + // It Should be enabled by default + Assert.That(IsEnabledOnProject(project1), Is.True); + + runnersClient.EnableRunner(project2.Id, new RunnerId(runner.Id)); + Assert.That(IsEnabledOnProject(project2), Is.True); + + // Runner can be disabled on projects that does not owns it + runnersClient.DisableRunner(project2.Id, new RunnerId(runner.Id)); + Assert.That(IsEnabledOnProject(project2), Is.False); + + // And the only way to unregister it from the owner project is to delete it + runnersClient.Delete(runner.Id); + var ex = Assert.Throws(() => IsRegistered(runner.Id)); + Assert.That(ex.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + + bool IsEnabledOnProject(Project project) => runnersClient[runner.Id].Projects.Any(x => x.Id == project.Id); + bool IsRegistered(long runnerId) => GetRetryPolicy().Execute(() => runnersClient[runnerId].Projects.Length != 0); + } + + [Test] + [NGitLabRetry] + public async Task Test_cannot_disable_runner_on_owner_project() + { + using var context = await GitLabTestContext.CreateAsync(); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[18.0,)")); + + var project = context.CreateProject(initializeWithCommits: true); + + var runnersClient = context.Client.Runners; + var runner = runnersClient.Register(new RunnerRegister { Token = project.RunnersToken }); + Assert.That(IsEnabledOnProject(project), Is.True); + + var exception = Assert.Throws(() => runnersClient.DisableRunner(project.Id, new RunnerId(runner.Id))); + Assert.That(exception.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + bool IsEnabledOnProject(Project project) => runnersClient[runner.Id].Projects.Any(x => x.Id == project.Id); + } + [Test] [NGitLabRetry] public async Task Test_can_register_and_delete_a_runner_on_a_group() @@ -56,11 +117,18 @@ public async Task Test_can_register_and_delete_a_runner_on_a_group() bool IsRegistered() => GetRetryPolicy().Execute(() => runnersClient[runner.Id].Groups.Any(x => x.Id == createdGroup1.Id)); } + /// + /// Project runner ownership is enforced on v18. + /// It is not possible to unassign a runner from the owner project. Delete the runner instead. + /// See . + /// [Test] [NGitLabRetry] public async Task Test_can_find_a_runner_on_a_project() { using var context = await GitLabTestContext.CreateAsync(); + context.IgnoreTestIfGitLabVersionOutOfRange(VersionRange.Parse("[,18.0)")); + var project = context.CreateProject(initializeWithCommits: true); var project2 = context.CreateProject(initializeWithCommits: true); var runnersClient = context.Client.Runners; diff --git a/NGitLab/Models/Project.cs b/NGitLab/Models/Project.cs index 86bf3d63..d1fa5675 100644 --- a/NGitLab/Models/Project.cs +++ b/NGitLab/Models/Project.cs @@ -227,4 +227,7 @@ public class Project [JsonPropertyName("ci_default_git_depth")] public int? CiDefaultGitDepth { get; set; } + + [JsonPropertyName("marked_for_deletion_on")] + public DateTime? MarkedForDeletionOn { get; set; } } diff --git a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt index bf883473..8bf1fe56 100644 --- a/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -3629,6 +3629,8 @@ NGitLab.Models.Project.LfsEnabled.get -> bool NGitLab.Models.Project.LfsEnabled.set -> void NGitLab.Models.Project.Links.get -> NGitLab.Models.ProjectLinks NGitLab.Models.Project.Links.set -> void +NGitLab.Models.Project.MarkedForDeletionOn.get -> System.DateTime? +NGitLab.Models.Project.MarkedForDeletionOn.set -> void NGitLab.Models.Project.MergeMethod.get -> string NGitLab.Models.Project.MergeMethod.set -> void NGitLab.Models.Project.MergePipelinesEnabled.get -> bool diff --git a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 78fea3cb..7056392f 100644 --- a/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -3628,6 +3628,8 @@ NGitLab.Models.Project.LfsEnabled.get -> bool NGitLab.Models.Project.LfsEnabled.set -> void NGitLab.Models.Project.Links.get -> NGitLab.Models.ProjectLinks NGitLab.Models.Project.Links.set -> void +NGitLab.Models.Project.MarkedForDeletionOn.get -> System.DateTime? +NGitLab.Models.Project.MarkedForDeletionOn.set -> void NGitLab.Models.Project.MergeMethod.get -> string NGitLab.Models.Project.MergeMethod.set -> void NGitLab.Models.Project.MergePipelinesEnabled.get -> bool diff --git a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index bf883473..8bf1fe56 100644 --- a/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/NGitLab/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -3629,6 +3629,8 @@ NGitLab.Models.Project.LfsEnabled.get -> bool NGitLab.Models.Project.LfsEnabled.set -> void NGitLab.Models.Project.Links.get -> NGitLab.Models.ProjectLinks NGitLab.Models.Project.Links.set -> void +NGitLab.Models.Project.MarkedForDeletionOn.get -> System.DateTime? +NGitLab.Models.Project.MarkedForDeletionOn.set -> void NGitLab.Models.Project.MergeMethod.get -> string NGitLab.Models.Project.MergeMethod.set -> void NGitLab.Models.Project.MergePipelinesEnabled.get -> bool