From cdc2b9a8eddb895c8395e2a07365cbbe535b876e Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Tue, 3 Feb 2026 14:18:22 -0500 Subject: [PATCH 1/9] chore: Add support for gitlab v18, remove v16 --- .github/workflows/ci.yml | 2 +- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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..7ebeec20 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 From b7e7be707e6676a80a28111523f5547f5f7f9e58 Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Wed, 4 Feb 2026 14:38:04 -0500 Subject: [PATCH 2/9] add conditions for v18 for container --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 1132 +++++++++-------- 1 file changed, 576 insertions(+), 556 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 7ebeec20..4d74c0f4 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -1,556 +1,576 @@ -#pragma warning disable MA0004 -#pragma warning disable MA0006 -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Docker.DotNet; -using Docker.DotNet.Models; -using Microsoft.Playwright; -using NGitLab.Models; -using NuGet.Versioning; -using NUnit.Framework; -using Polly; - -namespace NGitLab.Tests.Docker; - -public class GitLabDockerContainer -{ - public const string ContainerName = "NGitLabClientTests"; - public const string ImageName = "gitlab/gitlab-ee"; - - /// - /// GitLab docker image version to spawn. - /// Used only on local environment (CI should already have a running GitLab instance from its services) - /// - /// - /// 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 = "18.1.6-ee.0"; - - /// - /// Resolved GitLab version taken from the help page once logged in - /// - private static string ResolvedGitLabVersion; - - private static string s_creationErrorMessage; - private static readonly SemaphoreSlim s_setupLock = new(initialCount: 1, maxCount: 1); - private static GitLabDockerContainer s_instance; - - public string Host { get; private set; } = "localhost"; - - public int HttpPort { get; private set; } = 48624; - - public string AdminUserName { get; } = "root"; - - public static string AdminPassword - { - get - { - var env = Environment.GetEnvironmentVariable("GITLAB_ROOT_PASSWORD"); - if (!string.IsNullOrEmpty(env)) - return env; - - return "Pa$$w0rd"; - } - } - - public string LicenseFile { get; set; } - - public Uri GitLabUrl => new("http://" + Host + ":" + HttpPort.ToString(CultureInfo.InvariantCulture)); - - public GitLabCredential Credentials { get; set; } - - public static async Task GetOrCreateInstance() - { - await s_setupLock.WaitAsync().ConfigureAwait(false); - try - { - if (s_instance == null) - { - if (s_creationErrorMessage != null) - { - Assert.Fail(s_creationErrorMessage); - } - - try - { - var instance = new GitLabDockerContainer(); - await instance.SetupAsync().ConfigureAwait(false); - s_instance = instance; - } - catch (Exception ex) - { - s_creationErrorMessage = ex.ToString(); - throw; - } - } - - return s_instance; - } - finally - { - s_setupLock.Release(); - } - } - - private async Task SetupAsync() - { - if (GitLabTestContext.IsContinuousIntegration()) - { - await WaitForCiGitLabInstance().ConfigureAwait(false); - } - else - { - await SpawnDockerContainerAsync().ConfigureAwait(false); - } - - EnsureChromiumIsInstalled(); - - // Use Playwright to launch Chromium - using var playwright = await Playwright.CreateAsync(); - await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions - { - // Headless = false, // Uncomment to have browser window visible - // SlowMo = 1000, // Slows down Playwright operations by the specified amount of ms. - }); - await using var browserContext = await browser.NewContextAsync(); - - await LoginAsync(browserContext); - await ResolveGitLabVersionAsync(browserContext).ConfigureAwait(false); - - await LoadCredentialsAsync().ConfigureAwait(false); - - if (Credentials != null) - { - Console.WriteLine("Using credentials from persisted credential file"); - return; - } - - await GenerateCredentialsAsync(browserContext).ConfigureAwait(false); - PersistCredentialsAsync(); - - static void EnsureChromiumIsInstalled() - { - TestContext.Progress.WriteLine("Making sure Chromium is installed"); - - var exitCode = Microsoft.Playwright.Program.Main(new[] { "install", "--force", "chromium", "--with-deps" }); - if (exitCode != 0) - throw new InvalidOperationException($"Cannot install browser (exit code: {exitCode})"); - - TestContext.Progress.WriteLine("Chromium installed"); - } - } - - private static async Task ValidateDockerIsEnabled(DockerClient client) - { - try - { - await client.Images.ListImagesAsync(new ImagesListParameters()).ConfigureAwait(false); - } - catch (ArgumentOutOfRangeException ex) when (ex.Message.StartsWith("The added or subtracted value results in an un-representable DateTime.", StringComparison.Ordinal)) - { - // Ignore https://github.com/rancher-sandbox/rancher-desktop/issues/5145 - } - catch (Exception ex) - { - s_creationErrorMessage = "Cannot connect to Docker service. Make sure it's running on your machine before launching any tests.\nDetails: " + ex; - Assert.Fail(s_creationErrorMessage); - } - } - - private async Task SpawnDockerContainerAsync() - { - Console.WriteLine($"Executing tests locally. Spawning GitLab docker image version '{LocalGitLabDockerVersion}'"); - using var httpClient = new HttpClient(); - - // Spawn the container - // https://docs.gitlab.com/omnibus/settings/configuration.html - using var conf = new DockerClientConfiguration(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")); - using var client = conf.CreateClient(); - await ValidateDockerIsEnabled(client); - - TestContext.Progress.WriteLine("Looking up GitLab Docker containers"); - var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); - var container = containers.FirstOrDefault(c => c.Names.Contains("/" + ContainerName, StringComparer.Ordinal)); - if (container != null) - { - TestContext.Progress.WriteLine("Verifying if the GitLab Docker container is using the right image"); - var inspect = await client.Containers.InspectContainerAsync(container.ID).ConfigureAwait(false); - var inspectImage = await client.Images.InspectImageAsync(ImageName + ":" + LocalGitLabDockerVersion).ConfigureAwait(false); - if (inspect.Image != inspectImage.ID) - { - TestContext.Progress.WriteLine("Ending GitLab Docker container, as it's using the wrong image"); - await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters { Force = true }).ConfigureAwait(false); - container = null; - } - } - - if (container == null) - { - // Download GitLab images - TestContext.Progress.WriteLine("Making sure the right GitLab Docker image is available locally"); - await client.Images.CreateImageAsync(new ImagesCreateParameters { FromImage = ImageName, Tag = LocalGitLabDockerVersion }, new AuthConfig(), new Progress()).ConfigureAwait(false); - - // Create the container - TestContext.Progress.WriteLine("Creating the GitLab Docker container"); - var hostConfig = new HostConfig - { - PortBindings = new Dictionary>(StringComparer.Ordinal) - { - { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", new List { new PortBinding { HostPort = HttpPort.ToString(CultureInfo.InvariantCulture) } } }, - }, - }; - - var response = await client.Containers.CreateContainerAsync(new CreateContainerParameters - { - Hostname = "localhost", - Image = ImageName + ":" + LocalGitLabDockerVersion, - Name = ContainerName, - Tty = false, - HostConfig = hostConfig, - ExposedPorts = new Dictionary(StringComparer.Ordinal) - { - { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", default }, - }, - Env = - [ - "GITLAB_OMNIBUS_CONFIG=external_url 'http://localhost:" + HttpPort.ToString(CultureInfo.InvariantCulture) + "/'", - "GITLAB_ROOT_PASSWORD=" + AdminPassword, - ], - }).ConfigureAwait(false); - - containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); - container = containers.First(c => c.ID == response.ID); - } - - // Start the container - if (container.State != "running") - { - TestContext.Progress.WriteLine("Starting the GitLab Docker container"); - var started = await client.Containers.StartContainerAsync(container.ID, new ContainerStartParameters()).ConfigureAwait(false); - if (!started) - { - Assert.Fail("Cannot start the Docker container"); - } - } - - // Wait for the container to be ready. - var stopwatch = Stopwatch.StartNew(); - while (true) - { - TestContext.Progress.WriteLine($@"Waiting for the GitLab Docker container to be ready ({stopwatch.Elapsed:mm\:ss})"); - var status = await client.Containers.InspectContainerAsync(container.ID); - if (!status.State.Running) - throw new InvalidOperationException($"Container '{status.ID}' is not running"); - - var healthState = status.State.Health.Status; - - // unhealthy is valid as long as the container is running as it may indicate a slow creation - if (healthState is "starting" or "unhealthy") - { - } - else if (healthState is "healthy") - { - // A healthy container doesn't mean the service is actually running. - // GitLab has lots of configuration steps that are still running when the container is healthy. - try - { - using var response = await httpClient.GetAsync(GitLabUrl).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - break; - } - catch - { - } - } - else - { - throw new InvalidOperationException($"Container status '{healthState}' is not supported"); - } - - await Task.Delay(5000); - } - - TestContext.Progress.WriteLine("GitLab Docker container is ready"); - } - - private async Task GenerateCredentialsAsync(IBrowserContext browserContext) - { - Console.WriteLine("Requesting credentials from GitLab instance"); - - var credentials = new GitLabCredential(); - await GenerateAdminToken(credentials).ConfigureAwait(false); - if (credentials.AdminUserToken != null) - { - GenerateUserToken(); - } - - Credentials = credentials; - - async Task GenerateAdminToken(GitLabCredential credentials) - { - TestContext.Progress.WriteLine("Generating Credentials"); - - var gitLabVersionAsNuGetVersion = NuGetVersion.Parse(ResolvedGitLabVersion); - var isMajorVersion15 = VersionRange.Parse("[15.0,16.0)").Satisfies(gitLabVersionAsNuGetVersion); - var isMajorVersionAtLeast16 = VersionRange.Parse("[16.0,)").Satisfies(gitLabVersionAsNuGetVersion); - - TestContext.Progress.WriteLine("Creating root token"); - - var page = await browserContext.NewPageAsync(); - await page.GotoAsync(GitLabUrl + "/-/profile/personal_access_tokens"); - - var formLocator = page.Locator("main#content-body form"); - - var tokenName = "GitLabClientTest-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); - - if (isMajorVersion15) - { - // Try the "old" 15.x.y way - formLocator = page.Locator("main#content-body form"); - await formLocator.GetByLabel("Token name").FillAsync(tokenName); - } - else if (isMajorVersionAtLeast16) - { - await SkipVersionReminder(page); - - await page.Locator("main[id='content-body'] button[data-testid='add-new-token-button']").ClickAsync(new LocatorClickOptions { Timeout = 5_000 }); - 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 - { - s_creationErrorMessage = $"Unable to generate an admin token: resolved GitLab version '{ResolvedGitLabVersion}' doesn't match any supported range in '{nameof(GenerateCredentialsAsync)}'."; - Assert.Fail(s_creationErrorMessage); - } - - foreach (var checkbox in await formLocator.GetByRole(AriaRole.Checkbox).AllAsync()) - { - await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); - } - - await formLocator.GetByRole(AriaRole.Button, new() { Name = "Create personal access token" }).ClickAsync(); - - var token = await page.Locator("button[title='Copy personal access token']").GetAttributeAsync("data-clipboard-text"); - credentials.AdminUserToken = token; - - // Get admin login cookie - // result.Cookie: experimentation_subject_id=XXX; _gitlab_session=XXXX; known_sign_in=XXXX - TestContext.Progress.WriteLine("Extracting GitLab session cookie"); - var cookies = await browserContext.CookiesAsync(new[] { GitLabUrl.AbsoluteUri }); - foreach (var cookie in cookies) - { - if (cookie.Name == "_gitlab_session") - { - credentials.AdminCookies = cookie.Value; - break; - } - } - } - - void GenerateUserToken() - { - var retryPolicy = Policy.Handle().WaitAndRetry(10, _ => TimeSpan.FromSeconds(1)); - var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); - var user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); - if (user == null) - { - try - { - user = retryPolicy.Execute(() => client.Users.Create(new UserUpsert - { - Username = "common_user", - Email = "common_user@example.com", - IsAdmin = false, - Name = "common_user", - SkipConfirmation = true, - ResetPassword = false, - Password = AdminPassword, - IsPrivateProfile = true, // Set profile to private for LastActivity test cases - })); - } - catch (GitLabException) - { - user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); - if (user == null) - throw new InvalidOperationException("Cannot create the common user"); - } - } - - var token = retryPolicy.Execute(() => client.Users.CreateToken(new UserTokenCreate - { - UserId = user.Id, - Name = "common_user", - Scopes = new[] { "api" }, - ExpiresAt = DateTime.UtcNow.AddDays(7), - })); - - credentials.UserToken = token.Token; - } - } - - private static async Task SkipVersionReminder(IPage page) - { - try - { - await page.Locator("button[data-testid='alert-modal-remind-button']").ClickAsync(new LocatorClickOptions { Timeout = 3_000 }); - } - catch (Exception) - { - } - } - - private void PersistCredentialsAsync() - { - var path = GetCredentialsFilePath(); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - var json = JsonSerializer.Serialize(Credentials); - File.WriteAllText(path, json); - } - - private async Task LoadCredentialsAsync() - { - var file = GetCredentialsFilePath(); - if (File.Exists(file)) - { - var json = File.ReadAllText(file); - var credentials = JsonSerializer.Deserialize(json); - if (credentials.AdminUserToken == null || credentials.UserToken == null) - return; - - var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); - try - { - // Validate token - var user = client.Users.Current; - - using var httpClient = new HttpClient - { - BaseAddress = GitLabUrl, - DefaultRequestHeaders = - { - { "Cookie", "_gitlab_session=" + credentials.AdminCookies }, - }, - }; - var response = await httpClient.GetAsync(new Uri("/", UriKind.RelativeOrAbsolute)); - if (response.RequestMessage.RequestUri.PathAndQuery == "/users/sign_in") - return; - - // Validate cookie - Credentials = credentials; - } - catch (GitLabException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) - { - } - } - } - - private static string GetCredentialsFilePath() - { - return Path.Combine(Path.GetTempPath(), "ngitlab", "credentials.json"); - } - - private async Task WaitForCiGitLabInstance() - { - Console.WriteLine($"Executing tests on CI. Checking GitLab instance..."); - - using var httpClient = new HttpClient(); - Console.WriteLine("Testing " + GitLabUrl); - - var now = Stopwatch.StartNew(); - while (now.Elapsed < TimeSpan.FromMinutes(10)) - { - try - { - var result = await httpClient.GetStringAsync(GitLabUrl).ConfigureAwait(false); - return; - } - catch - { - } - - await Task.Delay(1000); - } - - s_creationErrorMessage = "GitLab is not well configured in CI"; - Assert.Fail(s_creationErrorMessage); - } - - private async Task ResolveGitLabVersionAsync(IBrowserContext browserContext) - { - Console.WriteLine("Resolving GitLab version from help page..."); - var page = await browserContext.NewPageAsync(); - await page.GotoAsync(new Uri(GitLabUrl, "help").AbsoluteUri); - var titleLink = await page.QuerySelectorAsync("h1 a"); - - if (titleLink is null) - { - s_creationErrorMessage = "Cannot find title on the help page to get GitLab version"; - Assert.Fail(s_creationErrorMessage); - } - - var version = await titleLink.TextContentAsync(); - - if (string.IsNullOrEmpty(version)) - { - s_creationErrorMessage = "Found title on the help page, but the version is empty"; - Assert.Fail(s_creationErrorMessage); - } - - ResolvedGitLabVersion = version.Trim().TrimStart('v'); - Console.WriteLine($"GitLab resolved version is '{ResolvedGitLabVersion}'"); - } - - private async Task LoginAsync(IBrowserContext browserContext) - { - var page = await browserContext.NewPageAsync(); - await page.GotoAsync(GitLabUrl.AbsoluteUri); - var url = await GetCurrentUrl(page); - - if (url != "/users/sign_in") - { - Console.WriteLine("Already logged in on GitLab instance"); - return; - } - - Console.WriteLine("Logging in on GitLab instance..."); - - var v15LoginInput = "form#new_user input[name='user[login]']"; - var v16LoginInput = "form[data-testid='sign-in-form'] input[name='user[login]']"; - - if (await page.QuerySelectorAsync(v15LoginInput) is not null) - { - await page.Locator(v15LoginInput).FillAsync(AdminUserName); - await page.Locator("form#new_user input[name='user[password]']").FillAsync(AdminPassword); - } - else if (await page.QuerySelectorAsync(v16LoginInput) is not null) - { - await page.Locator(v16LoginInput).FillAsync(AdminUserName); - await page.Locator("form[data-testid='sign-in-form'] input[name='user[password]']").FillAsync(AdminPassword); - } - else - { - s_creationErrorMessage = $"Unable to find the correct login input. Please make sure that login form for the GitLab version you target is supported in '{nameof(LoginAsync)}'"; - Assert.Fail(s_creationErrorMessage); - } - - var checkbox = page.Locator("form[data-testid='sign-in-form'] input[type=checkbox][name='user[remember_me]']"); - await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); - - await page.RunAndWaitForResponseAsync(async () => - { - await page.EvalOnSelectorAsync("form[data-testid='sign-in-form']", "form => form.submit()"); - }, response => response.Status == 200); - } - - private static Task GetCurrentUrl(IPage page) => page.EvaluateAsync("window.location.pathname"); -} +#pragma warning disable MA0004 +#pragma warning disable MA0006 +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Playwright; +using NGitLab.Models; +using NuGet.Versioning; +using NUnit.Framework; +using Polly; + +namespace NGitLab.Tests.Docker; + +public class GitLabDockerContainer +{ + public const string ContainerName = "NGitLabClientTests"; + public const string ImageName = "gitlab/gitlab-ee"; + + /// + /// GitLab docker image version to spawn. + /// Used only on local environment (CI should already have a running GitLab instance from its services) + /// + /// + /// 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 = "18.1.6-ee.0"; + + /// + /// Resolved GitLab version taken from the help page once logged in + /// + private static string ResolvedGitLabVersion; + + private static string s_creationErrorMessage; + private static readonly SemaphoreSlim s_setupLock = new(initialCount: 1, maxCount: 1); + private static GitLabDockerContainer s_instance; + + public string Host { get; private set; } = "localhost"; + + public int HttpPort { get; private set; } = 48624; + + public string AdminUserName { get; } = "root"; + + public static string AdminPassword + { + get + { + var env = Environment.GetEnvironmentVariable("GITLAB_ROOT_PASSWORD"); + if (!string.IsNullOrEmpty(env)) + return env; + + return "Pa$$w0rd"; + } + } + + public string LicenseFile { get; set; } + + public Uri GitLabUrl => new("http://" + Host + ":" + HttpPort.ToString(CultureInfo.InvariantCulture)); + + public GitLabCredential Credentials { get; set; } + + public static async Task GetOrCreateInstance() + { + await s_setupLock.WaitAsync().ConfigureAwait(false); + try + { + if (s_instance == null) + { + if (s_creationErrorMessage != null) + { + Assert.Fail(s_creationErrorMessage); + } + + try + { + var instance = new GitLabDockerContainer(); + await instance.SetupAsync().ConfigureAwait(false); + s_instance = instance; + } + catch (Exception ex) + { + s_creationErrorMessage = ex.ToString(); + throw; + } + } + + return s_instance; + } + finally + { + s_setupLock.Release(); + } + } + + private async Task SetupAsync() + { + if (GitLabTestContext.IsContinuousIntegration()) + { + await WaitForCiGitLabInstance().ConfigureAwait(false); + } + else + { + await SpawnDockerContainerAsync().ConfigureAwait(false); + } + + EnsureChromiumIsInstalled(); + + // Use Playwright to launch Chromium + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + // Headless = false, // Uncomment to have browser window visible + // SlowMo = 1000, // Slows down Playwright operations by the specified amount of ms. + }); + await using var browserContext = await browser.NewContextAsync(); + + await LoginAsync(browserContext); + await ResolveGitLabVersionAsync(browserContext).ConfigureAwait(false); + + await LoadCredentialsAsync().ConfigureAwait(false); + + if (Credentials != null) + { + Console.WriteLine("Using credentials from persisted credential file"); + return; + } + + await GenerateCredentialsAsync(browserContext).ConfigureAwait(false); + PersistCredentialsAsync(); + + static void EnsureChromiumIsInstalled() + { + TestContext.Progress.WriteLine("Making sure Chromium is installed"); + + var exitCode = Microsoft.Playwright.Program.Main(new[] { "install", "--force", "chromium", "--with-deps" }); + if (exitCode != 0) + throw new InvalidOperationException($"Cannot install browser (exit code: {exitCode})"); + + TestContext.Progress.WriteLine("Chromium installed"); + } + } + + private static async Task ValidateDockerIsEnabled(DockerClient client) + { + try + { + await client.Images.ListImagesAsync(new ImagesListParameters()).ConfigureAwait(false); + } + catch (ArgumentOutOfRangeException ex) when (ex.Message.StartsWith("The added or subtracted value results in an un-representable DateTime.", StringComparison.Ordinal)) + { + // Ignore https://github.com/rancher-sandbox/rancher-desktop/issues/5145 + } + catch (Exception ex) + { + s_creationErrorMessage = "Cannot connect to Docker service. Make sure it's running on your machine before launching any tests.\nDetails: " + ex; + Assert.Fail(s_creationErrorMessage); + } + } + + private async Task SpawnDockerContainerAsync() + { + Console.WriteLine($"Executing tests locally. Spawning GitLab docker image version '{LocalGitLabDockerVersion}'"); + using var httpClient = new HttpClient(); + + // Spawn the container + // https://docs.gitlab.com/omnibus/settings/configuration.html + using var conf = new DockerClientConfiguration(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")); + using var client = conf.CreateClient(); + await ValidateDockerIsEnabled(client); + + TestContext.Progress.WriteLine("Looking up GitLab Docker containers"); + var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); + var container = containers.FirstOrDefault(c => c.Names.Contains("/" + ContainerName, StringComparer.Ordinal)); + if (container != null) + { + TestContext.Progress.WriteLine("Verifying if the GitLab Docker container is using the right image"); + var inspect = await client.Containers.InspectContainerAsync(container.ID).ConfigureAwait(false); + var inspectImage = await client.Images.InspectImageAsync(ImageName + ":" + LocalGitLabDockerVersion).ConfigureAwait(false); + if (inspect.Image != inspectImage.ID) + { + TestContext.Progress.WriteLine("Ending GitLab Docker container, as it's using the wrong image"); + await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters { Force = true }).ConfigureAwait(false); + container = null; + } + } + + if (container == null) + { + // Download GitLab images + TestContext.Progress.WriteLine("Making sure the right GitLab Docker image is available locally"); + await client.Images.CreateImageAsync(new ImagesCreateParameters { FromImage = ImageName, Tag = LocalGitLabDockerVersion }, new AuthConfig(), new Progress()).ConfigureAwait(false); + + // Create the container + TestContext.Progress.WriteLine("Creating the GitLab Docker container"); + var hostConfig = new HostConfig + { + PortBindings = new Dictionary>(StringComparer.Ordinal) + { + { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", new List { new PortBinding { HostPort = HttpPort.ToString(CultureInfo.InvariantCulture) } } }, + }, + }; + + var response = await client.Containers.CreateContainerAsync(new CreateContainerParameters + { + Hostname = "localhost", + Image = ImageName + ":" + LocalGitLabDockerVersion, + Name = ContainerName, + Tty = false, + HostConfig = hostConfig, + ExposedPorts = new Dictionary(StringComparer.Ordinal) + { + { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", default }, + }, + Env = + [ + "GITLAB_OMNIBUS_CONFIG=external_url 'http://localhost:" + HttpPort.ToString(CultureInfo.InvariantCulture) + "/'", + "GITLAB_ROOT_PASSWORD=" + AdminPassword, + ], + }).ConfigureAwait(false); + + containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); + container = containers.First(c => c.ID == response.ID); + } + + // Start the container + if (container.State != "running") + { + TestContext.Progress.WriteLine("Starting the GitLab Docker container"); + var started = await client.Containers.StartContainerAsync(container.ID, new ContainerStartParameters()).ConfigureAwait(false); + if (!started) + { + Assert.Fail("Cannot start the Docker container"); + } + } + + // Wait for the container to be ready. + var stopwatch = Stopwatch.StartNew(); + while (true) + { + TestContext.Progress.WriteLine($@"Waiting for the GitLab Docker container to be ready ({stopwatch.Elapsed:mm\:ss})"); + var status = await client.Containers.InspectContainerAsync(container.ID); + if (!status.State.Running) + throw new InvalidOperationException($"Container '{status.ID}' is not running"); + + var healthState = status.State.Health.Status; + + // unhealthy is valid as long as the container is running as it may indicate a slow creation + if (healthState is "starting" or "unhealthy") + { + } + else if (healthState is "healthy") + { + // A healthy container doesn't mean the service is actually running. + // GitLab has lots of configuration steps that are still running when the container is healthy. + try + { + using var response = await httpClient.GetAsync(GitLabUrl).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + break; + } + catch + { + } + } + else + { + throw new InvalidOperationException($"Container status '{healthState}' is not supported"); + } + + await Task.Delay(5000); + } + + TestContext.Progress.WriteLine("GitLab Docker container is ready"); + } + + private async Task GenerateCredentialsAsync(IBrowserContext browserContext) + { + Console.WriteLine("Requesting credentials from GitLab instance"); + + var credentials = new GitLabCredential(); + await GenerateAdminToken(credentials).ConfigureAwait(false); + if (credentials.AdminUserToken != null) + { + GenerateUserToken(); + } + + Credentials = credentials; + + async Task GenerateAdminToken(GitLabCredential credentials) + { + TestContext.Progress.WriteLine("Generating 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(new Uri(GitLabUrl, accessTokenRelativeUri).ToString()); + + var formLocator = page.Locator("main#content-body form"); + + var tokenName = "GitLabClientTest-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + + if (isMajorVersionAtLeast18) + { + 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) + { + await SkipVersionReminder(page); + + await page.Locator("main[id='content-body'] button[data-testid='add-new-token-button']").ClickAsync(new LocatorClickOptions { Timeout = 5_000 }); + 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)}'."; + Assert.Fail(s_creationErrorMessage); + } + + foreach (var checkbox in await formLocator.GetByRole(AriaRole.Checkbox).AllAsync()) + { + await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); + } + + if (isMajorVersionAtLeast18) + { + await formLocator.GetByTestId("create-token-button").ClickAsync(); + } + else + { + await formLocator.GetByRole(AriaRole.Button, new() { Name = "Create personal access token" }).ClickAsync(); + } + + var token = await page.Locator("button[title='Copy personal access token']").GetAttributeAsync("data-clipboard-text"); + credentials.AdminUserToken = token; + + // Get admin login cookie + // result.Cookie: experimentation_subject_id=XXX; _gitlab_session=XXXX; known_sign_in=XXXX + TestContext.Progress.WriteLine("Extracting GitLab session cookie"); + var cookies = await browserContext.CookiesAsync(new[] { GitLabUrl.AbsoluteUri }); + foreach (var cookie in cookies) + { + if (cookie.Name == "_gitlab_session") + { + credentials.AdminCookies = cookie.Value; + break; + } + } + } + + void GenerateUserToken() + { + var retryPolicy = Policy.Handle().WaitAndRetry(10, _ => TimeSpan.FromSeconds(1)); + var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); + var user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); + if (user == null) + { + try + { + user = retryPolicy.Execute(() => client.Users.Create(new UserUpsert + { + Username = "common_user", + Email = "common_user@example.com", + IsAdmin = false, + Name = "common_user", + SkipConfirmation = true, + ResetPassword = false, + Password = AdminPassword, + IsPrivateProfile = true, // Set profile to private for LastActivity test cases + })); + } + catch (GitLabException) + { + user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); + if (user == null) + throw new InvalidOperationException("Cannot create the common user"); + } + } + + var token = retryPolicy.Execute(() => client.Users.CreateToken(new UserTokenCreate + { + UserId = user.Id, + Name = "common_user", + Scopes = new[] { "api" }, + ExpiresAt = DateTime.UtcNow.AddDays(7), + })); + + credentials.UserToken = token.Token; + } + } + + private static async Task SkipVersionReminder(IPage page) + { + try + { + await page.Locator("button[data-testid='alert-modal-remind-button']").ClickAsync(new LocatorClickOptions { Timeout = 3_000 }); + } + catch (Exception) + { + } + } + + private void PersistCredentialsAsync() + { + var path = GetCredentialsFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonSerializer.Serialize(Credentials); + File.WriteAllText(path, json); + } + + private async Task LoadCredentialsAsync() + { + var file = GetCredentialsFilePath(); + if (File.Exists(file)) + { + var json = File.ReadAllText(file); + var credentials = JsonSerializer.Deserialize(json); + if (credentials.AdminUserToken == null || credentials.UserToken == null) + return; + + var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); + try + { + // Validate token + var user = client.Users.Current; + + using var httpClient = new HttpClient + { + BaseAddress = GitLabUrl, + DefaultRequestHeaders = + { + { "Cookie", "_gitlab_session=" + credentials.AdminCookies }, + }, + }; + var response = await httpClient.GetAsync(new Uri("/", UriKind.RelativeOrAbsolute)); + if (response.RequestMessage.RequestUri.PathAndQuery == "/users/sign_in") + return; + + // Validate cookie + Credentials = credentials; + } + catch (GitLabException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) + { + } + } + } + + private static string GetCredentialsFilePath() + { + return Path.Combine(Path.GetTempPath(), "ngitlab", "credentials.json"); + } + + private async Task WaitForCiGitLabInstance() + { + Console.WriteLine($"Executing tests on CI. Checking GitLab instance..."); + + using var httpClient = new HttpClient(); + Console.WriteLine("Testing " + GitLabUrl); + + var now = Stopwatch.StartNew(); + while (now.Elapsed < TimeSpan.FromMinutes(10)) + { + try + { + var result = await httpClient.GetStringAsync(GitLabUrl).ConfigureAwait(false); + return; + } + catch + { + } + + await Task.Delay(1000); + } + + s_creationErrorMessage = "GitLab is not well configured in CI"; + Assert.Fail(s_creationErrorMessage); + } + + private async Task ResolveGitLabVersionAsync(IBrowserContext browserContext) + { + Console.WriteLine("Resolving GitLab version from help page..."); + var page = await browserContext.NewPageAsync(); + await page.GotoAsync(new Uri(GitLabUrl, "help").AbsoluteUri); + var titleLink = await page.QuerySelectorAsync("h1 a"); + + if (titleLink is null) + { + s_creationErrorMessage = "Cannot find title on the help page to get GitLab version"; + Assert.Fail(s_creationErrorMessage); + } + + var version = await titleLink.TextContentAsync(); + + if (string.IsNullOrEmpty(version)) + { + s_creationErrorMessage = "Found title on the help page, but the version is empty"; + Assert.Fail(s_creationErrorMessage); + } + + ResolvedGitLabVersion = version.Trim().TrimStart('v'); + Console.WriteLine($"GitLab resolved version is '{ResolvedGitLabVersion}'"); + } + + private async Task LoginAsync(IBrowserContext browserContext) + { + var page = await browserContext.NewPageAsync(); + await page.GotoAsync(GitLabUrl.AbsoluteUri); + var url = await GetCurrentUrl(page); + + if (url != "/users/sign_in") + { + Console.WriteLine("Already logged in on GitLab instance"); + return; + } + + Console.WriteLine("Logging in on GitLab instance..."); + + var v15LoginInput = "form#new_user input[name='user[login]']"; + var v16LoginInput = "form[data-testid='sign-in-form'] input[name='user[login]']"; + + if (await page.QuerySelectorAsync(v15LoginInput) is not null) + { + await page.Locator(v15LoginInput).FillAsync(AdminUserName); + await page.Locator("form#new_user input[name='user[password]']").FillAsync(AdminPassword); + } + else if (await page.QuerySelectorAsync(v16LoginInput) is not null) + { + await page.Locator(v16LoginInput).FillAsync(AdminUserName); + await page.Locator("form[data-testid='sign-in-form'] input[name='user[password]']").FillAsync(AdminPassword); + } + else + { + s_creationErrorMessage = $"Unable to find the correct login input. Please make sure that login form for the GitLab version you target is supported in '{nameof(LoginAsync)}'"; + Assert.Fail(s_creationErrorMessage); + } + + var checkbox = page.Locator("form[data-testid='sign-in-form'] input[type=checkbox][name='user[remember_me]']"); + await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); + + await page.RunAndWaitForResponseAsync(async () => + { + await page.EvalOnSelectorAsync("form[data-testid='sign-in-form']", "form => form.submit()"); + }, response => response.Status == 200); + } + + private static Task GetCurrentUrl(IPage page) => page.EvaluateAsync("window.location.pathname"); +} From 611ad3edd4d806ba41c0823bc655700bbcc24cd1 Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Fri, 6 Feb 2026 11:27:54 -0500 Subject: [PATCH 3/9] fix login/token --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 4d74c0f4..125bab2b 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 = "18.1.6-ee.0"; + private const string LocalGitLabDockerVersion = "18.6.5-ee.0"; /// /// Resolved GitLab version taken from the help page once logged in @@ -208,8 +208,40 @@ private async Task SpawnDockerContainerAsync() { { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", new List { new PortBinding { HostPort = HttpPort.ToString(CultureInfo.InvariantCulture) } } }, }, + ShmSize = 512 * 1024 * 1024, }; + // 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", + + "gitlab_shell['log_level'] = 'WARN'", + "patroni['log_level'] = 'WARN'", + ]; + var response = await client.Containers.CreateContainerAsync(new CreateContainerParameters { Hostname = "localhost", @@ -223,8 +255,9 @@ 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)}", + "GITLAB_LOG_LEVEL=WARN", ], }).ConfigureAwait(false); @@ -351,16 +384,19 @@ async Task GenerateAdminToken(GitLabCredential credentials) await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); } + 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 @@ -528,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) @@ -572,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"); } From 2b90cc880b2f118fdd995854d02dda203cef2f7e Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Mon, 9 Feb 2026 10:21:09 -0500 Subject: [PATCH 4/9] Fix tests --- NGitLab.Tests/ProjectsTests.cs | 34 +++++++++- .../RepositoryClient/RepositoryClientTests.cs | 24 ++++++- NGitLab.Tests/RunnerTests.cs | 68 +++++++++++++++++++ NGitLab/Models/Project.cs | 3 + 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/NGitLab.Tests/ProjectsTests.cs b/NGitLab.Tests/ProjectsTests.cs index 0ab5ccd6..5f9e4165 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 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; } } From 0d41e870ecfae9698fb837b8d2674537b14a4905 Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Mon, 9 Feb 2026 11:18:09 -0500 Subject: [PATCH 5/9] fix public api --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 10 +++++----- NGitLab/PublicAPI/net472/PublicAPI.Unshipped.txt | 2 ++ NGitLab/PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 ++ .../PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt | 2 ++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 125bab2b..cf35bdfe 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 = "18.6.5-ee.0"; + private const string LocalGitLabDockerVersion = "18.1.6-ee.0"; /// /// Resolved GitLab version taken from the help page once logged in @@ -208,9 +208,13 @@ 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 intermitent 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 = [ @@ -237,9 +241,6 @@ private async Task SpawnDockerContainerAsync() "gitlab_rails['kerberos_enabled'] = false", "gitlab_rails['packages_enabled'] = false", "gitlab_rails['dependency_proxy_enabled'] = false", - - "gitlab_shell['log_level'] = 'WARN'", - "patroni['log_level'] = 'WARN'", ]; var response = await client.Containers.CreateContainerAsync(new CreateContainerParameters @@ -257,7 +258,6 @@ private async Task SpawnDockerContainerAsync() [ $"GITLAB_ROOT_PASSWORD={AdminPassword}", $"GITLAB_OMNIBUS_CONFIG={string.Join("; ", omnibusConfig)}", - "GITLAB_LOG_LEVEL=WARN", ], }).ConfigureAwait(false); 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 From f8ff4d00923b85c117a4731e8a0adea857b60287 Mon Sep 17 00:00:00 2001 From: Thomas Cortes <78750681+Toa741@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:08:29 -0500 Subject: [PATCH 6/9] Update NGitLab.Tests/Docker/GitLabDockerContainer.cs --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index cf35bdfe..68930915 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -210,7 +210,7 @@ private async Task SpawnDockerContainerAsync() }, // Update size of /dev/shm to to 512mb (default: 64mb) - // Avoids intermitent crashes of GitLab + // Avoids intermittent crashes of GitLab ShmSize = 512 * 1024 * 1024, }; From 1a34bda8a6be16aa9891c84d1ca375208d61d2cd Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Mon, 9 Feb 2026 14:01:03 -0500 Subject: [PATCH 7/9] with bom --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 2 +- NGitLab.Tests/ProjectsTests.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 68930915..745e4663 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -1,4 +1,4 @@ -#pragma warning disable MA0004 +#pragma warning disable MA0004 #pragma warning disable MA0006 using System; using System.Collections.Generic; diff --git a/NGitLab.Tests/ProjectsTests.cs b/NGitLab.Tests/ProjectsTests.cs index 5f9e4165..28fabd21 100644 --- a/NGitLab.Tests/ProjectsTests.cs +++ b/NGitLab.Tests/ProjectsTests.cs @@ -536,7 +536,7 @@ public async Task UpdateAsync_WhenProjectNotFound_ItThrows() } /// - /// On versions prior v18, projects where deleted immediately. + /// 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 . /// @@ -819,8 +819,8 @@ public async Task Test_project_groups_query_returns_ancestor_groups() } /// - /// 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. + /// 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] From a9409ac0ead624276a758fdaf611ee9884172f75 Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Mon, 9 Feb 2026 14:17:10 -0500 Subject: [PATCH 8/9] without bom --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 745e4663..68930915 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -1,4 +1,4 @@ -#pragma warning disable MA0004 +#pragma warning disable MA0004 #pragma warning disable MA0006 using System; using System.Collections.Generic; From 05156b908fcd151c3bb9c829b458f23298b69048 Mon Sep 17 00:00:00 2001 From: Thomas Cortes Date: Mon, 9 Feb 2026 15:14:56 -0500 Subject: [PATCH 9/9] test lf --- NGitLab.Tests/Docker/GitLabDockerContainer.cs | 1246 ++++++++--------- 1 file changed, 623 insertions(+), 623 deletions(-) diff --git a/NGitLab.Tests/Docker/GitLabDockerContainer.cs b/NGitLab.Tests/Docker/GitLabDockerContainer.cs index 68930915..54290949 100644 --- a/NGitLab.Tests/Docker/GitLabDockerContainer.cs +++ b/NGitLab.Tests/Docker/GitLabDockerContainer.cs @@ -1,623 +1,623 @@ -#pragma warning disable MA0004 -#pragma warning disable MA0006 -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Docker.DotNet; -using Docker.DotNet.Models; -using Microsoft.Playwright; -using NGitLab.Models; -using NuGet.Versioning; -using NUnit.Framework; -using Polly; - -namespace NGitLab.Tests.Docker; - -public class GitLabDockerContainer -{ - public const string ContainerName = "NGitLabClientTests"; - public const string ImageName = "gitlab/gitlab-ee"; - - /// - /// GitLab docker image version to spawn. - /// Used only on local environment (CI should already have a running GitLab instance from its services) - /// - /// - /// 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 = "18.1.6-ee.0"; - - /// - /// Resolved GitLab version taken from the help page once logged in - /// - private static string ResolvedGitLabVersion; - - private static string s_creationErrorMessage; - private static readonly SemaphoreSlim s_setupLock = new(initialCount: 1, maxCount: 1); - private static GitLabDockerContainer s_instance; - - public string Host { get; private set; } = "localhost"; - - public int HttpPort { get; private set; } = 48624; - - public string AdminUserName { get; } = "root"; - - public static string AdminPassword - { - get - { - var env = Environment.GetEnvironmentVariable("GITLAB_ROOT_PASSWORD"); - if (!string.IsNullOrEmpty(env)) - return env; - - return "Pa$$w0rd"; - } - } - - public string LicenseFile { get; set; } - - public Uri GitLabUrl => new("http://" + Host + ":" + HttpPort.ToString(CultureInfo.InvariantCulture)); - - public GitLabCredential Credentials { get; set; } - - public static async Task GetOrCreateInstance() - { - await s_setupLock.WaitAsync().ConfigureAwait(false); - try - { - if (s_instance == null) - { - if (s_creationErrorMessage != null) - { - Assert.Fail(s_creationErrorMessage); - } - - try - { - var instance = new GitLabDockerContainer(); - await instance.SetupAsync().ConfigureAwait(false); - s_instance = instance; - } - catch (Exception ex) - { - s_creationErrorMessage = ex.ToString(); - throw; - } - } - - return s_instance; - } - finally - { - s_setupLock.Release(); - } - } - - private async Task SetupAsync() - { - if (GitLabTestContext.IsContinuousIntegration()) - { - await WaitForCiGitLabInstance().ConfigureAwait(false); - } - else - { - await SpawnDockerContainerAsync().ConfigureAwait(false); - } - - EnsureChromiumIsInstalled(); - - // Use Playwright to launch Chromium - using var playwright = await Playwright.CreateAsync(); - await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions - { - // Headless = false, // Uncomment to have browser window visible - // SlowMo = 1000, // Slows down Playwright operations by the specified amount of ms. - }); - await using var browserContext = await browser.NewContextAsync(); - - await LoginAsync(browserContext); - await ResolveGitLabVersionAsync(browserContext).ConfigureAwait(false); - - await LoadCredentialsAsync().ConfigureAwait(false); - - if (Credentials != null) - { - Console.WriteLine("Using credentials from persisted credential file"); - return; - } - - await GenerateCredentialsAsync(browserContext).ConfigureAwait(false); - PersistCredentialsAsync(); - - static void EnsureChromiumIsInstalled() - { - TestContext.Progress.WriteLine("Making sure Chromium is installed"); - - var exitCode = Microsoft.Playwright.Program.Main(new[] { "install", "--force", "chromium", "--with-deps" }); - if (exitCode != 0) - throw new InvalidOperationException($"Cannot install browser (exit code: {exitCode})"); - - TestContext.Progress.WriteLine("Chromium installed"); - } - } - - private static async Task ValidateDockerIsEnabled(DockerClient client) - { - try - { - await client.Images.ListImagesAsync(new ImagesListParameters()).ConfigureAwait(false); - } - catch (ArgumentOutOfRangeException ex) when (ex.Message.StartsWith("The added or subtracted value results in an un-representable DateTime.", StringComparison.Ordinal)) - { - // Ignore https://github.com/rancher-sandbox/rancher-desktop/issues/5145 - } - catch (Exception ex) - { - s_creationErrorMessage = "Cannot connect to Docker service. Make sure it's running on your machine before launching any tests.\nDetails: " + ex; - Assert.Fail(s_creationErrorMessage); - } - } - - private async Task SpawnDockerContainerAsync() - { - Console.WriteLine($"Executing tests locally. Spawning GitLab docker image version '{LocalGitLabDockerVersion}'"); - using var httpClient = new HttpClient(); - - // Spawn the container - // https://docs.gitlab.com/omnibus/settings/configuration.html - using var conf = new DockerClientConfiguration(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")); - using var client = conf.CreateClient(); - await ValidateDockerIsEnabled(client); - - TestContext.Progress.WriteLine("Looking up GitLab Docker containers"); - var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); - var container = containers.FirstOrDefault(c => c.Names.Contains("/" + ContainerName, StringComparer.Ordinal)); - if (container != null) - { - TestContext.Progress.WriteLine("Verifying if the GitLab Docker container is using the right image"); - var inspect = await client.Containers.InspectContainerAsync(container.ID).ConfigureAwait(false); - var inspectImage = await client.Images.InspectImageAsync(ImageName + ":" + LocalGitLabDockerVersion).ConfigureAwait(false); - if (inspect.Image != inspectImage.ID) - { - TestContext.Progress.WriteLine("Ending GitLab Docker container, as it's using the wrong image"); - await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters { Force = true }).ConfigureAwait(false); - container = null; - } - } - - if (container == null) - { - // Download GitLab images - TestContext.Progress.WriteLine("Making sure the right GitLab Docker image is available locally"); - await client.Images.CreateImageAsync(new ImagesCreateParameters { FromImage = ImageName, Tag = LocalGitLabDockerVersion }, new AuthConfig(), new Progress()).ConfigureAwait(false); - - // Create the container - TestContext.Progress.WriteLine("Creating the GitLab Docker container"); - var hostConfig = new HostConfig - { - PortBindings = new Dictionary>(StringComparer.Ordinal) - { - { 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", - Image = ImageName + ":" + LocalGitLabDockerVersion, - Name = ContainerName, - Tty = false, - HostConfig = hostConfig, - ExposedPorts = new Dictionary(StringComparer.Ordinal) - { - { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", default }, - }, - Env = - [ - $"GITLAB_ROOT_PASSWORD={AdminPassword}", - $"GITLAB_OMNIBUS_CONFIG={string.Join("; ", omnibusConfig)}", - ], - }).ConfigureAwait(false); - - containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); - container = containers.First(c => c.ID == response.ID); - } - - // Start the container - if (container.State != "running") - { - TestContext.Progress.WriteLine("Starting the GitLab Docker container"); - var started = await client.Containers.StartContainerAsync(container.ID, new ContainerStartParameters()).ConfigureAwait(false); - if (!started) - { - Assert.Fail("Cannot start the Docker container"); - } - } - - // Wait for the container to be ready. - var stopwatch = Stopwatch.StartNew(); - while (true) - { - TestContext.Progress.WriteLine($@"Waiting for the GitLab Docker container to be ready ({stopwatch.Elapsed:mm\:ss})"); - var status = await client.Containers.InspectContainerAsync(container.ID); - if (!status.State.Running) - throw new InvalidOperationException($"Container '{status.ID}' is not running"); - - var healthState = status.State.Health.Status; - - // unhealthy is valid as long as the container is running as it may indicate a slow creation - if (healthState is "starting" or "unhealthy") - { - } - else if (healthState is "healthy") - { - // A healthy container doesn't mean the service is actually running. - // GitLab has lots of configuration steps that are still running when the container is healthy. - try - { - using var response = await httpClient.GetAsync(GitLabUrl).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - break; - } - catch - { - } - } - else - { - throw new InvalidOperationException($"Container status '{healthState}' is not supported"); - } - - await Task.Delay(5000); - } - - TestContext.Progress.WriteLine("GitLab Docker container is ready"); - } - - private async Task GenerateCredentialsAsync(IBrowserContext browserContext) - { - Console.WriteLine("Requesting credentials from GitLab instance"); - - var credentials = new GitLabCredential(); - await GenerateAdminToken(credentials).ConfigureAwait(false); - if (credentials.AdminUserToken != null) - { - GenerateUserToken(); - } - - Credentials = credentials; - - async Task GenerateAdminToken(GitLabCredential credentials) - { - TestContext.Progress.WriteLine("Generating 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(new Uri(GitLabUrl, accessTokenRelativeUri).ToString()); - - var formLocator = page.Locator("main#content-body form"); - - var tokenName = "GitLabClientTest-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); - - if (isMajorVersionAtLeast18) - { - 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) - { - await SkipVersionReminder(page); - - await page.Locator("main[id='content-body'] button[data-testid='add-new-token-button']").ClickAsync(new LocatorClickOptions { Timeout = 5_000 }); - 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)}'."; - Assert.Fail(s_creationErrorMessage); - } - - foreach (var checkbox in await formLocator.GetByRole(AriaRole.Checkbox).AllAsync()) - { - await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); - } - - 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"); - } - - credentials.AdminUserToken = token; - - // Get admin login cookie - // result.Cookie: experimentation_subject_id=XXX; _gitlab_session=XXXX; known_sign_in=XXXX - TestContext.Progress.WriteLine("Extracting GitLab session cookie"); - var cookies = await browserContext.CookiesAsync(new[] { GitLabUrl.AbsoluteUri }); - foreach (var cookie in cookies) - { - if (cookie.Name == "_gitlab_session") - { - credentials.AdminCookies = cookie.Value; - break; - } - } - } - - void GenerateUserToken() - { - var retryPolicy = Policy.Handle().WaitAndRetry(10, _ => TimeSpan.FromSeconds(1)); - var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); - var user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); - if (user == null) - { - try - { - user = retryPolicy.Execute(() => client.Users.Create(new UserUpsert - { - Username = "common_user", - Email = "common_user@example.com", - IsAdmin = false, - Name = "common_user", - SkipConfirmation = true, - ResetPassword = false, - Password = AdminPassword, - IsPrivateProfile = true, // Set profile to private for LastActivity test cases - })); - } - catch (GitLabException) - { - user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); - if (user == null) - throw new InvalidOperationException("Cannot create the common user"); - } - } - - var token = retryPolicy.Execute(() => client.Users.CreateToken(new UserTokenCreate - { - UserId = user.Id, - Name = "common_user", - Scopes = new[] { "api" }, - ExpiresAt = DateTime.UtcNow.AddDays(7), - })); - - credentials.UserToken = token.Token; - } - } - - private static async Task SkipVersionReminder(IPage page) - { - try - { - await page.Locator("button[data-testid='alert-modal-remind-button']").ClickAsync(new LocatorClickOptions { Timeout = 3_000 }); - } - catch (Exception) - { - } - } - - private void PersistCredentialsAsync() - { - var path = GetCredentialsFilePath(); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - var json = JsonSerializer.Serialize(Credentials); - File.WriteAllText(path, json); - } - - private async Task LoadCredentialsAsync() - { - var file = GetCredentialsFilePath(); - if (File.Exists(file)) - { - var json = File.ReadAllText(file); - var credentials = JsonSerializer.Deserialize(json); - if (credentials.AdminUserToken == null || credentials.UserToken == null) - return; - - var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); - try - { - // Validate token - var user = client.Users.Current; - - using var httpClient = new HttpClient - { - BaseAddress = GitLabUrl, - DefaultRequestHeaders = - { - { "Cookie", "_gitlab_session=" + credentials.AdminCookies }, - }, - }; - var response = await httpClient.GetAsync(new Uri("/", UriKind.RelativeOrAbsolute)); - if (response.RequestMessage.RequestUri.PathAndQuery == "/users/sign_in") - return; - - // Validate cookie - Credentials = credentials; - } - catch (GitLabException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) - { - } - } - } - - private static string GetCredentialsFilePath() - { - return Path.Combine(Path.GetTempPath(), "ngitlab", "credentials.json"); - } - - private async Task WaitForCiGitLabInstance() - { - Console.WriteLine($"Executing tests on CI. Checking GitLab instance..."); - - using var httpClient = new HttpClient(); - Console.WriteLine("Testing " + GitLabUrl); - - var now = Stopwatch.StartNew(); - while (now.Elapsed < TimeSpan.FromMinutes(10)) - { - try - { - var result = await httpClient.GetStringAsync(GitLabUrl).ConfigureAwait(false); - return; - } - catch - { - } - - await Task.Delay(1000); - } - - s_creationErrorMessage = "GitLab is not well configured in CI"; - Assert.Fail(s_creationErrorMessage); - } - - private async Task ResolveGitLabVersionAsync(IBrowserContext browserContext) - { - Console.WriteLine("Resolving GitLab version from help page..."); - var page = await browserContext.NewPageAsync(); - await page.GotoAsync(new Uri(GitLabUrl, "help").AbsoluteUri); - var titleLink = await page.QuerySelectorAsync("h1 a"); - - if (titleLink is null) - { - s_creationErrorMessage = "Cannot find title on the help page to get GitLab version"; - Assert.Fail(s_creationErrorMessage); - } - - var version = await titleLink.TextContentAsync(); - - if (string.IsNullOrEmpty(version)) - { - s_creationErrorMessage = "Found title on the help page, but the version is empty"; - Assert.Fail(s_creationErrorMessage); - } - - ResolvedGitLabVersion = version.Trim().TrimStart('v'); - Console.WriteLine($"GitLab resolved version is '{ResolvedGitLabVersion}'"); - - await CloseRedesignModal(page); - } - - private async Task LoginAsync(IBrowserContext browserContext) - { - var page = await browserContext.NewPageAsync(); - await page.GotoAsync(GitLabUrl.AbsoluteUri); - var url = await GetCurrentUrl(page); - - if (url != "/users/sign_in") - { - Console.WriteLine("Already logged in on GitLab instance"); - return; - } - - Console.WriteLine("Logging in on GitLab instance..."); - - var v15LoginInput = "form#new_user input[name='user[login]']"; - var v16LoginInput = "form[data-testid='sign-in-form'] input[name='user[login]']"; - - if (await page.QuerySelectorAsync(v15LoginInput) is not null) - { - await page.Locator(v15LoginInput).FillAsync(AdminUserName); - await page.Locator("form#new_user input[name='user[password]']").FillAsync(AdminPassword); - } - else if (await page.QuerySelectorAsync(v16LoginInput) is not null) - { - await page.Locator(v16LoginInput).FillAsync(AdminUserName); - await page.Locator("form[data-testid='sign-in-form'] input[name='user[password]']").FillAsync(AdminPassword); - } - else - { - s_creationErrorMessage = $"Unable to find the correct login input. Please make sure that login form for the GitLab version you target is supported in '{nameof(LoginAsync)}'"; - Assert.Fail(s_creationErrorMessage); - } - - var checkbox = page.Locator("form[data-testid='sign-in-form'] input[type=checkbox][name='user[remember_me]']"); - await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); - - await page.RunAndWaitForResponseAsync(async () => - { - await page.EvalOnSelectorAsync("form[data-testid='sign-in-form']", "form => form.submit()"); - }, 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"); -} +#pragma warning disable MA0004 +#pragma warning disable MA0006 +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Playwright; +using NGitLab.Models; +using NuGet.Versioning; +using NUnit.Framework; +using Polly; + +namespace NGitLab.Tests.Docker; + +public class GitLabDockerContainer +{ + public const string ContainerName = "NGitLabClientTests"; + public const string ImageName = "gitlab/gitlab-ee"; + + /// + /// GitLab docker image version to spawn. + /// Used only on local environment (CI should already have a running GitLab instance from its services) + /// + /// + /// 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 = "18.1.6-ee.0"; + + /// + /// Resolved GitLab version taken from the help page once logged in + /// + private static string ResolvedGitLabVersion; + + private static string s_creationErrorMessage; + private static readonly SemaphoreSlim s_setupLock = new(initialCount: 1, maxCount: 1); + private static GitLabDockerContainer s_instance; + + public string Host { get; private set; } = "localhost"; + + public int HttpPort { get; private set; } = 48624; + + public string AdminUserName { get; } = "root"; + + public static string AdminPassword + { + get + { + var env = Environment.GetEnvironmentVariable("GITLAB_ROOT_PASSWORD"); + if (!string.IsNullOrEmpty(env)) + return env; + + return "Pa$$w0rd"; + } + } + + public string LicenseFile { get; set; } + + public Uri GitLabUrl => new("http://" + Host + ":" + HttpPort.ToString(CultureInfo.InvariantCulture)); + + public GitLabCredential Credentials { get; set; } + + public static async Task GetOrCreateInstance() + { + await s_setupLock.WaitAsync().ConfigureAwait(false); + try + { + if (s_instance == null) + { + if (s_creationErrorMessage != null) + { + Assert.Fail(s_creationErrorMessage); + } + + try + { + var instance = new GitLabDockerContainer(); + await instance.SetupAsync().ConfigureAwait(false); + s_instance = instance; + } + catch (Exception ex) + { + s_creationErrorMessage = ex.ToString(); + throw; + } + } + + return s_instance; + } + finally + { + s_setupLock.Release(); + } + } + + private async Task SetupAsync() + { + if (GitLabTestContext.IsContinuousIntegration()) + { + await WaitForCiGitLabInstance().ConfigureAwait(false); + } + else + { + await SpawnDockerContainerAsync().ConfigureAwait(false); + } + + EnsureChromiumIsInstalled(); + + // Use Playwright to launch Chromium + using var playwright = await Playwright.CreateAsync(); + await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + // Headless = false, // Uncomment to have browser window visible + // SlowMo = 1000, // Slows down Playwright operations by the specified amount of ms. + }); + await using var browserContext = await browser.NewContextAsync(); + + await LoginAsync(browserContext); + await ResolveGitLabVersionAsync(browserContext).ConfigureAwait(false); + + await LoadCredentialsAsync().ConfigureAwait(false); + + if (Credentials != null) + { + Console.WriteLine("Using credentials from persisted credential file"); + return; + } + + await GenerateCredentialsAsync(browserContext).ConfigureAwait(false); + PersistCredentialsAsync(); + + static void EnsureChromiumIsInstalled() + { + TestContext.Progress.WriteLine("Making sure Chromium is installed"); + + var exitCode = Microsoft.Playwright.Program.Main(new[] { "install", "--force", "chromium", "--with-deps" }); + if (exitCode != 0) + throw new InvalidOperationException($"Cannot install browser (exit code: {exitCode})"); + + TestContext.Progress.WriteLine("Chromium installed"); + } + } + + private static async Task ValidateDockerIsEnabled(DockerClient client) + { + try + { + await client.Images.ListImagesAsync(new ImagesListParameters()).ConfigureAwait(false); + } + catch (ArgumentOutOfRangeException ex) when (ex.Message.StartsWith("The added or subtracted value results in an un-representable DateTime.", StringComparison.Ordinal)) + { + // Ignore https://github.com/rancher-sandbox/rancher-desktop/issues/5145 + } + catch (Exception ex) + { + s_creationErrorMessage = "Cannot connect to Docker service. Make sure it's running on your machine before launching any tests.\nDetails: " + ex; + Assert.Fail(s_creationErrorMessage); + } + } + + private async Task SpawnDockerContainerAsync() + { + Console.WriteLine($"Executing tests locally. Spawning GitLab docker image version '{LocalGitLabDockerVersion}'"); + using var httpClient = new HttpClient(); + + // Spawn the container + // https://docs.gitlab.com/omnibus/settings/configuration.html + using var conf = new DockerClientConfiguration(new Uri(OperatingSystem.IsWindows() ? "npipe://./pipe/docker_engine" : "unix:///var/run/docker.sock")); + using var client = conf.CreateClient(); + await ValidateDockerIsEnabled(client); + + TestContext.Progress.WriteLine("Looking up GitLab Docker containers"); + var containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); + var container = containers.FirstOrDefault(c => c.Names.Contains("/" + ContainerName, StringComparer.Ordinal)); + if (container != null) + { + TestContext.Progress.WriteLine("Verifying if the GitLab Docker container is using the right image"); + var inspect = await client.Containers.InspectContainerAsync(container.ID).ConfigureAwait(false); + var inspectImage = await client.Images.InspectImageAsync(ImageName + ":" + LocalGitLabDockerVersion).ConfigureAwait(false); + if (inspect.Image != inspectImage.ID) + { + TestContext.Progress.WriteLine("Ending GitLab Docker container, as it's using the wrong image"); + await client.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters { Force = true }).ConfigureAwait(false); + container = null; + } + } + + if (container == null) + { + // Download GitLab images + TestContext.Progress.WriteLine("Making sure the right GitLab Docker image is available locally"); + await client.Images.CreateImageAsync(new ImagesCreateParameters { FromImage = ImageName, Tag = LocalGitLabDockerVersion }, new AuthConfig(), new Progress()).ConfigureAwait(false); + + // Create the container + TestContext.Progress.WriteLine("Creating the GitLab Docker container"); + var hostConfig = new HostConfig + { + PortBindings = new Dictionary>(StringComparer.Ordinal) + { + { 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", + Image = ImageName + ":" + LocalGitLabDockerVersion, + Name = ContainerName, + Tty = false, + HostConfig = hostConfig, + ExposedPorts = new Dictionary(StringComparer.Ordinal) + { + { HttpPort.ToString(CultureInfo.InvariantCulture) + "/tcp", default }, + }, + Env = + [ + $"GITLAB_ROOT_PASSWORD={AdminPassword}", + $"GITLAB_OMNIBUS_CONFIG={string.Join("; ", omnibusConfig)}", + ], + }).ConfigureAwait(false); + + containers = await client.Containers.ListContainersAsync(new ContainersListParameters { All = true }).ConfigureAwait(false); + container = containers.First(c => c.ID == response.ID); + } + + // Start the container + if (container.State != "running") + { + TestContext.Progress.WriteLine("Starting the GitLab Docker container"); + var started = await client.Containers.StartContainerAsync(container.ID, new ContainerStartParameters()).ConfigureAwait(false); + if (!started) + { + Assert.Fail("Cannot start the Docker container"); + } + } + + // Wait for the container to be ready. + var stopwatch = Stopwatch.StartNew(); + while (true) + { + TestContext.Progress.WriteLine($@"Waiting for the GitLab Docker container to be ready ({stopwatch.Elapsed:mm\:ss})"); + var status = await client.Containers.InspectContainerAsync(container.ID); + if (!status.State.Running) + throw new InvalidOperationException($"Container '{status.ID}' is not running"); + + var healthState = status.State.Health.Status; + + // unhealthy is valid as long as the container is running as it may indicate a slow creation + if (healthState is "starting" or "unhealthy") + { + } + else if (healthState is "healthy") + { + // A healthy container doesn't mean the service is actually running. + // GitLab has lots of configuration steps that are still running when the container is healthy. + try + { + using var response = await httpClient.GetAsync(GitLabUrl).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + break; + } + catch + { + } + } + else + { + throw new InvalidOperationException($"Container status '{healthState}' is not supported"); + } + + await Task.Delay(5000); + } + + TestContext.Progress.WriteLine("GitLab Docker container is ready"); + } + + private async Task GenerateCredentialsAsync(IBrowserContext browserContext) + { + Console.WriteLine("Requesting credentials from GitLab instance"); + + var credentials = new GitLabCredential(); + await GenerateAdminToken(credentials).ConfigureAwait(false); + if (credentials.AdminUserToken != null) + { + GenerateUserToken(); + } + + Credentials = credentials; + + async Task GenerateAdminToken(GitLabCredential credentials) + { + TestContext.Progress.WriteLine("Generating 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(new Uri(GitLabUrl, accessTokenRelativeUri).ToString()); + + var formLocator = page.Locator("main#content-body form"); + + var tokenName = "GitLabClientTest-" + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + + if (isMajorVersionAtLeast18) + { + 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) + { + await SkipVersionReminder(page); + + await page.Locator("main[id='content-body'] button[data-testid='add-new-token-button']").ClickAsync(new LocatorClickOptions { Timeout = 5_000 }); + 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)}'."; + Assert.Fail(s_creationErrorMessage); + } + + foreach (var checkbox in await formLocator.GetByRole(AriaRole.Checkbox).AllAsync()) + { + await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); + } + + 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"); + } + + credentials.AdminUserToken = token; + + // Get admin login cookie + // result.Cookie: experimentation_subject_id=XXX; _gitlab_session=XXXX; known_sign_in=XXXX + TestContext.Progress.WriteLine("Extracting GitLab session cookie"); + var cookies = await browserContext.CookiesAsync(new[] { GitLabUrl.AbsoluteUri }); + foreach (var cookie in cookies) + { + if (cookie.Name == "_gitlab_session") + { + credentials.AdminCookies = cookie.Value; + break; + } + } + } + + void GenerateUserToken() + { + var retryPolicy = Policy.Handle().WaitAndRetry(10, _ => TimeSpan.FromSeconds(1)); + var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); + var user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); + if (user == null) + { + try + { + user = retryPolicy.Execute(() => client.Users.Create(new UserUpsert + { + Username = "common_user", + Email = "common_user@example.com", + IsAdmin = false, + Name = "common_user", + SkipConfirmation = true, + ResetPassword = false, + Password = AdminPassword, + IsPrivateProfile = true, // Set profile to private for LastActivity test cases + })); + } + catch (GitLabException) + { + user = retryPolicy.Execute(() => client.Users.Get("common_user")).FirstOrDefault(); + if (user == null) + throw new InvalidOperationException("Cannot create the common user"); + } + } + + var token = retryPolicy.Execute(() => client.Users.CreateToken(new UserTokenCreate + { + UserId = user.Id, + Name = "common_user", + Scopes = new[] { "api" }, + ExpiresAt = DateTime.UtcNow.AddDays(7), + })); + + credentials.UserToken = token.Token; + } + } + + private static async Task SkipVersionReminder(IPage page) + { + try + { + await page.Locator("button[data-testid='alert-modal-remind-button']").ClickAsync(new LocatorClickOptions { Timeout = 3_000 }); + } + catch (Exception) + { + } + } + + private void PersistCredentialsAsync() + { + var path = GetCredentialsFilePath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonSerializer.Serialize(Credentials); + File.WriteAllText(path, json); + } + + private async Task LoadCredentialsAsync() + { + var file = GetCredentialsFilePath(); + if (File.Exists(file)) + { + var json = File.ReadAllText(file); + var credentials = JsonSerializer.Deserialize(json); + if (credentials.AdminUserToken == null || credentials.UserToken == null) + return; + + var client = new GitLabClient(GitLabUrl.ToString(), credentials.AdminUserToken); + try + { + // Validate token + var user = client.Users.Current; + + using var httpClient = new HttpClient + { + BaseAddress = GitLabUrl, + DefaultRequestHeaders = + { + { "Cookie", "_gitlab_session=" + credentials.AdminCookies }, + }, + }; + var response = await httpClient.GetAsync(new Uri("/", UriKind.RelativeOrAbsolute)); + if (response.RequestMessage.RequestUri.PathAndQuery == "/users/sign_in") + return; + + // Validate cookie + Credentials = credentials; + } + catch (GitLabException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) + { + } + } + } + + private static string GetCredentialsFilePath() + { + return Path.Combine(Path.GetTempPath(), "ngitlab", "credentials.json"); + } + + private async Task WaitForCiGitLabInstance() + { + Console.WriteLine($"Executing tests on CI. Checking GitLab instance..."); + + using var httpClient = new HttpClient(); + Console.WriteLine("Testing " + GitLabUrl); + + var now = Stopwatch.StartNew(); + while (now.Elapsed < TimeSpan.FromMinutes(10)) + { + try + { + var result = await httpClient.GetStringAsync(GitLabUrl).ConfigureAwait(false); + return; + } + catch + { + } + + await Task.Delay(1000); + } + + s_creationErrorMessage = "GitLab is not well configured in CI"; + Assert.Fail(s_creationErrorMessage); + } + + private async Task ResolveGitLabVersionAsync(IBrowserContext browserContext) + { + Console.WriteLine("Resolving GitLab version from help page..."); + var page = await browserContext.NewPageAsync(); + await page.GotoAsync(new Uri(GitLabUrl, "help").AbsoluteUri); + var titleLink = await page.QuerySelectorAsync("h1 a"); + + if (titleLink is null) + { + s_creationErrorMessage = "Cannot find title on the help page to get GitLab version"; + Assert.Fail(s_creationErrorMessage); + } + + var version = await titleLink.TextContentAsync(); + + if (string.IsNullOrEmpty(version)) + { + s_creationErrorMessage = "Found title on the help page, but the version is empty"; + Assert.Fail(s_creationErrorMessage); + } + + ResolvedGitLabVersion = version.Trim().TrimStart('v'); + Console.WriteLine($"GitLab resolved version is '{ResolvedGitLabVersion}'"); + + await CloseRedesignModal(page); + } + + private async Task LoginAsync(IBrowserContext browserContext) + { + var page = await browserContext.NewPageAsync(); + await page.GotoAsync(GitLabUrl.AbsoluteUri); + var url = await GetCurrentUrl(page); + + if (url != "/users/sign_in") + { + Console.WriteLine("Already logged in on GitLab instance"); + return; + } + + Console.WriteLine("Logging in on GitLab instance..."); + + var v15LoginInput = "form#new_user input[name='user[login]']"; + var v16LoginInput = "form[data-testid='sign-in-form'] input[name='user[login]']"; + + if (await page.QuerySelectorAsync(v15LoginInput) is not null) + { + await page.Locator(v15LoginInput).FillAsync(AdminUserName); + await page.Locator("form#new_user input[name='user[password]']").FillAsync(AdminPassword); + } + else if (await page.QuerySelectorAsync(v16LoginInput) is not null) + { + await page.Locator(v16LoginInput).FillAsync(AdminUserName); + await page.Locator("form[data-testid='sign-in-form'] input[name='user[password]']").FillAsync(AdminPassword); + } + else + { + s_creationErrorMessage = $"Unable to find the correct login input. Please make sure that login form for the GitLab version you target is supported in '{nameof(LoginAsync)}'"; + Assert.Fail(s_creationErrorMessage); + } + + var checkbox = page.Locator("form[data-testid='sign-in-form'] input[type=checkbox][name='user[remember_me]']"); + await checkbox.CheckAsync(new LocatorCheckOptions { Force = true }); + + await page.RunAndWaitForResponseAsync(async () => + { + await page.EvalOnSelectorAsync("form[data-testid='sign-in-form']", "form => form.submit()"); + }, 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"); +}