// // Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later. // using System.Net; using System.Text.Json; using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Moq.Protected; using StellaOps.Integrations.Plugin.GitHubApp.CodeScanning; using Xunit; namespace StellaOps.Integrations.Tests.CodeScanning; /// /// Tests for . /// [Trait("Category", "Unit")] public class GitHubCodeScanningClientTests { private readonly Mock _httpHandlerMock; private readonly IHttpClientFactory _httpClientFactory; public GitHubCodeScanningClientTests() { _httpHandlerMock = new Mock(); var httpClient = new HttpClient(_httpHandlerMock.Object) { BaseAddress = new Uri("https://api.github.com") }; var factoryMock = new Mock(); factoryMock .Setup(f => f.CreateClient(GitHubCodeScanningClient.HttpClientName)) .Returns(httpClient); _httpClientFactory = factoryMock.Object; } [Fact] public async Task UploadSarifAsync_Success_ReturnsResult() { // Arrange var responseJson = JsonSerializer.Serialize(new { id = "sarif-123", url = "https://api.github.com/repos/owner/repo/code-scanning/sarifs/sarif-123" }); SetupHttpResponse(HttpStatusCode.Accepted, responseJson); var client = CreateClient(); var request = new SarifUploadRequest { CommitSha = "a".PadRight(40, 'b'), Ref = "refs/heads/main", SarifContent = "{\"version\":\"2.1.0\",\"runs\":[]}" }; // Act var result = await client.UploadSarifAsync("owner", "repo", request, CancellationToken.None); // Assert result.Id.Should().Be("sarif-123"); result.Status.Should().Be(ProcessingStatus.Pending); } [Fact] public async Task UploadSarifAsync_InvalidCommitSha_Throws() { // Arrange var client = CreateClient(); var request = new SarifUploadRequest { CommitSha = "short", Ref = "refs/heads/main", SarifContent = "{}" }; // Act & Assert await Assert.ThrowsAsync( () => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None)); } [Fact] public async Task UploadSarifAsync_InvalidRef_Throws() { // Arrange var client = CreateClient(); var request = new SarifUploadRequest { CommitSha = "a".PadRight(40, 'b'), Ref = "main", // Missing refs/ prefix SarifContent = "{}" }; // Act & Assert await Assert.ThrowsAsync( () => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None)); } [Fact] public async Task GetUploadStatusAsync_Complete_ReturnsStatus() { // Arrange var responseJson = JsonSerializer.Serialize(new { processing_status = "complete", analyses_url = "https://api.github.com/repos/owner/repo/code-scanning/analyses", results_count = 5, rules_count = 3 }); SetupHttpResponse(HttpStatusCode.OK, responseJson); var client = CreateClient(); // Act var status = await client.GetUploadStatusAsync("owner", "repo", "sarif-123", CancellationToken.None); // Assert status.Status.Should().Be(ProcessingStatus.Complete); status.ResultsCount.Should().Be(5); status.RulesCount.Should().Be(3); status.IsComplete.Should().BeTrue(); } [Fact] public async Task GetUploadStatusAsync_Pending_ReturnsStatus() { // Arrange var responseJson = JsonSerializer.Serialize(new { processing_status = "pending" }); SetupHttpResponse(HttpStatusCode.OK, responseJson); var client = CreateClient(); // Act var status = await client.GetUploadStatusAsync("owner", "repo", "sarif-123", CancellationToken.None); // Assert status.Status.Should().Be(ProcessingStatus.Pending); status.IsInProgress.Should().BeTrue(); } [Fact] public async Task GetUploadStatusAsync_Failed_ReturnsErrors() { // Arrange var responseJson = JsonSerializer.Serialize(new { processing_status = "failed", errors = new[] { "Invalid SARIF", "Missing runs" } }); SetupHttpResponse(HttpStatusCode.OK, responseJson); var client = CreateClient(); // Act var status = await client.GetUploadStatusAsync("owner", "repo", "sarif-123", CancellationToken.None); // Assert status.Status.Should().Be(ProcessingStatus.Failed); status.Errors.Should().HaveCount(2); status.Errors.Should().Contain("Invalid SARIF"); } [Fact] public async Task ListAlertsAsync_ReturnsAlerts() { // Arrange var alertsData = new object[] { new { number = 1, state = "open", rule = new { id = "csharp/sql-injection", severity = "high", description = "SQL injection" }, tool = new { name = "StellaOps", version = "1.0" }, html_url = "https://github.com/owner/repo/security/code-scanning/1", created_at = "2026-01-09T10:00:00Z" }, new { number = 2, state = "dismissed", rule = new { id = "csharp/xss", severity = "medium", description = "XSS vulnerability" }, tool = new { name = "StellaOps", version = "1.0" }, html_url = "https://github.com/owner/repo/security/code-scanning/2", created_at = "2026-01-08T10:00:00Z", dismissed_at = "2026-01-09T11:00:00Z", dismissed_reason = "false_positive" } }; var responseJson = JsonSerializer.Serialize(alertsData); SetupHttpResponse(HttpStatusCode.OK, responseJson); var client = CreateClient(); // Act var alerts = await client.ListAlertsAsync("owner", "repo", null, CancellationToken.None); // Assert alerts.Should().HaveCount(2); alerts[0].Number.Should().Be(1); alerts[0].State.Should().Be("open"); alerts[0].RuleId.Should().Be("csharp/sql-injection"); alerts[1].DismissedReason.Should().Be("false_positive"); } [Fact] public async Task ListAlertsAsync_WithFilter_AppliesQueryString() { // Arrange SetupHttpResponse(HttpStatusCode.OK, "[]"); var client = CreateClient(); var filter = new AlertFilter { State = "open", Severity = "high", PerPage = 50 }; // Act await client.ListAlertsAsync("owner", "repo", filter, CancellationToken.None); // Assert - Verify the request URL contained query parameters _httpHandlerMock.Protected().Verify( "SendAsync", Times.Once(), ItExpr.Is(req => req.RequestUri!.Query.Contains("state=open") && req.RequestUri.Query.Contains("severity=high") && req.RequestUri.Query.Contains("per_page=50")), ItExpr.IsAny()); } [Fact] public async Task GetAlertAsync_ReturnsAlert() { // Arrange var responseJson = JsonSerializer.Serialize(new { number = 42, state = "open", rule = new { id = "csharp/path-traversal", severity = "critical", description = "Path traversal" }, tool = new { name = "StellaOps" }, html_url = "https://github.com/owner/repo/security/code-scanning/42", created_at = "2026-01-09T10:00:00Z", most_recent_instance = new { @ref = "refs/heads/main", location = new { path = "src/Controllers/FileController.cs", start_line = 42, end_line = 45 } } }); SetupHttpResponse(HttpStatusCode.OK, responseJson); var client = CreateClient(); // Act var alert = await client.GetAlertAsync("owner", "repo", 42, CancellationToken.None); // Assert alert.Number.Should().Be(42); alert.RuleSeverity.Should().Be("critical"); alert.MostRecentInstance.Should().NotBeNull(); alert.MostRecentInstance!.Location!.Path.Should().Be("src/Controllers/FileController.cs"); alert.MostRecentInstance.Location.StartLine.Should().Be(42); } [Fact] public async Task UpdateAlertAsync_Dismiss_ReturnsUpdatedAlert() { // Arrange var responseJson = JsonSerializer.Serialize(new { number = 1, state = "dismissed", rule = new { id = "test", severity = "low", description = "Test" }, tool = new { name = "StellaOps" }, html_url = "https://github.com/owner/repo/security/code-scanning/1", created_at = "2026-01-09T10:00:00Z", dismissed_at = "2026-01-09T12:00:00Z", dismissed_reason = "false_positive" }); SetupHttpResponse(HttpStatusCode.OK, responseJson); var client = CreateClient(); var update = new AlertUpdate { State = "dismissed", DismissedReason = "false_positive", DismissedComment = "Not applicable to our use case" }; // Act var alert = await client.UpdateAlertAsync("owner", "repo", 1, update, CancellationToken.None); // Assert alert.State.Should().Be("dismissed"); alert.DismissedReason.Should().Be("false_positive"); } [Fact] public async Task UpdateAlertAsync_InvalidState_Throws() { // Arrange var client = CreateClient(); var update = new AlertUpdate { State = "invalid" }; // Act & Assert await Assert.ThrowsAsync( () => client.UpdateAlertAsync("owner", "repo", 1, update, CancellationToken.None)); } [Fact] public async Task UpdateAlertAsync_DismissWithoutReason_Throws() { // Arrange var client = CreateClient(); var update = new AlertUpdate { State = "dismissed" // Missing DismissedReason }; // Act & Assert await Assert.ThrowsAsync( () => client.UpdateAlertAsync("owner", "repo", 1, update, CancellationToken.None)); } [Fact] public async Task UploadSarifAsync_Unauthorized_ThrowsGitHubApiException() { // Arrange SetupHttpResponse(HttpStatusCode.Unauthorized, "{\"message\":\"Bad credentials\"}"); var client = CreateClient(); var request = new SarifUploadRequest { CommitSha = "a".PadRight(40, 'b'), Ref = "refs/heads/main", SarifContent = "{}" }; // Act & Assert var ex = await Assert.ThrowsAsync( () => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None)); ex.StatusCode.Should().Be(HttpStatusCode.Unauthorized); ex.Message.Should().Contain("authentication"); } [Fact] public async Task UploadSarifAsync_NotFound_ThrowsGitHubApiException() { // Arrange SetupHttpResponse(HttpStatusCode.NotFound, "{\"message\":\"Not Found\"}"); var client = CreateClient(); var request = new SarifUploadRequest { CommitSha = "a".PadRight(40, 'b'), Ref = "refs/heads/main", SarifContent = "{}" }; // Act & Assert var ex = await Assert.ThrowsAsync( () => client.UploadSarifAsync("owner", "repo", request, CancellationToken.None)); ex.StatusCode.Should().Be(HttpStatusCode.NotFound); } [Fact] public void AlertFilter_ToQueryString_BuildsCorrectQuery() { // Arrange var filter = new AlertFilter { State = "open", Severity = "high", Tool = "StellaOps", Ref = "refs/heads/main", PerPage = 100, Page = 2, Sort = "created", Direction = "desc" }; // Act var query = filter.ToQueryString(); // Assert query.Should().Contain("state=open"); query.Should().Contain("severity=high"); query.Should().Contain("tool_name=StellaOps"); query.Should().Contain("per_page=100"); query.Should().Contain("page=2"); query.Should().Contain("sort=created"); query.Should().Contain("direction=desc"); } [Fact] public void AlertFilter_ToQueryString_Empty_ReturnsEmpty() { // Arrange var filter = new AlertFilter(); // Act var query = filter.ToQueryString(); // Assert query.Should().BeEmpty(); } [Fact] public void SarifUploadRequest_Validate_EmptySarif_Throws() { // Arrange var request = new SarifUploadRequest { CommitSha = "a".PadRight(40, 'b'), Ref = "refs/heads/main", SarifContent = "" }; // Act & Assert Assert.Throws(() => request.Validate()); } private GitHubCodeScanningClient CreateClient() { return new GitHubCodeScanningClient( _httpClientFactory, NullLogger.Instance, TimeProvider.System); } private void SetupHttpResponse(HttpStatusCode statusCode, string content) { _httpHandlerMock.Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage { StatusCode = statusCode, Content = new StringContent(content) }); } }