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