Tim Jones 2 недель назад
Родитель
Сommit
54daf66e86

+ 10 - 0
RackPeek.Domain/Git/LibGit2GitRepository.cs

@@ -35,10 +35,19 @@ public sealed class LibGit2GitRepository : IGitRepository {
         bool insecureTls = false) {
         _configDirectory = configDirectory;
         _credentials = credentialsProvider.GetHandler();
+        // The user opted into git by setting GIT_TOKEN. Auto-init on a fresh
+        // config directory so the UI can immediately offer Add Remote — without
+        // this, first-time users hit "Git is not available." on every action.
+        // Init is idempotent for existing repos (IsValid skips the call) and
+        // does not touch existing files; it only creates .git/.
+        if (Directory.Exists(configDirectory) && !Repository.IsValid(configDirectory))
+            Repository.Init(configDirectory);
+
         _isAvailable = Repository.IsValid(configDirectory);
         // When insecureTls is true, accept any TLS certificate. Required for
         // self-hosted forges (Gitea, GitLab) behind a private CA or self-signed
         // cert. Public hosts already ship trusted certs; leave it off for them.
+        InsecureTls = insecureTls;
         _certificateCheck = insecureTls
             ? (_, _, _) => true
             : null;
@@ -57,6 +66,7 @@ public sealed class LibGit2GitRepository : IGitRepository {
     private bool _isAvailable;
 
     public bool IsAvailable => _isAvailable;
+    public bool InsecureTls { get; }
 
     public void Init() {
         Repository.Init(_configDirectory);

+ 1 - 1
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -28,7 +28,7 @@ public static class ServiceCollectionExtensions {
         IConfiguration config,
         string? yamlPath = null) {
         var gitToken = config["GIT_TOKEN"];
-        if (!string.IsNullOrEmpty(gitToken) && !string.IsNullOrWhiteSpace(yamlPath)) {
+        if (!string.IsNullOrWhiteSpace(gitToken) && !string.IsNullOrWhiteSpace(yamlPath)) {
             var gitUsername = config["GIT_USERNAME"] ?? "git";
             var insecureTls = string.Equals(
                 config["GIT_INSECURE_TLS"], "true", StringComparison.OrdinalIgnoreCase);

+ 8 - 1
Shared.Rcl/Layout/GitStatusIndicator.razor

@@ -43,7 +43,14 @@
         </button>
     }
 
-    @if (!_hasRemote)
+    @if (_status == GitRepoStatus.NotAvailable)
+    {
+        <span class="flex items-center gap-1 text-amber-400" data-testid="git-unavailable">
+            <span class="w-2 h-2 rounded-full bg-amber-400"></span>
+            Git configured but config directory is not writable
+        </span>
+    }
+    else if (!_hasRemote)
     {
         <span class="text-zinc-600">·</span>
 

+ 10 - 2
Shared.Rcl/wwwroot/raw_docs/git-integration.md

@@ -75,8 +75,10 @@ docker run -d \
 ## Wiring up the remote
 
 Open RackPeek in the browser. With `GIT_TOKEN` set, a Git status indicator
-appears in the header. Enable Git when prompted, then enter the repository
-remote URL — for example:
+appears in the header. RackPeek initialises a local git repository inside
+the config directory automatically on startup — no extra click required.
+Click **Add Remote** in the indicator and enter the repository URL — for
+example:
 
 * `https://github.com/youruser/rackpeek-config.git`
 * `https://gitea.example.com/youruser/rackpeek-config.git`
@@ -84,6 +86,12 @@ remote URL — for example:
 
 RackPeek will commit and sync configuration changes from there on.
 
+If the indicator shows **"Git configured but config directory is not
+writable"**, the container can't create the `.git/` folder inside
+`/app/config`. This is almost always a host filesystem permission issue
+on a bind mount — see the [Installation Guide](install-guide) for the
+ownership fix (`chown -R 1000:1000 /path/on/host/rackpeek`).
+
 ## Security notes
 
 * The token is read from the environment at container start; it is not

+ 78 - 0
Tests/Git/AddRemoteUseCaseTests.cs

@@ -0,0 +1,78 @@
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace Tests.Git;
+
+public sealed class AddRemoteUseCaseTests : IDisposable {
+    private readonly string _tempDir;
+    private readonly IGitRepository _repo;
+    private readonly AddRemoteUseCase _useCase;
+
+    public AddRemoteUseCaseTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-add-remote-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+
+        _repo = new LibGit2GitRepository(
+            _tempDir,
+            new TokenCredentialsProvider("test", "test-token"));
+
+        _useCase = new AddRemoteUseCase(_repo);
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, true);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    [Theory]
+    [InlineData("https://github.com/youruser/rackpeek-config.git")]
+    [InlineData("https://gitea.example.com/youruser/rackpeek-config.git")]
+    [InlineData("https://gitlab.example.com/youruser/rackpeek-config.git")]
+    public async Task Accepts_Documented_HTTPS_Examples(string url) {
+        // The doc lists these three URL shapes as supported. Use case must
+        // not reject them as malformed — a fetch failure (no real remote) is
+        // acceptable but "Only HTTPS URLs are supported" is not.
+        var error = await _useCase.ExecuteAsync(url);
+
+        Assert.DoesNotContain("Only HTTPS URLs are supported", error ?? string.Empty);
+        Assert.DoesNotContain("Git is not available", error ?? string.Empty);
+    }
+
+    [Theory]
+    [InlineData("http://github.com/u/r.git")]
+    [InlineData("ssh://git@github.com/u/r.git")]
+    [InlineData("git@github.com:u/r.git")]
+    [InlineData("file:///tmp/repo")]
+    public async Task Rejects_Non_HTTPS_URLs(string url) {
+        // Doc: "RackPeek does not use any host-specific APIs; the integration
+        // is plain git over HTTPS." Only HTTPS is supported on purpose — SSH
+        // would need a different credentials flow we don't offer.
+        var error = await _useCase.ExecuteAsync(url);
+
+        Assert.Equal("Only HTTPS URLs are supported.", error);
+    }
+
+    [Fact]
+    public async Task Rejects_Empty_URL() {
+        var error = await _useCase.ExecuteAsync("   ");
+        Assert.Equal("URL is required.", error);
+    }
+
+    [Fact]
+    public async Task Rejects_When_Remote_Already_Configured() {
+        await _useCase.ExecuteAsync("https://example.com/first.git");
+
+        var error = await _useCase.ExecuteAsync("https://example.com/second.git");
+
+        Assert.Equal("Remote already configured.", error);
+    }
+}

+ 176 - 0
Tests/Git/GitConfigurationTests.cs

@@ -0,0 +1,176 @@
+using LibGit2Sharp;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain;
+using RackPeek.Domain.Git;
+
+namespace Tests.Git;
+
+[Collection("Git static state")]
+public sealed class GitConfigurationTests : IDisposable {
+    private readonly string _tempDir;
+
+    public GitConfigurationTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-git-cfg-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, true);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    private static IConfiguration BuildConfig(Dictionary<string, string?> values) =>
+        new ConfigurationBuilder()
+            .AddInMemoryCollection(values)
+            .Build();
+
+    [Fact]
+    public void Missing_Git_Token_Registers_NullGitRepository() {
+        // Doc: GIT_TOKEN is required to enable the integration. Without it the
+        // indicator never appears (RpkConstants.HasGitServices stays false) and
+        // every git call is a no-op via NullGitRepository.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?>()), _tempDir);
+
+        ServiceProvider provider = services.BuildServiceProvider();
+        IGitRepository repo = provider.GetRequiredService<IGitRepository>();
+
+        Assert.IsType<NullGitRepository>(repo);
+        Assert.False(RpkConstants.HasGitServices);
+    }
+
+    [Fact]
+    public void Empty_Git_Token_Registers_NullGitRepository() {
+        // An empty/whitespace GIT_TOKEN must be treated the same as unset.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "   "
+        }), _tempDir);
+
+        ServiceProvider provider = services.BuildServiceProvider();
+        IGitRepository repo = provider.GetRequiredService<IGitRepository>();
+
+        Assert.IsType<NullGitRepository>(repo);
+        Assert.False(RpkConstants.HasGitServices);
+    }
+
+    [Fact]
+    public void Git_Token_Set_Registers_LibGit2GitRepository_And_Flips_HasGitServices() {
+        // Doc: with GIT_TOKEN set, a Git status indicator appears in the
+        // header. The header gates the indicator on RpkConstants.HasGitServices.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_USERNAME"] = "octocat"
+        }), _tempDir);
+
+        ServiceProvider provider = services.BuildServiceProvider();
+        IGitRepository repo = provider.GetRequiredService<IGitRepository>();
+
+        Assert.IsType<LibGit2GitRepository>(repo);
+        Assert.True(RpkConstants.HasGitServices);
+    }
+
+    [Fact]
+    public void Git_Username_Defaults_To_git_When_Only_Token_Provided() {
+        // Doc table says GIT_USERNAME is "required", but in practice the code
+        // defaults it to the conventional "git" so users with personal access
+        // tokens that don't care about the username (e.g. GitHub fine-grained
+        // tokens) still authenticate correctly.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token"
+        }), _tempDir);
+
+        IGitCredentialsProvider creds = services.BuildServiceProvider()
+            .GetRequiredService<IGitCredentialsProvider>();
+
+        LibGit2Sharp.Handlers.CredentialsHandler handler = creds.GetHandler();
+        var credentials = (UsernamePasswordCredentials)handler("https://example.com", null!,
+            SupportedCredentialTypes.UsernamePassword);
+
+        Assert.Equal("git", credentials.Username);
+        Assert.Equal("ghp_test_token", credentials.Password);
+    }
+
+    [Fact]
+    public void Insecure_Tls_Flag_Defaults_To_False() {
+        // Doc: GIT_INSECURE_TLS is optional. Default behaviour must validate
+        // certificates so we don't silently accept MITM on public hosts.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.False(repo.InsecureTls);
+    }
+
+    [Fact]
+    public void Insecure_Tls_True_Is_Plumbed_Through_To_Repository() {
+        // Doc: GIT_INSECURE_TLS=true skips TLS validation. The flag must
+        // reach the repository instance that runs push/pull/fetch.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_INSECURE_TLS"] = "true"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.True(repo.InsecureTls);
+    }
+
+    [Fact]
+    public void Insecure_Tls_String_Comparison_Is_Case_Insensitive() {
+        // Common YAML/.env idiom is "True" or "TRUE"; the doc shows lowercase
+        // but users will type whatever feels natural.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_INSECURE_TLS"] = "TRUE"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.True(repo.InsecureTls);
+    }
+
+    [Fact]
+    public void Insecure_Tls_Any_Other_Value_Means_False() {
+        // Only "true" enables the bypass — strings like "yes", "1", etc.
+        // must NOT silently disable TLS.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_INSECURE_TLS"] = "yes"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.False(repo.InsecureTls);
+    }
+}

