472 lines
14 KiB
C#
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)
|
|
});
|
|
}
|
|
}
|