Files
git.stella-ops.org/src/Integrations/__Tests/StellaOps.Integrations.Tests/CodeScanning/GitHubCodeScanningClientTests.cs
2026-01-09 18:27:46 +02:00

472 lines
14 KiB
C#

// <copyright file="GitHubCodeScanningClientTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
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;
/// <summary>
/// Tests for <see cref="GitHubCodeScanningClient"/>.
/// </summary>
[Trait("Category", "Unit")]
public class GitHubCodeScanningClientTests
{
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly IHttpClientFactory _httpClientFactory;
public GitHubCodeScanningClientTests()
{
_httpHandlerMock = new Mock<HttpMessageHandler>();
var httpClient = new HttpClient(_httpHandlerMock.Object)
{
BaseAddress = new Uri("https://api.github.com")
};
var factoryMock = new Mock<IHttpClientFactory>();
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<ArgumentException>(
() => 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<ArgumentException>(
() => 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<HttpRequestMessage>(req =>
req.RequestUri!.Query.Contains("state=open") &&
req.RequestUri.Query.Contains("severity=high") &&
req.RequestUri.Query.Contains("per_page=50")),
ItExpr.IsAny<CancellationToken>());
}
[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<ArgumentException>(
() => 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<ArgumentException>(
() => 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<GitHubApiException>(
() => 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<GitHubApiException>(
() => 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<ArgumentException>(() => request.Validate());
}
private GitHubCodeScanningClient CreateClient()
{
return new GitHubCodeScanningClient(
_httpClientFactory,
NullLogger<GitHubCodeScanningClient>.Instance,
TimeProvider.System);
}
private void SetupHttpResponse(HttpStatusCode statusCode, string content)
{
_httpHandlerMock.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = statusCode,
Content = new StringContent(content)
});
}
}