+ 118 - 0
Tests/Git/GitIntegrationDocClaimsTests.cs

@@ -0,0 +1,118 @@
+using LibGit2Sharp;
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace Tests.Git;
+
+/// <summary>
+///     Holds tests that pin doc-only claims (UI strings, security promises,
+///     architectural statements) which don't fit cleanly inside one component
+///     test class.
+/// </summary>
+public sealed class GitIntegrationDocClaimsTests : IDisposable {
+    private readonly string _tempDir;
+
+    public GitIntegrationDocClaimsTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-doc-claims-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, true);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    [Fact]
+    public async Task Token_Never_Touches_The_Config_Directory() {
+        // Doc: "The token is read from the environment at container start; it
+        // is not persisted in the YAML config." Stronger guarantee: it must
+        // never appear in ANY file under the config directory, including the
+        // .git folder, so a leaked backup never leaks the token.
+        var token = $"sentinel-token-{Guid.NewGuid():N}";
+
+        var configPath = Path.Combine(_tempDir, "config.yaml");
+        await File.WriteAllTextAsync(configPath, "resources: []\n");
+
+        var repo = new LibGit2GitRepository(
+            _tempDir,
+            new TokenCredentialsProvider("octocat", token));
+
+        var addRemote = new AddRemoteUseCase(repo);
+        await addRemote.ExecuteAsync("https://example.com/user/repo.git");
+
+        var commit = new CommitAllUseCase(repo);
+        await commit.ExecuteAsync("test commit");
+
+        // Recursively scan everything under the config dir for the sentinel.
+        foreach (var file in Directory.EnumerateFiles(_tempDir, "*", SearchOption.AllDirectories)) {
+            var bytes = await File.ReadAllBytesAsync(file);
+            // tokens are ASCII; cheaper than decoding every binary file as text
+            var contents = System.Text.Encoding.ASCII.GetString(bytes);
+            Assert.DoesNotContain(token, contents);
+        }
+    }
+
+    [Fact]
+    public void Writability_Warning_String_In_UI_Matches_Documented_Wording() {
+        // Doc: 'If the indicator shows "Git configured but config directory is
+        // not writable", the container can't create the .git/ folder...'.
+        // Lock that exact wording so the doc and UI cannot drift out of sync.
+        // The tests run from Tests/bin/Debug/net10.0; walk up to the repo root.
+        var razorPath = LocateRepoFile("Shared.Rcl/Layout/GitStatusIndicator.razor");
+
+        var contents = File.ReadAllText(razorPath);
+
+        Assert.Contains(
+            "Git configured but config directory is not writable",
+            contents);
+    }
+
+    [Fact]
+    public void Integration_Uses_LibGit2_And_Not_System_Git() {
+        // Doc callout: "You do not need to install `git` in the container.
+        // RackPeek uses the bundled libgit2 library to talk to remotes
+        // directly over HTTPS." Verify the production implementation is
+        // LibGit2Sharp-backed (not a process-shelling wrapper) so the doc
+        // statement can't be invalidated by an accidental refactor.
+        IGitRepository repo = new LibGit2GitRepository(
+            _tempDir,
+            new TokenCredentialsProvider("u", "t"));
+
+        // Sanity: LibGit2Sharp must be reachable at all — if it weren't, the
+        // line above would have thrown a DllNotFoundException for the native
+        // libgit2 binary, which proves it ships with the assembly.
+        Assert.True(repo.IsAvailable);
+
+        // Pin: the production binding is the LibGit2Sharp-backed one. If
+        // someone swaps it for a CLI-shelling implementation, this fails and
+        // they're forced to revisit the "no system git needed" promise.
+        Assert.IsType<LibGit2GitRepository>(repo);
+
+        // And LibGit2Sharp itself must be loaded into the test process, which
+        // proves the assembly ships with native binaries.
+        Assert.NotNull(typeof(Repository).Assembly.Location);
+    }
+
+    private static string LocateRepoFile(string relativeFromRepoRoot) {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+
+        while (dir is not null) {
+            var candidate = Path.Combine(dir.FullName, relativeFromRepoRoot);
+            if (File.Exists(candidate))
+                return candidate;
+            dir = dir.Parent;
+        }
+
+        throw new FileNotFoundException(
+            $"Could not locate {relativeFromRepoRoot} walking up from {AppContext.BaseDirectory}");
+    }
+}

