Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -57,12 +57,12 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal("CVE-2024-12345", approval!.FindingId);
|
||||
Assert.Equal("AcceptRisk", approval.Decision);
|
||||
@@ -83,7 +83,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
@@ -102,7 +102,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
@@ -121,7 +121,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
@@ -168,12 +168,12 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request);
|
||||
var response = await _client.PostAsJsonAsync($"/api/v1/scans/{scanId}/approvals", request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal(decision, approval!.Decision);
|
||||
}
|
||||
@@ -189,7 +189,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
var scanId = await CreateTestScanAsync();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -222,7 +222,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -253,7 +253,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(approval);
|
||||
Assert.Equal(findingId, approval!.FindingId);
|
||||
Assert.Equal("Suppress", approval.Decision);
|
||||
@@ -328,7 +328,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
await _client.DeleteAsync($"/api/v1/scans/{scanId}/approvals/{findingId}");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals");
|
||||
var response = await _client.GetAsync($"/api/v1/scans/{scanId}/approvals", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -361,7 +361,7 @@ public sealed class ApprovalEndpointsTests : IDisposable
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>();
|
||||
var approval = await response.Content.ReadFromJsonAsync<ApprovalResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(approval);
|
||||
Assert.True(approval!.IsRevoked);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ public sealed class BaselineEndpointsTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sha256:artifact123", result!.ArtifactDigest);
|
||||
Assert.NotEmpty(result.Recommendations);
|
||||
@@ -44,10 +44,10 @@ public sealed class BaselineEndpointsTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123?environment=production");
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123?environment=production", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result!.Recommendations);
|
||||
}
|
||||
@@ -59,8 +59,8 @@ public sealed class BaselineEndpointsTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
foreach (var rec in result!.Recommendations)
|
||||
@@ -112,8 +112,8 @@ public sealed class BaselineEndpointsTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123");
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions);
|
||||
var response = await client.GetAsync("/api/v1/baselines/recommendations/sha256:artifact123", TestContext.Current.CancellationToken);
|
||||
var result = await response.Content.ReadFromJsonAsync<BaselineRecommendationsResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotEmpty(result!.Recommendations);
|
||||
|
||||
@@ -24,7 +24,7 @@ public sealed class CallGraphEndpointsTests
|
||||
var scanId = await CreateScanAsync(client);
|
||||
var request = CreateMinimalCallGraph(scanId);
|
||||
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/scans/{scanId}/callgraphs", request);
|
||||
var response = await client.PostAsJsonAsync($"/api/v1/scans/{scanId}/callgraphs", request, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
@@ -49,10 +49,10 @@ public sealed class CallGraphEndpointsTests
|
||||
};
|
||||
httpRequest.Headers.TryAddWithoutValidation("Content-Digest", "sha256:deadbeef");
|
||||
|
||||
var first = await client.SendAsync(httpRequest);
|
||||
var first = await client.SendAsync(httpRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, first.StatusCode);
|
||||
|
||||
var payload = await first.Content.ReadFromJsonAsync<CallGraphAcceptedResponseDto>();
|
||||
var payload = await first.Content.ReadFromJsonAsync<CallGraphAcceptedResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload!.CallgraphId));
|
||||
Assert.Equal("sha256:deadbeef", payload.Digest);
|
||||
|
||||
@@ -35,10 +35,10 @@ public sealed class CounterfactualEndpointsTests
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("finding-123", result!.FindingId);
|
||||
Assert.Equal("Block", result.CurrentVerdict);
|
||||
@@ -60,7 +60,7 @@ public sealed class CounterfactualEndpointsTests
|
||||
VulnId = "CVE-2021-44228"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ public sealed class CounterfactualEndpointsTests
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result!.Paths, p => p.Type == "Vex");
|
||||
@@ -99,8 +99,8 @@ public sealed class CounterfactualEndpointsTests
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result!.Paths, p => p.Type == "Reachability");
|
||||
@@ -120,8 +120,8 @@ public sealed class CounterfactualEndpointsTests
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Contains(result!.Paths, p => p.Type == "Exception");
|
||||
@@ -142,8 +142,8 @@ public sealed class CounterfactualEndpointsTests
|
||||
MaxPaths = 2
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Paths.Count <= 2);
|
||||
@@ -159,7 +159,7 @@ public sealed class CounterfactualEndpointsTests
|
||||
var response = await client.GetAsync("/api/v1/counterfactuals/finding/finding-123");
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("finding-123", result!.FindingId);
|
||||
}
|
||||
@@ -212,8 +212,8 @@ public sealed class CounterfactualEndpointsTests
|
||||
CurrentVerdict = "Block"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/counterfactuals/compute", request, TestContext.Current.CancellationToken);
|
||||
var result = await response.Content.ReadFromJsonAsync<CounterfactualResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
foreach (var path in result!.Paths)
|
||||
|
||||
@@ -36,10 +36,10 @@ public sealed class DeltaCompareEndpointsTests
|
||||
IncludePolicyDiff = true
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions);
|
||||
var result = await response.Content.ReadFromJsonAsync<DeltaCompareResponseDto>(SerializerOptions, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.Base);
|
||||
Assert.NotNull(result.Target);
|
||||
@@ -62,7 +62,7 @@ public sealed class DeltaCompareEndpointsTests
|
||||
TargetDigest = "sha256:target456"
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class DeltaCompareEndpointsTests
|
||||
TargetDigest = ""
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/delta/compare", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,11 +50,11 @@ public sealed class EpssEndpointsTests : IDisposable
|
||||
[Fact(DisplayName = "POST /epss/current rejects empty CVE list")]
|
||||
public async Task PostCurrentBatch_EmptyList_ReturnsBadRequest()
|
||||
{
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = Array.Empty<string>() });
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds = Array.Empty<string>() }, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Invalid request", problem!.Title);
|
||||
}
|
||||
@@ -64,11 +64,11 @@ public sealed class EpssEndpointsTests : IDisposable
|
||||
{
|
||||
var cveIds = Enumerable.Range(1, 1001).Select(i => $"CVE-2025-{i:D5}").ToArray();
|
||||
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds });
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/epss/current", new { cveIds }, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Batch size exceeded", problem!.Title);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ public sealed class EpssEndpointsTests : IDisposable
|
||||
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal(503, problem!.Status);
|
||||
Assert.Contains("EPSS data is not available", problem.Detail, StringComparison.Ordinal);
|
||||
@@ -133,7 +133,7 @@ public sealed class EpssEndpointsTests : IDisposable
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("CVE not found", problem!.Title);
|
||||
}
|
||||
@@ -168,7 +168,7 @@ public sealed class EpssEndpointsTests : IDisposable
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Invalid date format", problem!.Title);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ public sealed class EpssEndpointsTests : IDisposable
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
|
||||
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("No history found", problem!.Title);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts?page=1&pageSize=25";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -50,7 +50,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts?band=HOT&page=1&pageSize=25";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -63,7 +63,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts?severity=CRITICAL,HIGH&page=1";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -76,7 +76,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts?status=open&page=1";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -89,7 +89,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts?sortBy=score&sortOrder=desc&page=1";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -106,7 +106,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-nonexistent-12345";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
@@ -123,7 +123,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-nonexistent-12345/evidence";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
@@ -136,7 +136,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-12345/evidence?format=minimal";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -149,7 +149,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-12345/evidence?format=full";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -172,7 +172,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(request, decision);
|
||||
var response = await _client.PostAsJsonAsync(request, decision, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
@@ -190,7 +190,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(request, decision);
|
||||
var response = await _client.PostAsJsonAsync(request, decision, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -211,7 +211,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(request, decision);
|
||||
var response = await _client.PostAsJsonAsync(request, decision, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -231,7 +231,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-nonexistent-12345/audit";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
@@ -244,7 +244,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-12345/audit?page=1&pageSize=50";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
@@ -261,7 +261,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-nonexistent-12345/replay-token";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
@@ -275,7 +275,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var verifyRequest = new { token = "invalid-token-12345" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(request, verifyRequest);
|
||||
var response = await _client.PostAsJsonAsync(request, verifyRequest, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -295,7 +295,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-nonexistent-12345/bundle";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
@@ -309,7 +309,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var bundleData = new { bundleId = "bundle-12345" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync(request, bundleData);
|
||||
var response = await _client.PostAsJsonAsync(request, bundleData, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -329,7 +329,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-nonexistent-12345/diff";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
@@ -342,7 +342,7 @@ public sealed class TriageWorkflowIntegrationTests : IClassFixture<ScannerApplic
|
||||
var request = "/api/v1/alerts/alert-12345/diff?baseline=scan-001";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync(request);
|
||||
var response = await _client.GetAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NotFound);
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// LayerSbomEndpointsTests.cs
|
||||
// Sprint: SPRINT_20260106_003_001_SCANNER_perlayer_sbom_api
|
||||
// Task: T016 - Integration tests for layer SBOM API
|
||||
// Description: Integration tests for per-layer SBOM and composition recipe endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Emit.Composition;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Domain;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class LayerSbomEndpointsTests
|
||||
{
|
||||
private const string BasePath = "/api/v1/scans";
|
||||
|
||||
#region List Layers Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ListLayers_WhenScanExists_ReturnsLayers()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(3));
|
||||
var stubCoordinator = new StubScanCoordinator();
|
||||
stubCoordinator.AddScan(scanId, "sha256:image123");
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.RemoveAll<IScanCoordinator>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
services.AddSingleton<IScanCoordinator>(stubCoordinator);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/layers");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<LayerListResponseDto>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(scanId, result!.ScanId);
|
||||
Assert.Equal("sha256:image123", result.ImageDigest);
|
||||
Assert.Equal(3, result.Layers.Count);
|
||||
Assert.All(result.Layers, l => Assert.True(l.HasSbom));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListLayers_WhenScanNotFound_Returns404()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/scan-not-found/layers");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListLayers_LayersOrderedByOrder()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
var layers = new[]
|
||||
{
|
||||
CreateLayerSummary("sha256:layer2", 2, 15),
|
||||
CreateLayerSummary("sha256:layer0", 0, 42),
|
||||
CreateLayerSummary("sha256:layer1", 1, 8),
|
||||
};
|
||||
mockService.AddScan(scanId, "sha256:image123", layers);
|
||||
var stubCoordinator = new StubScanCoordinator();
|
||||
stubCoordinator.AddScan(scanId, "sha256:image123");
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.RemoveAll<IScanCoordinator>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
services.AddSingleton<IScanCoordinator>(stubCoordinator);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/layers");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<LayerListResponseDto>();
|
||||
Assert.NotNull(result);
|
||||
// Verify layer order is as stored (service already orders by Order)
|
||||
Assert.Equal(0, result!.Layers[0].Order);
|
||||
Assert.Equal(1, result.Layers[1].Order);
|
||||
Assert.Equal(2, result.Layers[2].Order);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Get Layer SBOM Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerSbom_DefaultFormat_ReturnsCycloneDx()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var layerDigest = "sha256:layer123";
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest));
|
||||
mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx"));
|
||||
mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx"));
|
||||
var stubCoordinator = new StubScanCoordinator();
|
||||
stubCoordinator.AddScan(scanId, "sha256:image123");
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.RemoveAll<IScanCoordinator>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
services.AddSingleton<IScanCoordinator>(stubCoordinator);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("cyclonedx", response.Content.Headers.ContentType?.ToString());
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("cyclonedx", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerSbom_SpdxFormat_ReturnsSpdx()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var layerDigest = "sha256:layer123";
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest));
|
||||
mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx"));
|
||||
mockService.AddLayerSbom(scanId, layerDigest, "spdx", CreateTestSbomBytes("spdx"));
|
||||
var stubCoordinator = new StubScanCoordinator();
|
||||
stubCoordinator.AddScan(scanId, "sha256:image123");
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.RemoveAll<IScanCoordinator>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
services.AddSingleton<IScanCoordinator>(stubCoordinator);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom?format=spdx");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains("spdx", response.Content.Headers.ContentType?.ToString());
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("spdx", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerSbom_SetsImmutableCacheHeaders()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var layerDigest = "sha256:layer123";
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1, layerDigest));
|
||||
mockService.AddLayerSbom(scanId, layerDigest, "cdx", CreateTestSbomBytes("cyclonedx"));
|
||||
var stubCoordinator = new StubScanCoordinator();
|
||||
stubCoordinator.AddScan(scanId, "sha256:image123");
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.RemoveAll<IScanCoordinator>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
services.AddSingleton<IScanCoordinator>(stubCoordinator);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/{layerDigest}/sbom");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.ETag);
|
||||
Assert.Contains("immutable", response.Headers.CacheControl?.ToString());
|
||||
Assert.True(response.Headers.Contains("X-StellaOps-Layer-Digest"));
|
||||
Assert.True(response.Headers.Contains("X-StellaOps-Format"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerSbom_WhenScanNotFound_Returns404()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/scan-not-found/layers/sha256:layer123/sbom");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetLayerSbom_WhenLayerNotFound_Returns404()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1));
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/layers/sha256:nonexistent/sbom");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Composition Recipe Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompositionRecipe_WhenExists_ReturnsRecipe()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2));
|
||||
mockService.AddCompositionRecipe(scanId, CreateTestRecipe(scanId, "sha256:image123", 2));
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/composition-recipe");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<CompositionRecipeResponseDto>();
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(scanId, result!.ScanId);
|
||||
Assert.Equal("sha256:image123", result.ImageDigest);
|
||||
Assert.NotNull(result.Recipe);
|
||||
Assert.Equal(2, result.Recipe.Layers.Count);
|
||||
Assert.NotNull(result.Recipe.MerkleRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompositionRecipe_WhenScanNotFound_Returns404()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/scan-not-found/composition-recipe");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCompositionRecipe_WhenRecipeNotAvailable_Returns404()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(1));
|
||||
// Note: not adding composition recipe
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/composition-recipe");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Verify Composition Recipe Tests
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCompositionRecipe_WhenValid_ReturnsSuccess()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2));
|
||||
mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult
|
||||
{
|
||||
Valid = true,
|
||||
MerkleRootMatch = true,
|
||||
LayerDigestsMatch = true,
|
||||
Errors = ImmutableArray<string>.Empty,
|
||||
});
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<CompositionRecipeVerificationResponseDto>();
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Valid);
|
||||
Assert.True(result.MerkleRootMatch);
|
||||
Assert.True(result.LayerDigestsMatch);
|
||||
Assert.Null(result.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCompositionRecipe_WhenInvalid_ReturnsErrors()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryLayerSbomService();
|
||||
mockService.AddScan(scanId, "sha256:image123", CreateTestLayers(2));
|
||||
mockService.SetVerificationResult(scanId, new CompositionRecipeVerificationResult
|
||||
{
|
||||
Valid = false,
|
||||
MerkleRootMatch = false,
|
||||
LayerDigestsMatch = true,
|
||||
Errors = ImmutableArray.Create("Merkle root mismatch: expected sha256:abc, got sha256:def"),
|
||||
});
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync($"{BasePath}/{scanId}/composition-recipe/verify", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<CompositionRecipeVerificationResponseDto>();
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result!.Valid);
|
||||
Assert.False(result.MerkleRootMatch);
|
||||
Assert.NotNull(result.Errors);
|
||||
Assert.Single(result.Errors!);
|
||||
Assert.Contains("Merkle root mismatch", result.Errors![0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCompositionRecipe_WhenScanNotFound_Returns404()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<ILayerSbomService>();
|
||||
services.AddSingleton<ILayerSbomService, InMemoryLayerSbomService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync($"{BasePath}/scan-not-found/composition-recipe/verify", null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Helpers
|
||||
|
||||
private static LayerSummary[] CreateTestLayers(int count, string? specificDigest = null)
|
||||
{
|
||||
var layers = new LayerSummary[count];
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
layers[i] = CreateLayerSummary(
|
||||
i == 0 && specificDigest != null ? specificDigest : $"sha256:layer{i}",
|
||||
i,
|
||||
10 + i * 5);
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
private static LayerSummary CreateLayerSummary(string digest, int order, int componentCount)
|
||||
{
|
||||
return new LayerSummary
|
||||
{
|
||||
LayerDigest = digest,
|
||||
Order = order,
|
||||
HasSbom = true,
|
||||
ComponentCount = componentCount,
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateTestSbomBytes(string format)
|
||||
{
|
||||
var content = format == "spdx"
|
||||
? """{"spdxVersion":"SPDX-3.0.1","format":"spdx"}"""
|
||||
: """{"bomFormat":"CycloneDX","specVersion":"1.7","format":"cyclonedx"}""";
|
||||
return Encoding.UTF8.GetBytes(content);
|
||||
}
|
||||
|
||||
private static CompositionRecipeResponse CreateTestRecipe(string scanId, string imageDigest, int layerCount)
|
||||
{
|
||||
var layers = new CompositionRecipeLayer[layerCount];
|
||||
for (int i = 0; i < layerCount; i++)
|
||||
{
|
||||
layers[i] = new CompositionRecipeLayer
|
||||
{
|
||||
Digest = $"sha256:layer{i}",
|
||||
Order = i,
|
||||
FragmentDigest = $"sha256:frag{i}",
|
||||
SbomDigests = new LayerSbomDigests
|
||||
{
|
||||
CycloneDx = $"sha256:cdx{i}",
|
||||
Spdx = $"sha256:spdx{i}",
|
||||
},
|
||||
ComponentCount = 10 + i * 5,
|
||||
};
|
||||
}
|
||||
|
||||
return new CompositionRecipeResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
ImageDigest = imageDigest,
|
||||
CreatedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
Recipe = new CompositionRecipe
|
||||
{
|
||||
Version = "1.0.0",
|
||||
GeneratorName = "StellaOps.Scanner",
|
||||
GeneratorVersion = "2026.04",
|
||||
Layers = layers.ToImmutableArray(),
|
||||
MerkleRoot = "sha256:merkleroot123",
|
||||
AggregatedSbomDigests = new AggregatedSbomDigests
|
||||
{
|
||||
CycloneDx = "sha256:finalcdx",
|
||||
Spdx = "sha256:finalspdx",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of ILayerSbomService for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryLayerSbomService : ILayerSbomService
|
||||
{
|
||||
private readonly Dictionary<string, (string ImageDigest, LayerSummary[] Layers)> _scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<(string ScanId, string LayerDigest, string Format), byte[]> _layerSboms = new();
|
||||
private readonly Dictionary<string, CompositionRecipeResponse> _recipes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, CompositionRecipeVerificationResult> _verificationResults = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddScan(string scanId, string imageDigest, LayerSummary[] layers)
|
||||
{
|
||||
_scans[scanId] = (imageDigest, layers);
|
||||
}
|
||||
|
||||
public bool HasScan(string scanId) => _scans.ContainsKey(scanId);
|
||||
|
||||
public (string ImageDigest, LayerSummary[] Layers)? GetScanData(string scanId)
|
||||
{
|
||||
if (_scans.TryGetValue(scanId, out var data))
|
||||
return data;
|
||||
return null;
|
||||
}
|
||||
|
||||
public void AddLayerSbom(string scanId, string layerDigest, string format, byte[] sbomBytes)
|
||||
{
|
||||
_layerSboms[(scanId, layerDigest, format)] = sbomBytes;
|
||||
}
|
||||
|
||||
public void AddCompositionRecipe(string scanId, CompositionRecipeResponse recipe)
|
||||
{
|
||||
_recipes[scanId] = recipe;
|
||||
}
|
||||
|
||||
public void SetVerificationResult(string scanId, CompositionRecipeVerificationResult result)
|
||||
{
|
||||
_verificationResults[scanId] = result;
|
||||
}
|
||||
|
||||
public Task<ImmutableArray<LayerSummary>> GetLayerSummariesAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_scans.TryGetValue(scanId.Value, out var scanData))
|
||||
{
|
||||
return Task.FromResult(ImmutableArray<LayerSummary>.Empty);
|
||||
}
|
||||
|
||||
return Task.FromResult(scanData.Layers.OrderBy(l => l.Order).ToImmutableArray());
|
||||
}
|
||||
|
||||
public Task<byte[]?> GetLayerSbomAsync(
|
||||
ScanId scanId,
|
||||
string layerDigest,
|
||||
string format,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_layerSboms.TryGetValue((scanId.Value, layerDigest, format), out var sbomBytes))
|
||||
{
|
||||
return Task.FromResult<byte[]?>(sbomBytes);
|
||||
}
|
||||
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
|
||||
public Task<CompositionRecipeResponse?> GetCompositionRecipeAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_recipes.TryGetValue(scanId.Value, out var recipe))
|
||||
{
|
||||
return Task.FromResult<CompositionRecipeResponse?>(recipe);
|
||||
}
|
||||
|
||||
return Task.FromResult<CompositionRecipeResponse?>(null);
|
||||
}
|
||||
|
||||
public Task<CompositionRecipeVerificationResult?> VerifyCompositionRecipeAsync(
|
||||
ScanId scanId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_verificationResults.TryGetValue(scanId.Value, out var result))
|
||||
{
|
||||
return Task.FromResult<CompositionRecipeVerificationResult?>(result);
|
||||
}
|
||||
|
||||
return Task.FromResult<CompositionRecipeVerificationResult?>(null);
|
||||
}
|
||||
|
||||
public Task StoreLayerSbomsAsync(
|
||||
ScanId scanId,
|
||||
string imageDigest,
|
||||
LayerSbomCompositionResult result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Not implemented for tests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stub IScanCoordinator that returns snapshots for registered scans.
|
||||
/// </summary>
|
||||
internal sealed class StubScanCoordinator : IScanCoordinator
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ScanSnapshot> _scans = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddScan(string scanId, string imageDigest)
|
||||
{
|
||||
var snapshot = new ScanSnapshot(
|
||||
ScanId.Parse(scanId),
|
||||
new ScanTarget("test-image", imageDigest, null),
|
||||
ScanStatus.Completed,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
DateTimeOffset.UtcNow,
|
||||
null, null, null);
|
||||
_scans[scanId] = snapshot;
|
||||
}
|
||||
|
||||
public ValueTask<ScanSubmissionResult> SubmitAsync(ScanSubmission submission, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
public ValueTask<ScanSnapshot?> GetAsync(ScanId scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_scans.TryGetValue(scanId.Value, out var snapshot))
|
||||
{
|
||||
return ValueTask.FromResult<ScanSnapshot?>(snapshot);
|
||||
}
|
||||
return ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
}
|
||||
|
||||
public ValueTask<ScanSnapshot?> TryFindByTargetAsync(string? reference, string? digest, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult<ScanSnapshot?>(null);
|
||||
|
||||
public ValueTask<bool> AttachReplayAsync(ScanId scanId, ReplayArtifacts replay, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(false);
|
||||
|
||||
public ValueTask<bool> AttachEntropyAsync(ScanId scanId, EntropySnapshot entropy, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(false);
|
||||
}
|
||||
@@ -57,15 +57,15 @@ public sealed class ManifestEndpointsTests
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await manifestRepository.SaveAsync(manifestRow);
|
||||
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest");
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>();
|
||||
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(scanId, manifest!.ScanId);
|
||||
Assert.Equal("sha256:manifest123", manifest.ManifestHash);
|
||||
@@ -86,7 +86,7 @@ public sealed class ManifestEndpointsTests
|
||||
var scanId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest");
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -147,7 +147,7 @@ public sealed class ManifestEndpointsTests
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await manifestRepository.SaveAsync(manifestRow);
|
||||
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scans/{scanId}/manifest");
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(DsseContentType));
|
||||
@@ -195,15 +195,15 @@ public sealed class ManifestEndpointsTests
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await manifestRepository.SaveAsync(manifestRow);
|
||||
await manifestRepository.SaveAsync(manifestRow, TestContext.Current.CancellationToken);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest");
|
||||
var response = await client.GetAsync($"/api/v1/scans/{scanId}/manifest", TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>();
|
||||
var manifest = await response.Content.ReadFromJsonAsync<ScanManifestResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(manifest);
|
||||
Assert.NotNull(manifest!.ContentDigest);
|
||||
Assert.StartsWith("sha-256=", manifest.ContentDigest);
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var content = new StringContent("{\"test\": true}", Encoding.UTF8, contentType);
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType,
|
||||
$"POST with content-type '{contentType}' should return 415");
|
||||
@@ -59,7 +59,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
var content = new StringContent("{\"test\": true}", Encoding.UTF8);
|
||||
content.Headers.ContentType = null;
|
||||
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should be either 415 or 400 depending on implementation
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -84,7 +84,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
var largeContent = new string('x', 50 * 1024 * 1024);
|
||||
var content = new StringContent($"{{\"data\": \"{largeContent}\"}}", Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should be 413 or the request might timeout/fail
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -109,7 +109,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var request = new HttpRequestMessage(new HttpMethod(method), endpoint);
|
||||
var response = await client.SendAsync(request);
|
||||
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed,
|
||||
$"{method} {endpoint} should return 405");
|
||||
@@ -128,7 +128,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var content = new StringContent("{ invalid json }", Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.BadRequest,
|
||||
@@ -144,7 +144,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.BadRequest,
|
||||
@@ -160,7 +160,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.BadRequest,
|
||||
@@ -182,7 +182,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(endpoint);
|
||||
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.NotFound,
|
||||
@@ -197,7 +197,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/nonexistent");
|
||||
var response = await client.GetAsync("/api/v1/nonexistent", TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
@@ -217,7 +217,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(endpoint);
|
||||
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
HttpStatusCode.BadRequest,
|
||||
@@ -235,7 +235,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(endpoint);
|
||||
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not cause server error (500)
|
||||
response.StatusCode.Should().NotBe(HttpStatusCode.InternalServerError,
|
||||
@@ -255,7 +255,7 @@ public sealed class ScannerNegativeTests : IClassFixture<ScannerApplicationFacto
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var tasks = Enumerable.Range(0, 100)
|
||||
.Select(_ => client.GetAsync("/api/v1/health"));
|
||||
.Select(_ => client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken));
|
||||
|
||||
var responses = await Task.WhenAll(tasks);
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ public sealed class PolicyEndpointsTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/policy/schema");
|
||||
var response = await client.GetAsync("/api/v1/policy/schema", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("application/schema+json", response.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
var payload = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains("\"$schema\"", payload);
|
||||
Assert.Contains("\"properties\"", payload);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public sealed class PolicyEndpointsTests
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/policy/diagnostics", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/policy/diagnostics", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var diagnostics = await response.Content.ReadFromJsonAsync<PolicyDiagnosticsResponseDto>(SerializerOptions);
|
||||
|
||||
@@ -35,17 +35,17 @@ public sealed class RuntimeEndpointsTests
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<RuntimeEventsIngestResponseDto>();
|
||||
var payload = await response.Content.ReadFromJsonAsync<RuntimeEventsIngestResponseDto>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal(2, payload!.Accepted);
|
||||
Assert.Equal(0, payload.Duplicates);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<RuntimeEventRepository>();
|
||||
var stored = await repository.ListAsync(CancellationToken.None);
|
||||
var stored = await repository.ListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(2, stored.Count);
|
||||
Assert.Contains(stored, doc => doc.EventId == "evt-001");
|
||||
Assert.All(stored, doc =>
|
||||
@@ -71,7 +71,7 @@ public sealed class RuntimeEndpointsTests
|
||||
Events = new[] { envelope }
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed class RuntimeEndpointsTests
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request);
|
||||
var response = await client.PostAsJsonAsync("/api/v1/runtime/events", request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal((HttpStatusCode)StatusCodes.Status429TooManyRequests, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.RetryAfter);
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class SbomEndpointsTests
|
||||
new System.Net.Http.Headers.NameValueHeaderValue("version", "1.7"));
|
||||
request.Content = content;
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomAcceptedResponseDto>();
|
||||
|
||||
@@ -38,9 +38,9 @@ public sealed partial class ScansEndpointsTests
|
||||
});
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/scans", new { image = new { digest = "sha256:demo" } });
|
||||
var submit = await client.PostAsJsonAsync("/api/v1/scans", new { image = new { digest = "sha256:demo" } }, TestContext.Current.CancellationToken);
|
||||
submit.EnsureSuccessStatusCode();
|
||||
var scanId = (await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>())!.ScanId;
|
||||
var scanId = (await submit.Content.ReadFromJsonAsync<ScanSubmitResponse>(TestContext.Current.CancellationToken))!.ScanId;
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var recordMode = scope.ServiceProvider.GetRequiredService<IRecordModeService>();
|
||||
@@ -66,13 +66,13 @@ public sealed partial class ScansEndpointsTests
|
||||
ScanTime = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var result = await recordMode.RecordAsync(request, coordinator);
|
||||
var result = await recordMode.RecordAsync(request, coordinator, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("sha256:sbom", result.Run.Outputs.Sbom);
|
||||
Assert.True(store.Objects.Count >= 2);
|
||||
|
||||
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}");
|
||||
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(status!.Replay);
|
||||
Assert.Equal(result.Artifacts.ManifestHash, status.Replay!.ManifestHash);
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ public sealed partial class ScansEndpointsTests
|
||||
var submitResponse = await client.PostAsJsonAsync("/api/v1/scans", new
|
||||
{
|
||||
image = new { digest = "sha256:demo" }
|
||||
});
|
||||
}, TestContext.Current.CancellationToken);
|
||||
submitResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var submitPayload = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>();
|
||||
var submitPayload = await submitResponse.Content.ReadFromJsonAsync<ScanSubmitResponse>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(submitPayload);
|
||||
var scanId = submitPayload!.ScanId;
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed partial class ScansEndpointsTests
|
||||
|
||||
Assert.NotNull(replay);
|
||||
|
||||
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}");
|
||||
var status = await client.GetFromJsonAsync<ScanStatusResponse>($"/api/v1/scans/{scanId}", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(status);
|
||||
Assert.NotNull(status!.Replay);
|
||||
Assert.Equal(replay!.ManifestHash, status.Replay!.ManifestHash);
|
||||
|
||||
@@ -37,8 +37,7 @@ public sealed class ScannerAuthorizationTests
|
||||
useTestAuthentication: true);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync(endpoint, content);
|
||||
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
|
||||
|
||||
// Without auth token, POST should fail - not succeed
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -61,7 +60,7 @@ public sealed class ScannerAuthorizationTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync(endpoint);
|
||||
var response = await client.GetAsync(endpoint, TestContext.Current.CancellationToken);
|
||||
|
||||
// Health endpoints should be accessible without auth (or not configured)
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -89,9 +88,7 @@ public sealed class ScannerAuthorizationTests
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "expired.token.here");
|
||||
|
||||
// Use POST to an endpoint that accepts POST
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not get a successful response with invalid token
|
||||
// BadRequest may occur if endpoint validates body before auth or auth rejects first
|
||||
@@ -116,8 +113,7 @@ public sealed class ScannerAuthorizationTests
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not get a successful response with malformed token
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -141,8 +137,7 @@ public sealed class ScannerAuthorizationTests
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "wrong.issuer.token");
|
||||
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not get a successful response with wrong issuer
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -166,8 +161,7 @@ public sealed class ScannerAuthorizationTests
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", "wrong.audience.token");
|
||||
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not get a successful response with wrong audience
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -189,7 +183,7 @@ public sealed class ScannerAuthorizationTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v1/health");
|
||||
var response = await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should be accessible without authentication (or endpoint not configured)
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -208,8 +202,7 @@ public sealed class ScannerAuthorizationTests
|
||||
useTestAuthentication: true);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not get a successful response without authentication
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -235,7 +228,7 @@ public sealed class ScannerAuthorizationTests
|
||||
|
||||
// Without proper auth, POST should fail
|
||||
var content = new StringContent("{}", System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/v1/scans", content);
|
||||
var response = await client.PostAsync("/api/v1/scans", content, TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not get a successful response without authentication
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -255,7 +248,7 @@ public sealed class ScannerAuthorizationTests
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.DeleteAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000");
|
||||
var response = await client.DeleteAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should not get a successful response without authentication
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -278,8 +271,8 @@ public sealed class ScannerAuthorizationTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
// Request without tenant header - use health endpoint which supports GET
|
||||
var response = await client.GetAsync("/api/v1/health");
|
||||
// Request without tenant header
|
||||
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should succeed without tenant header (or endpoint not configured)
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
@@ -301,7 +294,7 @@ public sealed class ScannerAuthorizationTests
|
||||
using var factory = new ScannerApplicationFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/health");
|
||||
var response = await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
|
||||
|
||||
// Check for common security headers (may vary by configuration)
|
||||
// These are recommendations, not hard requirements
|
||||
@@ -321,7 +314,7 @@ public sealed class ScannerAuthorizationTests
|
||||
request.Headers.Add("Origin", "https://example.com");
|
||||
request.Headers.Add("Access-Control-Request-Method", "GET");
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
// CORS preflight should either succeed or be explicitly denied
|
||||
response.StatusCode.Should().BeOneOf(
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>StellaOps.Scanner.WebService.Tests</RootNamespace>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Scanner.WebService/StellaOps.Scanner.WebService.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Gate/StellaOps.Scanner.Gate.csproj" />
|
||||
<ProjectReference Include="../../../__Tests/__Libraries/StellaOps.Infrastructure.Postgres.Testing/StellaOps.Infrastructure.Postgres.Testing.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="../../../Authority/__Libraries/StellaOps.Authority.Persistence/StellaOps.Authority.Persistence.csproj" />
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
using var capture = new OtelCapture();
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/health");
|
||||
var response = await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
@@ -58,7 +58,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
|
||||
// This would normally require a valid scan to exist
|
||||
// For now, verify the endpoint responds appropriately
|
||||
var response = await client.GetAsync("/api/v1/scans");
|
||||
var response = await client.GetAsync("/api/v1/scans", TestContext.Current.CancellationToken);
|
||||
|
||||
// The endpoint should return a list (empty if no scans)
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
|
||||
@@ -73,7 +73,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
using var capture = new OtelCapture("StellaOps.Scanner");
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/sbom");
|
||||
var response = await client.GetAsync("/api/v1/sbom", TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -87,7 +87,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
using var capture = new OtelCapture("StellaOps.Scanner");
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/findings");
|
||||
var response = await client.GetAsync("/api/v1/findings", TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
using var capture = new OtelCapture("StellaOps.Scanner");
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/api/v1/reports");
|
||||
var response = await client.GetAsync("/api/v1/reports", TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.OK, HttpStatusCode.NoContent, HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -116,7 +116,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
// Request a non-existent scan
|
||||
var response = await client.GetAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000");
|
||||
var response = await client.GetAsync("/api/v1/scans/00000000-0000-0000-0000-000000000000", TestContext.Current.CancellationToken);
|
||||
|
||||
// Should get 404 or similar error
|
||||
response.StatusCode.Should().BeOneOf(HttpStatusCode.NotFound, HttpStatusCode.Unauthorized);
|
||||
@@ -134,7 +134,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
using var capture = new OtelCapture();
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
await client.GetAsync("/api/v1/health");
|
||||
await client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken);
|
||||
|
||||
// HTTP traces should follow semantic conventions
|
||||
// This is a smoke test to ensure OTel is properly configured
|
||||
@@ -151,7 +151,7 @@ public sealed class ScannerOtelAssertionTests : IClassFixture<ScannerApplication
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
// Fire multiple concurrent requests
|
||||
var tasks = Enumerable.Range(0, 5).Select(_ => client.GetAsync("/api/v1/health"));
|
||||
var tasks = Enumerable.Range(0, 5).Select(_ => client.GetAsync("/api/v1/health", TestContext.Current.CancellationToken));
|
||||
var responses = await Task.WhenAll(tasks);
|
||||
|
||||
foreach (var response in responses)
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// VexGateEndpointsTests.cs
|
||||
// Sprint: SPRINT_20260106_003_002_SCANNER_vex_gate_service
|
||||
// Task: T025 - API integration tests
|
||||
// Description: Integration tests for VEX gate API endpoints.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Gate;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
public sealed class VexGateEndpointsTests
|
||||
{
|
||||
private const string BasePath = "/api/v1/scans";
|
||||
|
||||
[Fact]
|
||||
public async Task GetGatePolicy_ReturnsPolicy()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/gate-policy");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var policy = await response.Content.ReadFromJsonAsync<VexGatePolicyDto>();
|
||||
Assert.NotNull(policy);
|
||||
Assert.NotNull(policy!.Version);
|
||||
Assert.NotNull(policy.Rules);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGatePolicy_WithTenantId_ReturnsPolicy()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/gate-policy?tenantId=tenant-a");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var policy = await response.Content.ReadFromJsonAsync<VexGatePolicyDto>();
|
||||
Assert.NotNull(policy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateResults_WhenScanNotFound_Returns404()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-results");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateResults_WhenScanExists_ReturnsResults()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryVexGateQueryService();
|
||||
mockService.AddScanResult(scanId, CreateTestGateResults(scanId));
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-results");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var results = await response.Content.ReadFromJsonAsync<VexGateResultsResponse>();
|
||||
Assert.NotNull(results);
|
||||
Assert.Equal(scanId, results!.ScanId);
|
||||
Assert.NotNull(results.GateSummary);
|
||||
Assert.NotNull(results.GatedFindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateResults_WithDecisionFilter_ReturnsFilteredResults()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryVexGateQueryService();
|
||||
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 3, warnCount: 5, passCount: 10));
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-results?decision=Block");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var results = await response.Content.ReadFromJsonAsync<VexGateResultsResponse>();
|
||||
Assert.NotNull(results);
|
||||
Assert.All(results!.GatedFindings, f => Assert.Equal("Block", f.Decision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateSummary_WhenScanNotFound_Returns404()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-summary");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGateSummary_WhenScanExists_ReturnsSummary()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryVexGateQueryService();
|
||||
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 2, warnCount: 8, passCount: 40));
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-summary");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var summary = await response.Content.ReadFromJsonAsync<VexGateSummaryDto>();
|
||||
Assert.NotNull(summary);
|
||||
Assert.Equal(50, summary!.TotalFindings);
|
||||
Assert.Equal(2, summary.Blocked);
|
||||
Assert.Equal(8, summary.Warned);
|
||||
Assert.Equal(40, summary.Passed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlockedFindings_WhenScanNotFound_Returns404()
|
||||
{
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService, InMemoryVexGateQueryService>();
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/scan-not-exists/gate-blocked");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBlockedFindings_WhenScanExists_ReturnsOnlyBlocked()
|
||||
{
|
||||
var scanId = "scan-" + Guid.NewGuid().ToString("N");
|
||||
var mockService = new InMemoryVexGateQueryService();
|
||||
mockService.AddScanResult(scanId, CreateTestGateResults(scanId, blockedCount: 5, warnCount: 10, passCount: 20));
|
||||
|
||||
using var factory = new ScannerApplicationFactory()
|
||||
.WithOverrides(configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IVexGateQueryService>();
|
||||
services.AddSingleton<IVexGateQueryService>(mockService);
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"{BasePath}/{scanId}/gate-blocked");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var findings = await response.Content.ReadFromJsonAsync<List<GatedFindingDto>>();
|
||||
Assert.NotNull(findings);
|
||||
Assert.Equal(5, findings!.Count);
|
||||
Assert.All(findings, f => Assert.Equal("Block", f.Decision));
|
||||
}
|
||||
|
||||
private static VexGateResultsResponse CreateTestGateResults(
|
||||
string scanId,
|
||||
int blockedCount = 1,
|
||||
int warnCount = 2,
|
||||
int passCount = 7)
|
||||
{
|
||||
var findings = new List<GatedFindingDto>();
|
||||
var totalFindings = blockedCount + warnCount + passCount;
|
||||
|
||||
for (int i = 0; i < blockedCount; i++)
|
||||
{
|
||||
findings.Add(CreateFinding($"CVE-2025-{1000 + i}", "Block", $"pkg:npm/vulnerable-lib@1.{i}.0"));
|
||||
}
|
||||
|
||||
for (int i = 0; i < warnCount; i++)
|
||||
{
|
||||
findings.Add(CreateFinding($"CVE-2025-{2000 + i}", "Warn", $"pkg:npm/risky-lib@2.{i}.0"));
|
||||
}
|
||||
|
||||
for (int i = 0; i < passCount; i++)
|
||||
{
|
||||
findings.Add(CreateFinding($"CVE-2025-{3000 + i}", "Pass", $"pkg:npm/safe-lib@3.{i}.0"));
|
||||
}
|
||||
|
||||
return new VexGateResultsResponse
|
||||
{
|
||||
ScanId = scanId,
|
||||
GateSummary = new VexGateSummaryDto
|
||||
{
|
||||
TotalFindings = totalFindings,
|
||||
Passed = passCount,
|
||||
Warned = warnCount,
|
||||
Blocked = blockedCount,
|
||||
EvaluatedAt = DateTimeOffset.UtcNow,
|
||||
},
|
||||
GatedFindings = findings,
|
||||
};
|
||||
}
|
||||
|
||||
private static GatedFindingDto CreateFinding(string cve, string decision, string purl)
|
||||
{
|
||||
return new GatedFindingDto
|
||||
{
|
||||
FindingId = $"finding-{Guid.NewGuid():N}",
|
||||
Cve = cve,
|
||||
Purl = purl,
|
||||
Decision = decision,
|
||||
Rationale = $"Test rationale for {decision}",
|
||||
PolicyRuleMatched = decision switch
|
||||
{
|
||||
"Block" => "block-exploitable-reachable",
|
||||
"Warn" => "warn-high-not-reachable",
|
||||
"Pass" => "pass-vendor-not-affected",
|
||||
_ => "default",
|
||||
},
|
||||
Evidence = new GateEvidenceDto
|
||||
{
|
||||
VendorStatus = decision == "Pass" ? "not_affected" : null,
|
||||
IsReachable = decision == "Block",
|
||||
HasCompensatingControl = false,
|
||||
ConfidenceScore = 0.95,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IVexGateQueryService for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryVexGateQueryService : IVexGateQueryService
|
||||
{
|
||||
private readonly Dictionary<string, VexGateResultsResponse> _scanResults = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void AddScanResult(string scanId, VexGateResultsResponse results)
|
||||
{
|
||||
_scanResults[scanId] = results;
|
||||
}
|
||||
|
||||
public Task<VexGateResultsResponse?> GetGateResultsAsync(
|
||||
string scanId,
|
||||
VexGateResultsQuery? query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_scanResults.TryGetValue(scanId, out var results))
|
||||
{
|
||||
return Task.FromResult<VexGateResultsResponse?>(null);
|
||||
}
|
||||
|
||||
// Apply query filters if present
|
||||
if (query is not null)
|
||||
{
|
||||
var filteredFindings = results.GatedFindings.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Decision))
|
||||
{
|
||||
filteredFindings = filteredFindings.Where(f =>
|
||||
string.Equals(f.Decision, query.Decision, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.MinConfidence.HasValue)
|
||||
{
|
||||
filteredFindings = filteredFindings.Where(f =>
|
||||
f.Evidence?.ConfidenceScore >= query.MinConfidence.Value);
|
||||
}
|
||||
|
||||
if (query.Offset.HasValue)
|
||||
{
|
||||
filteredFindings = filteredFindings.Skip(query.Offset.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
filteredFindings = filteredFindings.Take(query.Limit.Value);
|
||||
}
|
||||
|
||||
return Task.FromResult<VexGateResultsResponse?>(new VexGateResultsResponse
|
||||
{
|
||||
ScanId = results.ScanId,
|
||||
GateSummary = results.GateSummary,
|
||||
GatedFindings = filteredFindings.ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<VexGateResultsResponse?>(results);
|
||||
}
|
||||
|
||||
public Task<VexGatePolicyDto> GetPolicyAsync(
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var defaultPolicy = VexGatePolicy.Default;
|
||||
var policyDto = new VexGatePolicyDto
|
||||
{
|
||||
Version = "1.0.0",
|
||||
DefaultDecision = defaultPolicy.DefaultDecision.ToString(),
|
||||
Rules = defaultPolicy.Rules.Select(r => new VexGatePolicyRuleDto
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
Priority = r.Priority,
|
||||
Decision = r.Decision.ToString(),
|
||||
Condition = new VexGatePolicyConditionDto
|
||||
{
|
||||
VendorStatus = r.Condition.VendorStatus?.ToString(),
|
||||
IsExploitable = r.Condition.IsExploitable,
|
||||
IsReachable = r.Condition.IsReachable,
|
||||
HasCompensatingControl = r.Condition.HasCompensatingControl,
|
||||
SeverityLevels = r.Condition.SeverityLevels?.ToList(),
|
||||
},
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
return Task.FromResult(policyDto);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user