From 7a5fe55768f6619f9d924ae9cf11c20ce8e8e779 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Tue, 30 Jun 2026 23:33:53 -0700 Subject: [PATCH 1/5] feat(config): add Integrations.GitHub config section Adds the Integrations.GitHub section to MagosConfig for the Phase 1 GitHub Releases client: BaseUrl (default https://api.github.com) + optional Token (PAT). Every field carries a default so an absent section yields a usable (anonymous) client. Consumed by AddIntegrations() in the Integrations library. --- magos-modificus/config/IntegrationsConfig.cs | 33 ++++++++++++++++++++ magos-modificus/config/MagosConfig.cs | 3 ++ 2 files changed, 36 insertions(+) create mode 100644 magos-modificus/config/IntegrationsConfig.cs diff --git a/magos-modificus/config/IntegrationsConfig.cs b/magos-modificus/config/IntegrationsConfig.cs new file mode 100644 index 00000000..39b98743 --- /dev/null +++ b/magos-modificus/config/IntegrationsConfig.cs @@ -0,0 +1,33 @@ +namespace Magos.Modificus.Config; + +/// +/// External-service integration settings (mod sources). Bound from the +/// Integrations section of by the config loader +/// in Magos.Modificus.General. Every field carries a default so an absent +/// section yields a usable object. +/// +public sealed class IntegrationsConfig +{ + /// GitHub Releases client settings. + public GitHubConfig GitHub { get; set; } = new(); +} + +/// +/// GitHub Releases client settings. The base URL defaults to the public GitHub +/// REST API; an optional personal access token raises the rate limit / unlocks +/// private repos (Phase 1: no token-management UI — supply via config only). +/// +public sealed class GitHubConfig +{ + /// + /// The GitHub REST API root, without a trailing slash. Defaults to the + /// public endpoint; override for GitHub Enterprise (https://<host>/api/v3). + /// + public string BaseUrl { get; set; } = "https://api.github.com"; + + /// + /// An optional personal access token sent as a Bearer token. When + /// unset, requests are anonymous (public releases need no auth). + /// + public string? Token { get; set; } +} diff --git a/magos-modificus/config/MagosConfig.cs b/magos-modificus/config/MagosConfig.cs index 2431f91f..1a17db13 100644 --- a/magos-modificus/config/MagosConfig.cs +++ b/magos-modificus/config/MagosConfig.cs @@ -29,6 +29,9 @@ public sealed class MagosConfig /// public string EnginseerRuntimeDir { get; set; } = AppPaths.DefaultEnginseerRuntimeDir; + /// External-service (mod-source) integration settings. + public IntegrationsConfig Integrations { get; set; } = new(); + /// A fully-defaulted config instance. public static MagosConfig CreateDefault() => new(); } From 30a6d78806d939dfc1eb245062e75d668ed7e466 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Tue, 30 Jun 2026 23:34:06 -0700 Subject: [PATCH 2/5] feat(integrations): implement GitHub Releases client Replaces the stub Integrations library with the real IGitHubClient: - IGitHubClient: ListReleases / GetLatestRelease (sync wrappers) + DownloadAssetAsync (streamed, with progress). - GitHubClient over the GitHub REST API via an IHttpClientFactory- provided HttpClient (AddHttpClient). - Rate-limit detection (X-RateLimit-Remaining: 0 + 403/429) throws GitHubRateLimitException carrying the reset time; other non-2xx throw GitHubApiException(status, message). 404 -> null/empty for the release lookups. - AddIntegrations() configures BaseAddress + User-Agent + Accept + optional Bearer auth from MagosConfig.Integrations.GitHub. System.Text.Json (in-box) for deserialization; Microsoft.Extensions.Http 10.0.9 (latest stable, .NET 10 LTS) is the only new dep. Also swaps the Phase-0 IModSourceService probe in App.axaml.cs for IGitHubClient (forced by removing the stub; one-line mechanical fix). --- .../integrations/GitHubApiException.cs | 51 +++ magos-modificus/integrations/GitHubClient.cs | 290 ++++++++++++++++++ magos-modificus/integrations/GitHubTypes.cs | 24 ++ magos-modificus/integrations/IGitHubClient.cs | 50 +++ .../integrations/IntegrationsModule.cs | 27 -- .../Magos.Modificus.Integrations.csproj | 21 ++ .../ServiceCollectionExtensions.cs | 64 ++++ magos-modificus/ui/App.axaml.cs | 2 +- 8 files changed, 501 insertions(+), 28 deletions(-) create mode 100644 magos-modificus/integrations/GitHubApiException.cs create mode 100644 magos-modificus/integrations/GitHubClient.cs create mode 100644 magos-modificus/integrations/GitHubTypes.cs create mode 100644 magos-modificus/integrations/IGitHubClient.cs delete mode 100644 magos-modificus/integrations/IntegrationsModule.cs create mode 100644 magos-modificus/integrations/ServiceCollectionExtensions.cs diff --git a/magos-modificus/integrations/GitHubApiException.cs b/magos-modificus/integrations/GitHubApiException.cs new file mode 100644 index 00000000..483fc3ea --- /dev/null +++ b/magos-modificus/integrations/GitHubApiException.cs @@ -0,0 +1,51 @@ +using System.Net; + +namespace Magos.Modificus.Integrations; + +/// +/// Thrown when the GitHub REST API returns a non-success response (other than +/// the 404 cases the client maps to null / empty). Carries the +/// HTTP status and the API's message field when available. +/// +/// +/// Unsealed so can specialize it; callers +/// can catch the base type to handle every GitHub API failure uniformly. +/// +public class GitHubApiException : Exception +{ + /// The HTTP status code returned by the API. + public int StatusCode { get; } + + /// The HTTP status code. + /// The API error message (its message field when available). + public GitHubApiException(int statusCode, string message) + : base(message) + { + StatusCode = statusCode; + } +} + +/// +/// Thrown when the GitHub API refuses a request because the rate limit is +/// exhausted — detected via a 403/429 status carrying +/// X-RateLimit-Remaining: 0. Carries the reset time +/// (X-RateLimit-Reset) when GitHub reports it, so callers can advise the +/// user when to retry. +/// +public sealed class GitHubRateLimitException : GitHubApiException +{ + /// + /// When the rate window resets (from X-RateLimit-Reset), or + /// null if the header was absent. + /// + public DateTimeOffset? ResetAt { get; } + + internal GitHubRateLimitException(DateTimeOffset? resetAt) + : base( + (int)HttpStatusCode.Forbidden, + "GitHub API rate limit exhausted" + + (resetAt.HasValue ? $" — resets at {resetAt:O}." : ".")) + { + ResetAt = resetAt; + } +} diff --git a/magos-modificus/integrations/GitHubClient.cs b/magos-modificus/integrations/GitHubClient.cs new file mode 100644 index 00000000..bae5241b --- /dev/null +++ b/magos-modificus/integrations/GitHubClient.cs @@ -0,0 +1,290 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Integrations; + +/// +/// The default — a thin wrapper over the GitHub REST +/// API (/repos/{owner}/{name}/releases) via . +/// +/// +/// +/// The HttpClient is supplied by IHttpClientFactory (typed-client +/// pattern); the base URL, User-Agent, Accept, and optional +/// Bearer auth are applied in . +/// +/// / are synchronous +/// wrappers over the async HTTP work (block with GetAwaiter().GetResult()). +/// Acceptable for Phase 1's UI callers; the fake-handler test path completes +/// synchronously and the runtime has no legacy sync context to deadlock against. +/// +/// Registered as a transient (the AddHttpClient<T,TImpl> default); it +/// holds no per-call state — the only field is the factory-provided +/// HttpClient, which is reused across requests. +/// +internal sealed class GitHubClient : IGitHubClient +{ + private const int DownloadBufferSize = 81920; + + private const string UserAgent = "Magos-Modificus"; + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public GitHubClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // Defensive: the configured client should already carry a User-Agent + // (set in AddIntegrations), but guarantee one — GitHub rejects requests + // without it. TryAdd avoids duplicating if the factory already set it. + _httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(UserAgent); + } + + /// + public IReadOnlyList ListReleases(GitHubRepo repo, CancellationToken ct = default) => + ListReleasesAsync(repo, ct).GetAwaiter().GetResult(); + + /// + public GitHubRelease? GetLatestRelease(GitHubRepo repo, CancellationToken ct = default) => + GetLatestReleaseAsync(repo, ct).GetAwaiter().GetResult(); + + /// + public async Task DownloadAssetAsync( + GitHubReleaseAsset asset, + string destinationPath, + IProgress? progress = null, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(asset); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + // The asset URL is absolute (on github.com / the CDN), so it ignores the + // API BaseAddress while still picking up the client's default headers + // (User-Agent + auth) — auth raises the rate limit for asset downloads too. + using var response = await _httpClient + .GetAsync(asset.BrowserDownloadUrl, HttpCompletionOption.ResponseHeadersRead, ct) + .ConfigureAwait(false); + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + + var directory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await using var network = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var file = new FileStream( + destinationPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + DownloadBufferSize, + useAsync: true); + + var buffer = new byte[DownloadBufferSize]; + long total = 0; + int read; + while ((read = await network.ReadAsync(buffer.AsMemory(0, DownloadBufferSize), ct).ConfigureAwait(false)) > 0) + { + await file.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false); + total += read; + progress?.Report(total); + } + } + + private async Task> ListReleasesAsync(GitHubRepo repo, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(repo); + + var uri = $"repos/{repo.Owner}/{repo.Name}/releases"; + using var response = await _httpClient.GetAsync(uri, ct).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + // Unknown repo — treat as "no releases" rather than an error, so a + // mistyped repo id doesn't crash a profile-creation prompt. + _logger.LogDebug("GET {Uri} -> 404; returning empty release list.", uri); + return Array.Empty(); + } + + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var dtos = await JsonSerializer + .DeserializeAsync>(stream, cancellationToken: ct) + .ConfigureAwait(false) + ?? new List(); + + return dtos.Select(Map).ToList(); + } + + private async Task GetLatestReleaseAsync(GitHubRepo repo, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(repo); + + var uri = $"repos/{repo.Owner}/{repo.Name}/releases/latest"; + using var response = await _httpClient.GetAsync(uri, ct).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + // GitHub returns 404 for "no latest release" (a repo with zero + // releases) — map to null rather than an exception. + _logger.LogDebug("GET {Uri} -> 404; no latest release.", uri); + return null; + } + + await EnsureSuccessAsync(response, ct).ConfigureAwait(false); + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + var dto = await JsonSerializer + .DeserializeAsync(stream, cancellationToken: ct) + .ConfigureAwait(false); + + return dto is null ? null : Map(dto); + } + + /// + /// Throws / + /// for a failed response; returns silently on success. Callers handle the + /// endpoint-specific 404 semantics before invoking this. + /// + private async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + { + return; + } + + if (IsRateLimited(response)) + { + var reset = ParseReset(response); + _logger.LogWarning( + "GitHub API rate limit exhausted (status {Status}, reset at {Reset}).", + (int)response.StatusCode, + reset); + throw new GitHubRateLimitException(reset); + } + + var message = await ReadErrorMessageAsync(response, ct).ConfigureAwait(false); + _logger.LogError("GitHub API request failed: status {Status}, message {Message}.", (int)response.StatusCode, message); + throw new GitHubApiException((int)response.StatusCode, message); + } + + private static bool IsRateLimited(HttpResponseMessage response) + { + // GitHub returns 403 (sometimes 429) when the rate window is empty, and + // sets X-RateLimit-Remaining: 0. A 403 for other reasons (permissions) + // carries a non-zero remaining — so both conditions must hold. + if (response.StatusCode != HttpStatusCode.Forbidden && + response.StatusCode != (HttpStatusCode)429) + { + return false; + } + + if (!response.Headers.TryGetValues("X-RateLimit-Remaining", out var values)) + { + return false; + } + + foreach (var value in values) + { + if (int.TryParse(value, out var remaining) && remaining <= 0) + { + return true; + } + } + + return false; + } + + private static DateTimeOffset? ParseReset(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("X-RateLimit-Reset", out var values)) + { + foreach (var value in values) + { + if (long.TryParse(value, out var epochSeconds)) + { + return DateTimeOffset.FromUnixTimeSeconds(epochSeconds); + } + } + } + + return null; + } + + private static async Task ReadErrorMessageAsync(HttpResponseMessage response, CancellationToken ct) + { + // GitHub errors are JSON with a "message" field. Fall back to the reason + // phrase for non-JSON bodies so the exception always carries something. + try + { + await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false); + if (doc.RootElement.ValueKind == JsonValueKind.Object && + doc.RootElement.TryGetProperty("message", out var message) && + message.ValueKind == JsonValueKind.String) + { + return message.GetString() ?? FallbackReason(response); + } + } + catch + { + // Non-JSON or unreadable body — fall through to the reason phrase. + } + + return FallbackReason(response); + } + + private static string FallbackReason(HttpResponseMessage response) => + response.ReasonPhrase ?? $"HTTP {(int)response.StatusCode}"; + + private static GitHubRelease Map(ReleaseDto dto) + { + var assets = (dto.Assets ?? new List()) + .Select(MapAsset) + .ToList(); + + return new GitHubRelease( + dto.TagName ?? string.Empty, + dto.Name ?? string.Empty, + dto.PublishedAt ?? DateTimeOffset.UnixEpoch, + assets); + } + + private static GitHubReleaseAsset MapAsset(AssetDto dto) => new( + dto.Name ?? string.Empty, + dto.Size ?? 0, + new Uri(dto.BrowserDownloadUrl!)); + + // ---- wire DTOs (snake_case ↔ the GitHub REST schema) ------------------ + + private sealed class ReleaseDto + { + [JsonPropertyName("tag_name")] + public string? TagName { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; set; } + + [JsonPropertyName("assets")] + public List? Assets { get; set; } + } + + private sealed class AssetDto + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("size")] + public long? Size { get; set; } + + [JsonPropertyName("browser_download_url")] + public string? BrowserDownloadUrl { get; set; } + } +} diff --git a/magos-modificus/integrations/GitHubTypes.cs b/magos-modificus/integrations/GitHubTypes.cs new file mode 100644 index 00000000..c45ff8b9 --- /dev/null +++ b/magos-modificus/integrations/GitHubTypes.cs @@ -0,0 +1,24 @@ +namespace Magos.Modificus.Integrations; + +/// +/// A GitHub repository identity — the owner + name that identify a repo on the +/// REST API. For example, the Darktide-Mod-Framework repo is +/// new GitHubRepo("Darktide-Mod-Framework", "DMF"). +/// +public sealed record GitHubRepo(string Owner, string Name); + +/// +/// A published GitHub release: its tag, display name, publish time, and the +/// downloadable assets attached to it. Immutable. +/// +public sealed record GitHubRelease( + string TagName, + string Name, + DateTimeOffset PublishedAt, + IReadOnlyList Assets); + +/// +/// A single downloadable asset attached to a release. +/// is the absolute URL served (and streamable) by GitHub's CDN. +/// +public sealed record GitHubReleaseAsset(string Name, long Size, Uri BrowserDownloadUrl); diff --git a/magos-modificus/integrations/IGitHubClient.cs b/magos-modificus/integrations/IGitHubClient.cs new file mode 100644 index 00000000..9704281d --- /dev/null +++ b/magos-modificus/integrations/IGitHubClient.cs @@ -0,0 +1,50 @@ +namespace Magos.Modificus.Integrations; + +/// +/// A read-only GitHub Releases client — the minimum mod-source surface needed +/// for the DMF new-profile prompt (Phase 4) and GitHub-hosted mod sources: list +/// a repository's releases, fetch the latest, and download a release asset. +/// +/// +/// +/// / are synchronous +/// wrappers that return fully-materialized results — the simple surface Phase 1 +/// callers want. is async (it's a file +/// download). +/// +/// Implemented over the GitHub REST API via +/// provided by IHttpClientFactory; registered through +/// . +/// +public interface IGitHubClient +{ + /// + /// Lists a repository's published releases (newest first, per the GitHub API). + /// A 404 (unknown repo) yields an empty list rather than an exception. + /// + /// A non-2xx response other than 404. + /// The API rate limit is exhausted. + IReadOnlyList ListReleases(GitHubRepo repo, CancellationToken ct = default); + + /// + /// The latest published release, or null if the repo has no releases + /// or is unknown (both surface as 404 from the API). + /// + /// A non-2xx response other than 404. + /// The API rate limit is exhausted. + GitHubRelease? GetLatestRelease(GitHubRepo repo, CancellationToken ct = default); + + /// + /// Downloads 's bytes to + /// , reporting cumulative byte count to + /// when provided. The destination's parent + /// directory is created if missing. + /// + /// A non-2xx response (e.g. a stale asset URL). + /// The API rate limit is exhausted. + Task DownloadAssetAsync( + GitHubReleaseAsset asset, + string destinationPath, + IProgress? progress = null, + CancellationToken ct = default); +} diff --git a/magos-modificus/integrations/IntegrationsModule.cs b/magos-modificus/integrations/IntegrationsModule.cs deleted file mode 100644 index 8019a338..00000000 --- a/magos-modificus/integrations/IntegrationsModule.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Magos.Modificus.Integrations; - -/// -/// External mod-source clients: Nexus Mods (primary), GitHub Releases, and -/// local install. Version checks, downloads / updates. Stub — implemented in a -/// later phase. See docs/architecture/MAGOS-MODIFICUS.md. -/// -public interface IModSourceService -{ -} - -internal sealed class ModSourceService : IModSourceService -{ -} - -/// DI registration for the Integrations library. -public static class ServiceCollectionExtensions -{ - /// Registers the Integrations library services. - public static IServiceCollection AddIntegrations(this IServiceCollection services) - { - services.AddSingleton(); - return services; - } -} diff --git a/magos-modificus/integrations/Magos.Modificus.Integrations.csproj b/magos-modificus/integrations/Magos.Modificus.Integrations.csproj index 2646e1dd..fc3c8a34 100644 --- a/magos-modificus/integrations/Magos.Modificus.Integrations.csproj +++ b/magos-modificus/integrations/Magos.Modificus.Integrations.csproj @@ -5,9 +5,30 @@ Magos.Modificus.Integrations + + + + + + + + + + + + diff --git a/magos-modificus/integrations/ServiceCollectionExtensions.cs b/magos-modificus/integrations/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..5ab4352a --- /dev/null +++ b/magos-modificus/integrations/ServiceCollectionExtensions.cs @@ -0,0 +1,64 @@ +using System.Net.Http.Headers; +using Magos.Modificus.Config; +using Microsoft.Extensions.DependencyInjection; + +namespace Magos.Modificus.Integrations; + +/// DI registration for the Integrations library. +public static class ServiceCollectionExtensions +{ + /// + /// Registers as a + /// typed HTTP client. The HttpClient (base URL + headers + optional + /// auth) is configured from + /// (), resolved from the container (provided by + /// AddGeneral()). + /// + /// + /// + /// Headers applied to every request: User-Agent: Magos-Modificus + /// (required by GitHub) and Accept: application/vnd.github+json. When + /// is set, it is sent as + /// Authorization: Bearer <token> (raises the rate limit / + /// unlocks private repos); anonymous access is used otherwise. + /// + /// The base URL is normalized to end with a trailing slash so relative + /// request URIs resolve correctly against HttpClient.BaseAddress. + /// + public static IServiceCollection AddIntegrations(this IServiceCollection services) + { + services.AddHttpClient((serviceProvider, client) => + { + var config = serviceProvider.GetRequiredService(); + var gitHub = config.Integrations.GitHub; + + // Trim whitespace + trailing slashes, then re-append one slash so + // relative request URIs resolve against BaseAddress predictably + // (the classic BaseAddress footgun). A blank value falls back to the + // public GitHub API. + var baseUrl = (gitHub.BaseUrl ?? string.Empty).Trim().TrimEnd('/'); + if (baseUrl.Length == 0) + { + baseUrl = GitHubConfigDefaults.BaseUrl; + } + + client.BaseAddress = new Uri(baseUrl + "/", UriKind.Absolute); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Magos-Modificus"); + client.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + + if (!string.IsNullOrWhiteSpace(gitHub.Token)) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", gitHub.Token); + } + }); + + return services; + } + + private static class GitHubConfigDefaults + { + public const string BaseUrl = "https://api.github.com"; + } +} diff --git a/magos-modificus/ui/App.axaml.cs b/magos-modificus/ui/App.axaml.cs index 2b2d384a..7b7422cf 100644 --- a/magos-modificus/ui/App.axaml.cs +++ b/magos-modificus/ui/App.axaml.cs @@ -60,7 +60,7 @@ private static int ResolveDomainServices(IServiceProvider services) { var count = 0; if (services.GetService() is not null) count++; - if (services.GetService() is not null) count++; + if (services.GetService() is not null) count++; if (services.GetService() is not null) count++; if (services.GetService() is not null) count++; if (services.GetService() is not null) count++; From 2f89de0d6f713d7cae041de62f09f2552a3f70f2 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Tue, 30 Jun 2026 23:34:18 -0700 Subject: [PATCH 3/5] test(integrations): add GitHub client + DI tests Adds the Magos.Modificus.Integrations.Tests xUnit project and registers it in the solution. All tests run against a stub HttpMessageHandler (no real network calls): - ListReleases/GetLatestRelease parsing, 404 -> empty/null, 500/403 -> GitHubApiException, non-JSON error fallback, missing-field robustness. - Rate-limit (X-RateLimit-Remaining: 0) -> GitHubRateLimitException (which is itself a GitHubApiException). - DownloadAssetAsync: writes bytes, reports progress, creates the destination dir, honors cancellation, 404 -> GitHubApiException. - AddIntegrations(): resolves IGitHubClient, exposes IHttpClientFactory, and wires BaseAddress + headers + auth from MagosConfig (verified end-to-end via a stub handler on the outgoing request). 98% line coverage on the Integrations library; existing tests unaffected (114 total, 0 failures). --- magos-modificus/magos-modificus.sln | 15 + .../GitHubClientTests.cs | 409 ++++++++++++++++++ ...rationsServiceCollectionExtensionsTests.cs | 139 ++++++ .../Magos.Modificus.Integrations.Tests.csproj | 33 ++ .../StubHttpMessageHandler.cs | 80 ++++ 5 files changed, 676 insertions(+) create mode 100644 magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Integrations.Tests/IntegrationsServiceCollectionExtensionsTests.cs create mode 100644 magos-modificus/tests/Magos.Modificus.Integrations.Tests/Magos.Modificus.Integrations.Tests.csproj create mode 100644 magos-modificus/tests/Magos.Modificus.Integrations.Tests/StubHttpMessageHandler.cs diff --git a/magos-modificus/magos-modificus.sln b/magos-modificus/magos-modificus.sln index 430738c1..611e4054 100644 --- a/magos-modificus/magos-modificus.sln +++ b/magos-modificus/magos-modificus.sln @@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Profiles.Te EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Steam.Tests", "tests\Magos.Modificus.Steam.Tests\Magos.Modificus.Steam.Tests.csproj", "{2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Magos.Modificus.Integrations.Tests", "tests\Magos.Modificus.Integrations.Tests\Magos.Modificus.Integrations.Tests.csproj", "{6DB44E7F-2EAA-484E-AFCA-22742857C817}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -185,6 +187,18 @@ Global {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x64.Build.0 = Release|Any CPU {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.ActiveCfg = Release|Any CPU {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200}.Release|x86.Build.0 = Release|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Debug|x64.ActiveCfg = Debug|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Debug|x64.Build.0 = Debug|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Debug|x86.Build.0 = Debug|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|Any CPU.Build.0 = Release|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|x64.ActiveCfg = Release|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|x64.Build.0 = Release|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|x86.ActiveCfg = Release|Any CPU + {6DB44E7F-2EAA-484E-AFCA-22742857C817}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -201,5 +215,6 @@ Global {A3D79190-AD1E-428E-A0B0-224AE2A5A2BF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {B4E0C2A1-1F2D-4A3E-9B5C-7D6E8F901A23} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {2E1647E7-8D82-44EB-ADB6-FAFA9AC42200} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {6DB44E7F-2EAA-484E-AFCA-22742857C817} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs new file mode 100644 index 00000000..f7ca618d --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs @@ -0,0 +1,409 @@ +using System.Net; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Magos.Modificus.Integrations.Tests; + +/// +/// Exercises against canned HTTP responses (no real +/// network): parsing, latest/404 handling, download + progress, rate-limit +/// detection, and error mapping. +/// +public sealed class GitHubClientTests +{ + private const string ApiBase = "https://api.github.com/"; + + private const string TwoReleasesJson = @" + [ + { + ""tag_name"": ""v1.2.0"", + ""name"": ""DMF 1.2"", + ""published_at"": ""2024-05-01T12:00:00Z"", + ""assets"": [ + { ""name"": ""dmf.zip"", ""size"": 2048, ""browser_download_url"": ""https://github.com/o/r/releases/download/v1.2.0/dmf.zip"" }, + { ""name"": ""dmf.tar.gz"", ""size"": 1800, ""browser_download_url"": ""https://github.com/o/r/releases/download/v1.2.0/dmf.tar.gz"" } + ] + }, + { + ""tag_name"": ""v1.1.0"", + ""name"": ""DMF 1.1"", + ""published_at"": ""2024-04-01T12:00:00Z"", + ""assets"": [] + } + ]"; + + private static GitHubClient CreateClient(HttpMessageHandler handler, string baseAddress = ApiBase) + { + var http = new HttpClient(handler) { BaseAddress = new Uri(baseAddress) }; + return new GitHubClient(http, NullLogger.Instance); + } + + // ---- ListReleases ------------------------------------------------------- + + [Fact] + public void ListReleases_parses_tag_name_published_and_assets() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json(TwoReleasesJson)); + var client = CreateClient(handler); + + var releases = client.ListReleases(new GitHubRepo("o", "r")); + + Assert.Equal(2, releases.Count); + + var latest = releases[0]; + Assert.Equal("v1.2.0", latest.TagName); + Assert.Equal("DMF 1.2", latest.Name); + Assert.Equal(new DateTimeOffset(2024, 5, 1, 12, 0, 0, TimeSpan.Zero), latest.PublishedAt); + Assert.Equal(2, latest.Assets.Count); + Assert.Equal("dmf.zip", latest.Assets[0].Name); + Assert.Equal(2048, latest.Assets[0].Size); + Assert.Equal( + new Uri("https://github.com/o/r/releases/download/v1.2.0/dmf.zip"), + latest.Assets[0].BrowserDownloadUrl); + + Assert.Empty(releases[1].Assets); + + var request = Assert.Single(handler.Requests); + Assert.Equal(new Uri("https://api.github.com/repos/o/r/releases"), request.RequestUri); + Assert.Equal(HttpMethod.Get, request.Method); + } + + [Fact] + public void ListReleases_404_returns_empty() + { + var handler = new StubHttpMessageHandler(_ => + HttpResponses.Json(@"{""message"":""Not Found""}", HttpStatusCode.NotFound)); + var client = CreateClient(handler); + + var releases = client.ListReleases(new GitHubRepo("o", "missing")); + + Assert.Empty(releases); + Assert.Single(handler.Requests); + } + + [Fact] + public void ListReleases_empty_array_yields_empty_list() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json("[]")); + var client = CreateClient(handler); + + Assert.Empty(client.ListReleases(new GitHubRepo("o", "r"))); + } + + [Fact] + public void ListReleases_500_throws_GitHubApiException_with_status_and_message() + { + var handler = new StubHttpMessageHandler(_ => + HttpResponses.Json(@"{""message"":""server boom""}", HttpStatusCode.InternalServerError)); + var client = CreateClient(handler); + + var ex = Assert.Throws(() => client.ListReleases(new GitHubRepo("o", "r"))); + Assert.Equal(500, ex.StatusCode); + Assert.Contains("server boom", ex.Message); + } + + [Fact] + public void ListReleases_non_json_error_body_falls_back_to_reason() + { + // A non-JSON error body shouldn't crash the error mapper — the status is + // still surfaced via the HTTP fallback. + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("upstream is down, not json"), + }); + var client = CreateClient(handler); + + var ex = Assert.Throws(() => client.ListReleases(new GitHubRepo("o", "r"))); + Assert.Equal(502, ex.StatusCode); + // Non-JSON body → fall back to the response reason phrase. + Assert.Equal("Bad Gateway", ex.Message); + } + + [Fact] + public void ListReleases_handles_release_with_missing_optional_fields() + { + // A release with no name / published_at / assets must still map cleanly + // to defaults rather than NRE — GitHub occasionally omits fields. + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json(@"[{ ""tag_name"": ""v0.1"" }]")); + var client = CreateClient(handler); + + var release = Assert.Single(client.ListReleases(new GitHubRepo("o", "r"))); + + Assert.Equal("v0.1", release.TagName); + Assert.Equal(string.Empty, release.Name); + Assert.Equal(DateTimeOffset.UnixEpoch, release.PublishedAt); + Assert.Empty(release.Assets); + } + + // ---- GetLatestRelease --------------------------------------------------- + + [Fact] + public void GetLatestRelease_returns_release_when_present() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json(@" + { + ""tag_name"": ""v2.0.0"", + ""name"": ""Latest"", + ""published_at"": ""2024-06-01T00:00:00Z"", + ""assets"": [ + { ""name"": ""bin.zip"", ""size"": 10, ""browser_download_url"": ""https://github.com/o/r/releases/download/v2.0.0/bin.zip"" } + ] + }")); + var client = CreateClient(handler); + + var latest = client.GetLatestRelease(new GitHubRepo("o", "r")); + + Assert.NotNull(latest); + Assert.Equal("v2.0.0", latest.TagName); + Assert.Equal("Latest", latest.Name); + Assert.Single(latest.Assets); + + var request = Assert.Single(handler.Requests); + Assert.Equal(new Uri("https://api.github.com/repos/o/r/releases/latest"), request.RequestUri); + } + + [Fact] + public void GetLatestRelease_404_returns_null() + { + var handler = new StubHttpMessageHandler(_ => + HttpResponses.Json(@"{""message"":""Not Found""}", HttpStatusCode.NotFound)); + var client = CreateClient(handler); + + Assert.Null(client.GetLatestRelease(new GitHubRepo("o", "r"))); + } + + [Fact] + public void GetLatestRelease_403_maps_to_GitHubApiException_when_not_rate_limited() + { + // 403 with a non-zero remaining is a permissions error, not rate-limit. + var handler = new StubHttpMessageHandler(_ => + { + var r = HttpResponses.Json(@"{""message"":""forbidden""}", HttpStatusCode.Forbidden); + r.Headers.Add("X-RateLimit-Remaining", "42"); + return r; + }); + var client = CreateClient(handler); + + var ex = Assert.Throws(() => client.GetLatestRelease(new GitHubRepo("o", "r"))); + Assert.Equal(403, ex.StatusCode); + Assert.DoesNotContain("rate limit", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // ---- Rate limiting ------------------------------------------------------ + + [Fact] + public void GetLatestRelease_rate_limited_throws_GitHubRateLimitException_with_reset() + { + const long reset = 1_716_000_000L; + var handler = new StubHttpMessageHandler(_ => HttpResponses.RateLimited(reset)); + var client = CreateClient(handler); + + var ex = Assert.Throws(() => client.GetLatestRelease(new GitHubRepo("o", "r"))); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(reset), ex.ResetAt); + } + + [Fact] + public void ListReleases_rate_limited_throws_GitHubRateLimitException() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.RateLimited(1_700_000_000L)); + var client = CreateClient(handler); + + Assert.Throws(() => client.ListReleases(new GitHubRepo("o", "r"))); + } + + [Fact] + public void GitHubRateLimitException_is_a_GitHubApiException() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.RateLimited(1_700_000_000L)); + var client = CreateClient(handler); + + // Callers can catch the base type to handle every API failure uniformly, + // so GitHubRateLimitException must be assignable to GitHubApiException. + var ex = Assert.Throws(() => client.ListReleases(new GitHubRepo("o", "r"))); + Assert.IsAssignableFrom(ex); + } + + // ---- DownloadAssetAsync ------------------------------------------------- + + [Fact] + public async Task DownloadAssetAsync_writes_bytes_and_reports_progress() + { + var payload = Enumerable.Range(1, 10).Select(i => (byte)i).ToArray(); + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + var client = CreateClient(handler); + + var dest = Path.Combine(Path.GetTempPath(), "magos-integrations-" + Guid.NewGuid() + ".bin"); + var progress = new CapturingProgress(); + try + { + await client.DownloadAssetAsync( + new GitHubReleaseAsset("dmf.zip", payload.Length, new Uri("https://github.com/o/r/releases/download/v1.2.0/dmf.zip")), + dest, + progress); + + Assert.Equal(payload, await File.ReadAllBytesAsync(dest)); + Assert.NotEmpty(progress.Reports); + Assert.Equal((long)payload.Length, progress.Reports[^1]); + } + finally + { + if (File.Exists(dest)) + { + File.Delete(dest); + } + } + + var request = Assert.Single(handler.Requests); + Assert.Equal( + new Uri("https://github.com/o/r/releases/download/v1.2.0/dmf.zip"), + request.RequestUri); + } + + [Fact] + public async Task DownloadAssetAsync_creates_missing_destination_directory() + { + var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + var client = CreateClient(handler); + + var tempDir = Path.Combine(Path.GetTempPath(), "magos-integrations-" + Guid.NewGuid()); + var dest = Path.Combine(tempDir, "nested", "asset.zip"); + try + { + await client.DownloadAssetAsync( + new GitHubReleaseAsset("asset.zip", payload.Length, new Uri("https://github.com/o/r/releases/download/v1/asset.zip")), + dest); + + Assert.True(File.Exists(dest)); + Assert.Equal(payload, await File.ReadAllBytesAsync(dest)); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public async Task DownloadAssetAsync_404_throws_GitHubApiException() + { + var handler = new StubHttpMessageHandler(_ => + HttpResponses.Json(@"{""message"":""Not Found""}", HttpStatusCode.NotFound)); + var client = CreateClient(handler); + + var dest = Path.Combine(Path.GetTempPath(), "magos-integrations-" + Guid.NewGuid() + ".bin"); + try + { + var ex = await Assert.ThrowsAsync(() => client.DownloadAssetAsync( + new GitHubReleaseAsset("missing.zip", 0, new Uri("https://github.com/o/r/releases/download/v1/missing.zip")), + dest)); + Assert.Equal(404, ex.StatusCode); + Assert.False(File.Exists(dest)); + } + finally + { + if (File.Exists(dest)) + { + File.Delete(dest); + } + } + } + + [Fact] + public void ListReleases_null_repo_throws() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json("[]")); + var client = CreateClient(handler); + Assert.Throws(() => client.ListReleases(null!)); + } + + [Fact] + public void GetLatestRelease_null_repo_throws() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json("[]")); + var client = CreateClient(handler); + Assert.Throws(() => client.GetLatestRelease(null!)); + } + + [Fact] + public async Task DownloadAssetAsync_cancellation_aborts() + { + // An async handler that never completes on its own — only the token cancels it. + var handler = new CancellableHandler(); + var client = CreateClient(handler); + + using var cts = new CancellationTokenSource(); + var dest = Path.Combine(Path.GetTempPath(), "magos-integrations-" + Guid.NewGuid() + ".bin"); + + // Start the download — the GET response never arrives until cancelled. + var task = client.DownloadAssetAsync( + new GitHubReleaseAsset("a.zip", 1, new Uri("https://github.com/o/r/releases/download/v1/a.zip")), + dest, + progress: null, + ct: cts.Token); + + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => task); + + if (File.Exists(dest)) + { + File.Delete(dest); + } + } + + [Fact] + public async Task DownloadAssetAsync_null_asset_throws() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json("[]")); + var client = CreateClient(handler); + + await Assert.ThrowsAsync(() => + client.DownloadAssetAsync(null!, Path.GetTempFileName())); + } + + [Fact] + public async Task DownloadAssetAsync_empty_path_throws() + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json("[]")); + var client = CreateClient(handler); + + await Assert.ThrowsAsync(() => + client.DownloadAssetAsync( + new GitHubReleaseAsset("a", 1, new Uri("https://github.com/o/r/x")), + "")); + } + + /// + /// An that captures reports synchronously (no + /// SynchronizationContext hopping) for deterministic test assertions. + /// + private sealed class CapturingProgress : IProgress + { + public List Reports { get; } = new(); + public void Report(long value) => Reports.Add(value); + } + + /// + /// A handler whose response never completes until cancelled — used to prove + /// honors its cancellation token. + /// + private sealed class CancellableHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(); + cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + return tcs.Task; + } + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Integrations.Tests/IntegrationsServiceCollectionExtensionsTests.cs b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/IntegrationsServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..a64d4657 --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/IntegrationsServiceCollectionExtensionsTests.cs @@ -0,0 +1,139 @@ +using Magos.Modificus.Config; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Magos.Modificus.Integrations.Tests; + +/// +/// Proves AddIntegrations() registers as a +/// typed HTTP client configured from +/// (base URL + optional auth), resolvable via DI with an +/// IHttpClientFactory-provided HttpClient. +/// +/// +/// Config is verified end-to-end: a stub is +/// wired into the same typed-client registration AddIntegrations() builds, +/// the client makes a real (offline) call, and the recorded request is asserted +/// on — so the test proves the MagosConfigHttpClient wiring +/// actually reaches the wire, not just that something resolves. +/// +public sealed class IntegrationsServiceCollectionExtensionsTests +{ + [Fact] + public void AddIntegrations_registers_resolvable_IGitHubClient() + { + var services = new ServiceCollection(); + services.AddSingleton(MagosConfig.CreateDefault()); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddIntegrations(); + using var provider = services.BuildServiceProvider(); + + var client = provider.GetService(); + + Assert.NotNull(client); + Assert.IsAssignableFrom(client); + } + + [Fact] + public void AddIntegrations_exposes_IHttpClientFactory() + { + // The typed client's HttpClient is built by the factory — proving the + // standard, testable HTTP DI pattern is wired. + var services = new ServiceCollection(); + services.AddSingleton(MagosConfig.CreateDefault()); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddIntegrations(); + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void AddIntegrations_configures_base_url_headers_and_auth_from_config() + { + var config = MagosConfig.CreateDefault(); + config.Integrations.GitHub.BaseUrl = "https://api.test.local"; + config.Integrations.GitHub.Token = "secret-token"; + + var (client, handler) = BuildWithStub(config); + client.ListReleases(new GitHubRepo("o", "r")); + + var request = Assert.Single(handler.Requests); + Assert.Equal("https://api.test.local/repos/o/r/releases", request.RequestUri!.ToString()); + Assert.Equal("Bearer secret-token", request.Authorization); + Assert.NotNull(request.UserAgent); + Assert.Contains("Magos-Modificus", request.UserAgent, StringComparison.Ordinal); + Assert.Contains("application/vnd.github+json", request.Accept, StringComparison.Ordinal); + } + + [Fact] + public void AddIntegrations_omits_auth_when_no_token_configured() + { + // Default config: Token is null → anonymous access (public releases need no auth). + var (client, handler) = BuildWithStub(MagosConfig.CreateDefault()); + client.ListReleases(new GitHubRepo("o", "r")); + + var request = Assert.Single(handler.Requests); + Assert.Null(request.Authorization); + } + + [Fact] + public void AddIntegrations_normalizes_trailing_slash_on_base_url() + { + var config = MagosConfig.CreateDefault(); + config.Integrations.GitHub.BaseUrl = "https://gh.enterprise.example/api/v3"; // no trailing slash + + var (client, handler) = BuildWithStub(config); + client.ListReleases(new GitHubRepo("o", "r")); + + var request = Assert.Single(handler.Requests); + Assert.Equal( + "https://gh.enterprise.example/api/v3/repos/o/r/releases", + request.RequestUri!.ToString()); + } + + [Fact] + public void AddIntegrations_falls_back_to_default_base_url_when_blank() + { + var config = MagosConfig.CreateDefault(); + config.Integrations.GitHub.BaseUrl = " "; + + var (client, handler) = BuildWithStub(config); + client.ListReleases(new GitHubRepo("o", "r")); + + var request = Assert.Single(handler.Requests); + Assert.Equal("https://api.github.com/repos/o/r/releases", request.RequestUri!.ToString()); + } + + [Fact] + public void AddIntegrations_is_idempotent_and_returns_same_collection() + { + var services = new ServiceCollection(); + + var returned = services.AddIntegrations(); + + Assert.Same(services, returned); + } + + /// + /// Wires a stub HTTP handler into the typed-client registration + /// AddIntegrations() builds (by re-entering the same + /// AddHttpClient<IGitHubClient, GitHubClient> builder) so tests + /// can drive the real client offline and inspect the outgoing request. + /// + private static (IGitHubClient Client, StubHttpMessageHandler Handler) BuildWithStub(MagosConfig config) + { + var handler = new StubHttpMessageHandler(_ => HttpResponses.Json("[]")); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(b => b.SetMinimumLevel(LogLevel.Warning)); + services.AddIntegrations(); + // Attach the stub to the same named typed client AddIntegrations registered. + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(_ => handler); + + var provider = services.BuildServiceProvider(); + return (provider.GetRequiredService(), handler); + } +} diff --git a/magos-modificus/tests/Magos.Modificus.Integrations.Tests/Magos.Modificus.Integrations.Tests.csproj b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/Magos.Modificus.Integrations.Tests.csproj new file mode 100644 index 00000000..dacedb5f --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/Magos.Modificus.Integrations.Tests.csproj @@ -0,0 +1,33 @@ + + + + false + true + Magos.Modificus.Integrations.Tests + Magos.Modificus.Integrations.Tests + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/magos-modificus/tests/Magos.Modificus.Integrations.Tests/StubHttpMessageHandler.cs b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/StubHttpMessageHandler.cs new file mode 100644 index 00000000..d4ff810c --- /dev/null +++ b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/StubHttpMessageHandler.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Headers; + +namespace Magos.Modificus.Integrations.Tests; + +/// +/// A scriptable for offline GitHub client +/// tests. Returns canned responses via a callback and records a lightweight +/// snapshot of each request so tests can assert on the outgoing URI + headers. +/// +/// +/// No real network calls are made. The callback receives the request and returns +/// the response to send back, letting each test shape the JSON/status it needs. +/// +internal sealed class StubHttpMessageHandler : HttpMessageHandler +{ + private readonly Func _respond; + + public StubHttpMessageHandler(Func respond) + { + _respond = respond; + } + + /// Snapshots of each request seen, in arrival order. + public List Requests { get; } = new(); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(new RequestSnapshot + { + Method = request.Method, + RequestUri = request.RequestUri, + Authorization = request.Headers.Authorization?.ToString(), + UserAgent = request.Headers.UserAgent.ToString(), + Accept = string.Join(", ", request.Headers.Accept.Select(a => a.MediaType)), + }); + + return Task.FromResult(_respond(request)); + } +} + +/// +/// A minimal, header-only snapshot of a sent request — enough for assertions +/// without retaining the (single-use) itself. +/// +internal sealed class RequestSnapshot +{ + public HttpMethod Method { get; init; } = HttpMethod.Get; + public Uri? RequestUri { get; init; } + public string? Authorization { get; init; } + public string? UserAgent { get; init; } + public string? Accept { get; init; } +} + +/// Helpers for building canned HTTP responses in tests. +internal static class HttpResponses +{ + /// A 200 OK JSON response carrying . + public static HttpResponseMessage Json(string json, HttpStatusCode status = HttpStatusCode.OK) + { + var content = new StringContent(json); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return new HttpResponseMessage(status) { Content = content }; + } + + /// A response carrying the X-RateLimit-Remaining: 0 signal. + public static HttpResponseMessage RateLimited( + long resetEpochSeconds, + HttpStatusCode status = HttpStatusCode.Forbidden) + { + var response = new HttpResponseMessage(status); + response.Headers.Add("X-RateLimit-Remaining", "0"); + response.Headers.Add("X-RateLimit-Reset", resetEpochSeconds.ToString()); + response.Content = new StringContent(@"{""message"":""API rate limit exceeded""}"); + response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return response; + } +} From 0dd6070e7a4926ca9eda169d97697b464442dfe9 Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Wed, 1 Jul 2026 00:01:21 -0700 Subject: [PATCH 4/5] fix(integrations): UA dedup, partial-download cleanup, rate-limit status Folds in code-review fixes for the GitHub client: - User-Agent: the constructor's TryParseAdd always appends, so production (where AddIntegrations already sets the UA) sent a duplicated 'Magos-Modificus Magos-Modificus'. Now only adds a UA when none is set (guard on UserAgent.Count == 0); comment corrected. - DownloadAssetAsync: a failure mid-copy (network drop / cancellation) previously left a partial destination file. Wrap the stream copy in try/catch and best-effort delete the partial file before rethrowing (TryDelete swallows IOException/UnauthorizedAccessException so the original exception propagates). New test with a stream that throws mid-read asserts no partial file remains. - GitHubRateLimitException now carries the actual response status (was hardcoded 403); a 429-driven limit surfaces as 429, not 403. New 429 test covers the path (existing tests cover 403). - IsRateLimited uses HttpStatusCode.TooManyRequests instead of the magic (HttpStatusCode)429 cast. --- .../integrations/GitHubApiException.cs | 11 ++- magos-modificus/integrations/GitHubClient.cs | 58 ++++++++++--- .../GitHubClientTests.cs | 81 +++++++++++++++++++ 3 files changed, 131 insertions(+), 19 deletions(-) diff --git a/magos-modificus/integrations/GitHubApiException.cs b/magos-modificus/integrations/GitHubApiException.cs index 483fc3ea..2668f61a 100644 --- a/magos-modificus/integrations/GitHubApiException.cs +++ b/magos-modificus/integrations/GitHubApiException.cs @@ -1,5 +1,3 @@ -using System.Net; - namespace Magos.Modificus.Integrations; /// @@ -30,7 +28,8 @@ public GitHubApiException(int statusCode, string message) /// exhausted — detected via a 403/429 status carrying /// X-RateLimit-Remaining: 0. Carries the reset time /// (X-RateLimit-Reset) when GitHub reports it, so callers can advise the -/// user when to retry. +/// user when to retry. reflects the +/// actual response status (403 or 429). /// public sealed class GitHubRateLimitException : GitHubApiException { @@ -40,10 +39,10 @@ public sealed class GitHubRateLimitException : GitHubApiException /// public DateTimeOffset? ResetAt { get; } - internal GitHubRateLimitException(DateTimeOffset? resetAt) + internal GitHubRateLimitException(int statusCode, DateTimeOffset? resetAt) : base( - (int)HttpStatusCode.Forbidden, - "GitHub API rate limit exhausted" + statusCode, + "GitHub API rate limit exhausted (HTTP " + statusCode + ")" + (resetAt.HasValue ? $" — resets at {resetAt:O}." : ".")) { ResetAt = resetAt; diff --git a/magos-modificus/integrations/GitHubClient.cs b/magos-modificus/integrations/GitHubClient.cs index bae5241b..b2f2c641 100644 --- a/magos-modificus/integrations/GitHubClient.cs +++ b/magos-modificus/integrations/GitHubClient.cs @@ -38,10 +38,13 @@ public GitHubClient(HttpClient httpClient, ILogger logger) _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - // Defensive: the configured client should already carry a User-Agent - // (set in AddIntegrations), but guarantee one — GitHub rejects requests - // without it. TryAdd avoids duplicating if the factory already set it. - _httpClient.DefaultRequestHeaders.UserAgent.TryParseAdd(UserAgent); + // Guarantee a User-Agent — GitHub rejects requests without one. DI (via + // AddIntegrations) already sets it on the typed client, so only add one + // when none is present (avoids a duplicated User-Agent in production). + if (_httpClient.DefaultRequestHeaders.UserAgent.Count == 0) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); + } } /// @@ -85,14 +88,43 @@ public async Task DownloadAssetAsync( DownloadBufferSize, useAsync: true); - var buffer = new byte[DownloadBufferSize]; - long total = 0; - int read; - while ((read = await network.ReadAsync(buffer.AsMemory(0, DownloadBufferSize), ct).ConfigureAwait(false)) > 0) + try + { + var buffer = new byte[DownloadBufferSize]; + long total = 0; + int read; + while ((read = await network.ReadAsync(buffer.AsMemory(0, DownloadBufferSize), ct).ConfigureAwait(false)) > 0) + { + await file.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false); + total += read; + progress?.Report(total); + } + } + catch + { + // A failure mid-copy (network drop, cancellation, etc.) leaves a + // partial file at destinationPath — and we created it, so we own the + // cleanup. Dispose (release the Windows file handle) before deleting; + // File.Delete throws on an open handle. Best-effort: swallow cleanup + // failures so the original exception propagates unmasked. + file.Dispose(); + TryDelete(destinationPath); + throw; + } + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - await file.WriteAsync(buffer.AsMemory(0, read), ct).ConfigureAwait(false); - total += read; - progress?.Report(total); + // Best-effort — the original error is the one callers need to see. } } @@ -164,7 +196,7 @@ private async Task EnsureSuccessAsync(HttpResponseMessage response, Cancellation "GitHub API rate limit exhausted (status {Status}, reset at {Reset}).", (int)response.StatusCode, reset); - throw new GitHubRateLimitException(reset); + throw new GitHubRateLimitException((int)response.StatusCode, reset); } var message = await ReadErrorMessageAsync(response, ct).ConfigureAwait(false); @@ -178,7 +210,7 @@ private static bool IsRateLimited(HttpResponseMessage response) // sets X-RateLimit-Remaining: 0. A 403 for other reasons (permissions) // carries a non-zero remaining — so both conditions must hold. if (response.StatusCode != HttpStatusCode.Forbidden && - response.StatusCode != (HttpStatusCode)429) + response.StatusCode != HttpStatusCode.TooManyRequests) { return false; } diff --git a/magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs index f7ca618d..e539c193 100644 --- a/magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs +++ b/magos-modificus/tests/Magos.Modificus.Integrations.Tests/GitHubClientTests.cs @@ -223,6 +223,20 @@ public void GitHubRateLimitException_is_a_GitHubApiException() Assert.IsAssignableFrom(ex); } + [Fact] + public void GetLatestRelease_429_rate_limited_carries_actual_status() + { + // GitHub occasionally signals rate limiting with 429 Too Many Requests; + // the exception must surface that status (not a hardcoded 403). + var handler = new StubHttpMessageHandler(_ => + HttpResponses.RateLimited(1_700_000_000L, HttpStatusCode.TooManyRequests)); + var client = CreateClient(handler); + + var ex = Assert.Throws(() => client.GetLatestRelease(new GitHubRepo("o", "r"))); + Assert.Equal(429, ex.StatusCode); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000L), ex.ResetAt); + } + // ---- DownloadAssetAsync ------------------------------------------------- [Fact] @@ -317,6 +331,39 @@ public async Task DownloadAssetAsync_404_throws_GitHubApiException() } } + [Fact] + public async Task DownloadAssetAsync_failed_mid_stream_deletes_partial_file() + { + // Simulate a network drop: a stream that emits a few bytes, then throws. + // The client created the destination file, so it must clean up the + // partial write rather than leave a corrupt file behind. + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new HalfwayFailingStream(bytesBeforeFailure: 5)), + }); + var client = CreateClient(handler); + + var dest = Path.Combine(Path.GetTempPath(), "magos-integrations-" + Guid.NewGuid() + ".bin"); + var progress = new CapturingProgress(); + try + { + await Assert.ThrowsAsync(() => client.DownloadAssetAsync( + new GitHubReleaseAsset("a.zip", 1024, new Uri("https://github.com/o/r/releases/download/v1/a.zip")), + dest, + progress)); + + Assert.False(File.Exists(dest)); + Assert.NotEmpty(progress.Reports); // proves the copy started before failing + } + finally + { + if (File.Exists(dest)) + { + File.Delete(dest); + } + } + } + [Fact] public void ListReleases_null_repo_throws() { @@ -406,4 +453,38 @@ protected override Task SendAsync( return tcs.Task; } } + + /// + /// A read-only stream that emits bytes + /// then throws — simulates a mid-download network + /// drop so partial-file cleanup can be asserted. + /// + private sealed class HalfwayFailingStream : Stream + { + private int _remaining; + + public HalfwayFailingStream(int bytesBeforeFailure) => _remaining = bytesBeforeFailure; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + if (_remaining <= 0) + { + throw new IOException("simulated network drop mid-download"); + } + + buffer[offset] = 0xAB; + _remaining--; + return 1; + } + } } From 51f93de3acbac747d97ed0e463af72946c9607bc Mon Sep 17 00:00:00 2001 From: ModifAmorphic Date: Wed, 1 Jul 2026 00:01:32 -0700 Subject: [PATCH 5/5] docs(integrations): note ListReleases page-size cap ListReleases silently caps at GitHub's default page size (~30). Non-issue for the Phase-4 DMF prompt (GetLatestRelease via /releases/latest is the right tool and doesn't paginate), but a caller doing a version-history scan would get a truncated list. Doc-only; pagination deferred to a later phase. --- magos-modificus/integrations/IGitHubClient.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/magos-modificus/integrations/IGitHubClient.cs b/magos-modificus/integrations/IGitHubClient.cs index 9704281d..28323930 100644 --- a/magos-modificus/integrations/IGitHubClient.cs +++ b/magos-modificus/integrations/IGitHubClient.cs @@ -21,6 +21,7 @@ public interface IGitHubClient /// /// Lists a repository's published releases (newest first, per the GitHub API). /// A 404 (unknown repo) yields an empty list rather than an exception. + /// Returns up to GitHub's default page size (~30); pagination is a later phase. /// /// A non-2xx response other than 404. /// The API rate limit is exhausted.