+ 137 - 0
Tests/Git/GitRepositoryAvailabilityTests.cs

@@ -0,0 +1,137 @@
+using LibGit2Sharp;
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace Tests.Git;
+
+public sealed class GitRepositoryAvailabilityTests : IDisposable {
+    private readonly string _tempDir;
+    private readonly IGitCredentialsProvider _creds =
+        new TokenCredentialsProvider("test-user", "test-token");
+
+    public GitRepositoryAvailabilityTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-git-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+        File.WriteAllText(Path.Combine(_tempDir, "config.yaml"), "");
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                ForceDelete(_tempDir);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    private static void ForceDelete(string path) {
+        foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) {
+            File.SetAttributes(file, FileAttributes.Normal);
+        }
+
+        Directory.Delete(path, true);
+    }
+
+    [Fact]
+    public void Fresh_Config_Directory_Becomes_Available_After_Construction() {
+        // Reproduces the user's bug: GIT_TOKEN is set, container starts on a
+        // fresh volume with only config.yaml. Without auto-init, IsAvailable
+        // stays false and every git action returns "Git is not available."
+        Assert.False(Repository.IsValid(_tempDir),
+            "precondition: fresh dir should not be a git repo yet");
+
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+
+        Assert.True(repo.IsAvailable,
+            "after construction with GIT_TOKEN configured, the repo should be initialised " +
+            "so the user can immediately add a remote without an explicit Enable Git click");
+    }
+
+    [Fact]
+    public async Task Add_Remote_Succeeds_On_Fresh_Config_Directory() {
+        // End-to-end: simulates the user clicking Add Remote on a brand-new
+        // install. Before the fix this returns "Git is not available." even
+        // though GIT_TOKEN was set on the container.
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+        var useCase = new AddRemoteUseCase(repo);
+
+        var error = await useCase.ExecuteAsync("https://example.com/user/repo.git");
+
+        // The AddRemote fetch will fail (no real remote) but the failure
+        // should be a network error, not "Git is not available."
+        Assert.DoesNotContain("Git is not available", error ?? string.Empty);
+    }
+
+    [Fact]
+    public void Existing_Repo_Is_Not_Reinitialised() {
+        // Auto-init must be idempotent: pre-existing repos must keep their
+        // history. Initialising twice would discard the user's commits.
+        Repository.Init(_tempDir);
+
+        using (var seed = new Repository(_tempDir)) {
+            File.WriteAllText(Path.Combine(_tempDir, "seed.txt"), "seed");
+            Commands.Stage(seed, "seed.txt");
+            var sig = new Signature("seed", "seed@test", DateTimeOffset.UtcNow);
+            seed.Commit("seed commit", sig, sig);
+        }
+
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+
+        Assert.True(repo.IsAvailable);
+
+        using var verify = new Repository(_tempDir);
+        Assert.NotNull(verify.Head.Tip);
+        Assert.Equal("seed commit", verify.Head.Tip.MessageShort);
+    }
+
+    [Fact]
+    public void Missing_Directory_Stays_Unavailable() {
+        // Edge case: directory doesn't exist (e.g. misconfigured volume).
+        // We must not throw out of the constructor and we must not pretend
+        // git is available.
+        var missing = Path.Combine(_tempDir, "definitely-not-there");
+
+        var repo = new LibGit2GitRepository(missing, _creds);
+
+        Assert.False(repo.IsAvailable);
+    }
+
+    [Fact]
+    public async Task Add_Remote_Persists_The_Configured_Origin() {
+        // The full happy path that was broken before the fix: construct a repo
+        // against a fresh dir, add a remote, confirm it lives on disk so a
+        // subsequent push/pull would find it.
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+        var useCase = new AddRemoteUseCase(repo);
+
+        var url = "https://example.com/user/repo.git";
+        await useCase.ExecuteAsync(url);
+
+        using var verify = new Repository(_tempDir);
+        Remote? origin = verify.Network.Remotes["origin"];
+
+        Assert.NotNull(origin);
+        Assert.Equal(url, origin!.Url);
+    }
+
+    [Fact]
+    public void Existing_Repo_With_Remote_Is_Detected_As_Available() {
+        // After a container restart we should pick up the previously
+        // initialised repo (including its remote) without re-initialising
+        // and without losing state.
+        Repository.Init(_tempDir);
+        using (var seed = new Repository(_tempDir)) {
+            seed.Network.Remotes.Add("origin", "https://example.com/user/repo.git");
+        }
+
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+
+        Assert.True(repo.IsAvailable);
+        Assert.True(repo.HasRemote());
+    }
+}

+ 30 - 0
Tests/Git/TokenCredentialsProviderTests.cs

@@ -0,0 +1,30 @@
+using LibGit2Sharp;
+using RackPeek.Domain.Git;
+
+namespace Tests.Git;
+
+public sealed class TokenCredentialsProviderTests {
+    [Fact]
+    public void Produces_HTTP_Basic_Credentials_With_Configured_Username_And_Token() {
+        // Doc: GIT_TOKEN is "used as the password for HTTP Basic auth";
+        // GIT_USERNAME is the username the token belongs to. The handler must
+        // wire those two into UsernamePasswordCredentials, which libgit2 sends
+        // as HTTP Basic over HTTPS.
+        var provider = new TokenCredentialsProvider("octocat", "ghp_secret");
+
+        LibGit2Sharp.Handlers.CredentialsHandler handler = provider.GetHandler();
+        var credentials = (UsernamePasswordCredentials)handler(
+            "https://github.com/foo/bar.git", null!, SupportedCredentialTypes.UsernamePassword);
+
+        Assert.Equal("octocat", credentials.Username);
+        Assert.Equal("ghp_secret", credentials.Password);
+    }
+
+    [Fact]
+    public void Rejects_Null_Username() =>
+        Assert.Throws<ArgumentNullException>(() => new TokenCredentialsProvider(null!, "token"));
+
+    [Fact]
+    public void Rejects_Null_Token() =>
+        Assert.Throws<ArgumentNullException>(() => new TokenCredentialsProvider("user", null!));
+}