Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// FindingEvidenceContractsTests.cs
|
||||
// Sprint: SPRINT_3800_0001_0001_evidence_api_models
|
||||
// Sprint: SPRINT_4300_0001_0002_findings_evidence_api
|
||||
// Description: Unit tests for JSON serialization of evidence API contracts.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -27,23 +27,26 @@ public class FindingEvidenceContractsTests
|
||||
{
|
||||
FindingId = "finding-123",
|
||||
Cve = "CVE-2021-44228",
|
||||
Component = new ComponentRef
|
||||
Component = new ComponentInfo
|
||||
{
|
||||
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
|
||||
Name = "log4j-core",
|
||||
Version = "2.14.1",
|
||||
Type = "maven"
|
||||
Purl = "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
|
||||
Ecosystem = "maven"
|
||||
},
|
||||
ReachablePath = new[] { "com.example.App.main", "org.apache.log4j.Logger.log" },
|
||||
LastSeen = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero)
|
||||
LastSeen = new DateTimeOffset(2025, 12, 18, 12, 0, 0, TimeSpan.Zero),
|
||||
AttestationRefs = new[] { "dsse:sha256:abc123" },
|
||||
Freshness = new FreshnessInfo { IsStale = false }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(response, SerializerOptions);
|
||||
|
||||
Assert.Contains("\"finding_id\":\"finding-123\"", json);
|
||||
Assert.Contains("\"cve\":\"CVE-2021-44228\"", json);
|
||||
Assert.Contains("\"component\":", json);
|
||||
Assert.Contains("\"reachable_path\":", json);
|
||||
Assert.Contains("\"last_seen\":", json);
|
||||
Assert.Contains("\"freshness\":", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -53,39 +56,35 @@ public class FindingEvidenceContractsTests
|
||||
{
|
||||
FindingId = "finding-456",
|
||||
Cve = "CVE-2023-12345",
|
||||
Component = new ComponentRef
|
||||
Component = new ComponentInfo
|
||||
{
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
Name = "lodash",
|
||||
Version = "4.17.20",
|
||||
Type = "npm"
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
Ecosystem = "npm"
|
||||
},
|
||||
Entrypoint = new EntrypointProof
|
||||
Entrypoint = new EntrypointInfo
|
||||
{
|
||||
Type = "http_handler",
|
||||
Type = "http",
|
||||
Route = "/api/v1/users",
|
||||
Method = "POST",
|
||||
Auth = "required",
|
||||
Fqn = "com.example.UserController.createUser"
|
||||
Auth = "jwt:write"
|
||||
},
|
||||
ScoreExplain = new ScoreExplanationDto
|
||||
Score = new ScoreInfo
|
||||
{
|
||||
Kind = "stellaops_risk_v1",
|
||||
RiskScore = 7.5,
|
||||
RiskScore = 75,
|
||||
Contributions = new[]
|
||||
{
|
||||
new ScoreContributionDto
|
||||
new ScoreContribution
|
||||
{
|
||||
Factor = "cvss_base",
|
||||
Weight = 0.4,
|
||||
RawValue = 9.8,
|
||||
Contribution = 3.92,
|
||||
Explanation = "CVSS v4 base score"
|
||||
Factor = "reachability",
|
||||
Value = 25,
|
||||
Reason = "Reachable from entrypoint"
|
||||
}
|
||||
},
|
||||
LastSeen = DateTimeOffset.UtcNow
|
||||
}
|
||||
},
|
||||
LastSeen = DateTimeOffset.UtcNow
|
||||
LastSeen = DateTimeOffset.UtcNow,
|
||||
Freshness = new FreshnessInfo { IsStale = false }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, SerializerOptions);
|
||||
@@ -94,178 +93,129 @@ public class FindingEvidenceContractsTests
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(original.FindingId, deserialized.FindingId);
|
||||
Assert.Equal(original.Cve, deserialized.Cve);
|
||||
Assert.Equal(original.Component?.Purl, deserialized.Component?.Purl);
|
||||
Assert.Equal(original.Component.Purl, deserialized.Component.Purl);
|
||||
Assert.Equal(original.Entrypoint?.Type, deserialized.Entrypoint?.Type);
|
||||
Assert.Equal(original.ScoreExplain?.RiskScore, deserialized.ScoreExplain?.RiskScore);
|
||||
Assert.Equal(original.Score?.RiskScore, deserialized.Score?.RiskScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentRef_SerializesAllFields()
|
||||
public void ComponentInfo_SerializesAllFields()
|
||||
{
|
||||
var component = new ComponentRef
|
||||
var component = new ComponentInfo
|
||||
{
|
||||
Purl = "pkg:nuget/Newtonsoft.Json@13.0.1",
|
||||
Name = "Newtonsoft.Json",
|
||||
Version = "13.0.1",
|
||||
Type = "nuget"
|
||||
Purl = "pkg:nuget/Newtonsoft.Json@13.0.1",
|
||||
Ecosystem = "nuget"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(component, SerializerOptions);
|
||||
|
||||
Assert.Contains("\"purl\":\"pkg:nuget/Newtonsoft.Json@13.0.1\"", json);
|
||||
Assert.Contains("\"name\":\"Newtonsoft.Json\"", json);
|
||||
Assert.Contains("\"version\":\"13.0.1\"", json);
|
||||
Assert.Contains("\"type\":\"nuget\"", json);
|
||||
Assert.Contains("\"purl\":\"pkg:nuget/Newtonsoft.Json@13.0.1\"", json);
|
||||
Assert.Contains("\"ecosystem\":\"nuget\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntrypointProof_SerializesWithLocation()
|
||||
public void EntrypointInfo_SerializesAllFields()
|
||||
{
|
||||
var entrypoint = new EntrypointProof
|
||||
var entrypoint = new EntrypointInfo
|
||||
{
|
||||
Type = "grpc_method",
|
||||
Type = "grpc",
|
||||
Route = "grpc.UserService.GetUser",
|
||||
Auth = "required",
|
||||
Phase = "runtime",
|
||||
Fqn = "com.example.UserServiceImpl.getUser",
|
||||
Location = new SourceLocation
|
||||
{
|
||||
File = "src/main/java/com/example/UserServiceImpl.java",
|
||||
Line = 42,
|
||||
Column = 5
|
||||
}
|
||||
Method = "CALL",
|
||||
Auth = "mtls"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(entrypoint, SerializerOptions);
|
||||
|
||||
Assert.Contains("\"type\":\"grpc_method\"", json);
|
||||
Assert.Contains("\"type\":\"grpc\"", json);
|
||||
Assert.Contains("\"route\":\"grpc.UserService.GetUser\"", json);
|
||||
Assert.Contains("\"location\":", json);
|
||||
Assert.Contains("\"file\":\"src/main/java/com/example/UserServiceImpl.java\"", json);
|
||||
Assert.Contains("\"line\":42", json);
|
||||
Assert.Contains("\"method\":\"CALL\"", json);
|
||||
Assert.Contains("\"auth\":\"mtls\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoundaryProofDto_SerializesWithControls()
|
||||
public void BoundaryInfo_SerializesWithControls()
|
||||
{
|
||||
var boundary = new BoundaryProofDto
|
||||
var boundary = new BoundaryInfo
|
||||
{
|
||||
Kind = "network",
|
||||
Surface = new SurfaceDescriptor
|
||||
{
|
||||
Type = "api",
|
||||
Protocol = "https",
|
||||
Port = 443
|
||||
},
|
||||
Exposure = new ExposureDescriptor
|
||||
{
|
||||
Level = "public",
|
||||
InternetFacing = true,
|
||||
Zone = "dmz"
|
||||
},
|
||||
Auth = new AuthDescriptor
|
||||
{
|
||||
Required = true,
|
||||
Type = "jwt",
|
||||
Roles = new[] { "admin", "user" }
|
||||
},
|
||||
Controls = new[]
|
||||
{
|
||||
new ControlDescriptor
|
||||
{
|
||||
Type = "waf",
|
||||
Active = true,
|
||||
Config = "OWASP-ModSecurity"
|
||||
}
|
||||
},
|
||||
LastSeen = DateTimeOffset.UtcNow,
|
||||
Confidence = 0.95
|
||||
Surface = "api",
|
||||
Exposure = "internet",
|
||||
Controls = new[] { "waf", "rate_limit" }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(boundary, SerializerOptions);
|
||||
|
||||
Assert.Contains("\"kind\":\"network\"", json);
|
||||
Assert.Contains("\"internet_facing\":true", json);
|
||||
Assert.Contains("\"controls\":[", json);
|
||||
Assert.Contains("\"confidence\":0.95", json);
|
||||
Assert.Contains("\"surface\":\"api\"", json);
|
||||
Assert.Contains("\"exposure\":\"internet\"", json);
|
||||
Assert.Contains("\"controls\":[\"waf\",\"rate_limit\"]", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VexEvidenceDto_SerializesCorrectly()
|
||||
public void VexStatusInfo_SerializesCorrectly()
|
||||
{
|
||||
var vex = new VexEvidenceDto
|
||||
var vex = new VexStatusInfo
|
||||
{
|
||||
Status = "not_affected",
|
||||
Justification = "vulnerable_code_not_in_execute_path",
|
||||
Impact = "The vulnerable code path is never executed in our usage",
|
||||
AttestationRef = "dsse:sha256:abc123",
|
||||
IssuedAt = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
ExpiresAt = new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Source = "vendor"
|
||||
Timestamp = new DateTimeOffset(2025, 12, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
Issuer = "vendor"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(vex, SerializerOptions);
|
||||
|
||||
Assert.Contains("\"status\":\"not_affected\"", json);
|
||||
Assert.Contains("\"justification\":\"vulnerable_code_not_in_execute_path\"", json);
|
||||
Assert.Contains("\"attestation_ref\":\"dsse:sha256:abc123\"", json);
|
||||
Assert.Contains("\"source\":\"vendor\"", json);
|
||||
Assert.Contains("\"issuer\":\"vendor\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoreExplanationDto_SerializesContributions()
|
||||
public void ScoreInfo_SerializesContributions()
|
||||
{
|
||||
var explanation = new ScoreExplanationDto
|
||||
var score = new ScoreInfo
|
||||
{
|
||||
Kind = "stellaops_risk_v1",
|
||||
RiskScore = 6.2,
|
||||
RiskScore = 62,
|
||||
Contributions = new[]
|
||||
{
|
||||
new ScoreContributionDto
|
||||
new ScoreContribution
|
||||
{
|
||||
Factor = "cvss_base",
|
||||
Weight = 0.4,
|
||||
RawValue = 9.8,
|
||||
Contribution = 3.92,
|
||||
Explanation = "Critical CVSS base score"
|
||||
Value = 40,
|
||||
Reason = "Critical CVSS base score"
|
||||
},
|
||||
new ScoreContributionDto
|
||||
{
|
||||
Factor = "epss",
|
||||
Weight = 0.2,
|
||||
RawValue = 0.45,
|
||||
Contribution = 0.09,
|
||||
Explanation = "45% probability of exploitation"
|
||||
},
|
||||
new ScoreContributionDto
|
||||
new ScoreContribution
|
||||
{
|
||||
Factor = "reachability",
|
||||
Weight = 0.3,
|
||||
RawValue = 1.0,
|
||||
Contribution = 0.3,
|
||||
Explanation = "Reachable from HTTP entrypoint"
|
||||
},
|
||||
new ScoreContributionDto
|
||||
{
|
||||
Factor = "gate_multiplier",
|
||||
Weight = 1.0,
|
||||
RawValue = 0.5,
|
||||
Contribution = -2.11,
|
||||
Explanation = "Auth gate reduces exposure by 50%"
|
||||
Value = 22,
|
||||
Reason = "Reachable from HTTP entrypoint"
|
||||
}
|
||||
},
|
||||
LastSeen = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(explanation, SerializerOptions);
|
||||
var json = JsonSerializer.Serialize(score, SerializerOptions);
|
||||
|
||||
Assert.Contains("\"kind\":\"stellaops_risk_v1\"", json);
|
||||
Assert.Contains("\"risk_score\":6.2", json);
|
||||
Assert.Contains("\"contributions\":[", json);
|
||||
Assert.Contains("\"risk_score\":62", json);
|
||||
Assert.Contains("\"factor\":\"cvss_base\"", json);
|
||||
Assert.Contains("\"factor\":\"epss\"", json);
|
||||
Assert.Contains("\"factor\":\"reachability\"", json);
|
||||
Assert.Contains("\"factor\":\"gate_multiplier\"", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshnessInfo_SerializesCorrectly()
|
||||
{
|
||||
var freshness = new FreshnessInfo
|
||||
{
|
||||
IsStale = true,
|
||||
ExpiresAt = new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero),
|
||||
TtlRemainingHours = 0
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(freshness, SerializerOptions);
|
||||
|
||||
Assert.Contains("\"is_stale\":true", json);
|
||||
Assert.Contains("\"expires_at\":", json);
|
||||
Assert.Contains("\"ttl_remaining_hours\":0", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -275,19 +225,22 @@ public class FindingEvidenceContractsTests
|
||||
{
|
||||
FindingId = "finding-minimal",
|
||||
Cve = "CVE-2025-0001",
|
||||
LastSeen = DateTimeOffset.UtcNow
|
||||
// All optional fields are null
|
||||
Component = new ComponentInfo
|
||||
{
|
||||
Name = "unknown",
|
||||
Version = "unknown"
|
||||
},
|
||||
LastSeen = DateTimeOffset.UtcNow,
|
||||
Freshness = new FreshnessInfo { IsStale = false }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(response, SerializerOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<FindingEvidenceResponse>(json, SerializerOptions);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Null(deserialized.Component);
|
||||
Assert.Null(deserialized.ReachablePath);
|
||||
Assert.Null(deserialized.Entrypoint);
|
||||
Assert.Null(deserialized.Boundary);
|
||||
Assert.Null(deserialized.Vex);
|
||||
Assert.Null(deserialized.ScoreExplain);
|
||||
Assert.Null(deserialized.Score);
|
||||
Assert.Null(deserialized.Boundary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scanner.Triage.Entities;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class FindingsEvidenceControllerTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidence_ReturnsNotFound_WhenFindingMissing()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/findings/{Guid.NewGuid()}/evidence");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidence_ReturnsForbidden_WhenRawScopeMissing()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/findings/{Guid.NewGuid()}/evidence?includeRaw=true");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidence_ReturnsEvidence_WhenFindingExists()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var findingId = await SeedFindingAsync(factory);
|
||||
|
||||
var response = await client.GetAsync($"/api/v1/findings/{findingId}/evidence");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<FindingEvidenceResponse>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(findingId.ToString(), result!.FindingId);
|
||||
Assert.Equal("CVE-2024-12345", result.Cve);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchEvidence_ReturnsBadRequest_WhenTooMany()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new BatchEvidenceRequest
|
||||
{
|
||||
FindingIds = Enumerable.Range(0, 101).Select(_ => Guid.NewGuid().ToString()).ToList()
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/findings/evidence/batch", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BatchEvidence_ReturnsResults_ForExistingFindings()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var findingId = await SeedFindingAsync(factory);
|
||||
|
||||
var request = new BatchEvidenceRequest
|
||||
{
|
||||
FindingIds = new[] { findingId.ToString(), Guid.NewGuid().ToString() }
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/findings/evidence/batch", request);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<BatchEvidenceResponse>(SerializerOptions);
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result!.Findings);
|
||||
Assert.Equal(findingId.ToString(), result.Findings[0].FindingId);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedFindingAsync(ScannerApplicationFactory factory)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<TriageDbContext>();
|
||||
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var findingId = Guid.NewGuid();
|
||||
var finding = new TriageFinding
|
||||
{
|
||||
Id = findingId,
|
||||
AssetId = Guid.NewGuid(),
|
||||
AssetLabel = "prod/api-gateway:1.2.3",
|
||||
Purl = "pkg:npm/lodash@4.17.20",
|
||||
CveId = "CVE-2024-12345",
|
||||
LastSeenAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.Findings.Add(finding);
|
||||
db.RiskResults.Add(new TriageRiskResult
|
||||
{
|
||||
FindingId = findingId,
|
||||
PolicyId = "policy-1",
|
||||
PolicyVersion = "1.0.0",
|
||||
InputsHash = "sha256:inputs",
|
||||
Score = 72,
|
||||
Verdict = TriageVerdict.Block,
|
||||
Lane = TriageLane.High,
|
||||
Why = "High risk score",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.EvidenceArtifacts.Add(new TriageEvidenceArtifact
|
||||
{
|
||||
FindingId = findingId,
|
||||
Type = TriageEvidenceType.Attestation,
|
||||
Title = "SBOM attestation",
|
||||
ContentHash = "sha256:attestation",
|
||||
Uri = "s3://evidence/attestation.json"
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return findingId;
|
||||
}
|
||||
}
|
||||
@@ -310,7 +310,7 @@ public sealed class NotifierIngestionTests
|
||||
},
|
||||
GeneratedAt = DateTimeOffset.Parse("2025-12-07T10:00:00Z"),
|
||||
Format = "cyclonedx",
|
||||
SpecVersion = "1.6",
|
||||
SpecVersion = "1.7",
|
||||
ComponentCount = 127,
|
||||
SbomRef = "s3://sboms/sbom-001.json",
|
||||
Digest = "sha256:sbom-digest-789"
|
||||
@@ -333,7 +333,7 @@ public sealed class NotifierIngestionTests
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("sbom-001", payload["sbomId"]?.GetValue<string>());
|
||||
Assert.Equal("cyclonedx", payload["format"]?.GetValue<string>());
|
||||
Assert.Equal("1.6", payload["specVersion"]?.GetValue<string>());
|
||||
Assert.Equal("1.7", payload["specVersion"]?.GetValue<string>());
|
||||
Assert.Equal(127, payload["componentCount"]?.GetValue<int>());
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class SbomEndpointsTests
|
||||
var sbomJson = """
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"specVersion": "1.7",
|
||||
"version": 1,
|
||||
"components": []
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public sealed class SbomEndpointsTests
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/sbom")
|
||||
{
|
||||
Content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json")
|
||||
Content = new StringContent(sbomJson, Encoding.UTF8, "application/vnd.cyclonedx+json; version=1.7")
|
||||
};
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
public sealed class SbomUploadEndpointsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upload_accepts_cyclonedx_fixture_and_returns_record()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new SbomUploadRequestDto
|
||||
{
|
||||
ArtifactRef = "example.com/app:1.0",
|
||||
SbomBase64 = LoadFixtureBase64("sample.cdx.json"),
|
||||
Source = new SbomUploadSourceDto
|
||||
{
|
||||
Tool = "syft",
|
||||
Version = "1.0.0",
|
||||
CiContext = new SbomUploadCiContextDto
|
||||
{
|
||||
BuildId = "build-123",
|
||||
Repository = "github.com/example/app"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/sbom/upload", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomUploadResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("example.com/app:1.0", payload!.ArtifactRef);
|
||||
Assert.Equal("cyclonedx", payload.Format);
|
||||
Assert.Equal("1.6", payload.FormatVersion);
|
||||
Assert.True(payload.ValidationResult.Valid);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.AnalysisJobId));
|
||||
|
||||
var recordResponse = await client.GetAsync($"/api/v1/sbom/uploads/{payload.SbomId}");
|
||||
Assert.Equal(HttpStatusCode.OK, recordResponse.StatusCode);
|
||||
|
||||
var record = await recordResponse.Content.ReadFromJsonAsync<SbomUploadRecordDto>();
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(payload.SbomId, record!.SbomId);
|
||||
Assert.Equal("example.com/app:1.0", record.ArtifactRef);
|
||||
Assert.Equal("syft", record.Source?.Tool);
|
||||
Assert.Equal("build-123", record.Source?.CiContext?.BuildId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_accepts_spdx_fixture_and_reports_quality_score()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var request = new SbomUploadRequestDto
|
||||
{
|
||||
ArtifactRef = "example.com/service:2.0",
|
||||
SbomBase64 = LoadFixtureBase64("sample.spdx.json")
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/sbom/upload", request);
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<SbomUploadResponseDto>();
|
||||
Assert.NotNull(payload);
|
||||
Assert.Equal("spdx", payload!.Format);
|
||||
Assert.Equal("2.3", payload.FormatVersion);
|
||||
Assert.True(payload.ValidationResult.Valid);
|
||||
Assert.True(payload.ValidationResult.QualityScore > 0);
|
||||
Assert.True(payload.ValidationResult.ComponentCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_rejects_unknown_format()
|
||||
{
|
||||
using var secrets = new TestSurfaceSecretsScope();
|
||||
using var factory = CreateFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var invalid = new SbomUploadRequestDto
|
||||
{
|
||||
ArtifactRef = "example.com/invalid:1.0",
|
||||
SbomBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{\"name\":\"oops\"}"))
|
||||
};
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/v1/sbom/upload", invalid);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
private static ScannerApplicationFactory CreateFactory()
|
||||
{
|
||||
return new ScannerApplicationFactory().WithOverrides(configuration =>
|
||||
{
|
||||
configuration["scanner:authority:enabled"] = "false";
|
||||
}, configureServices: services =>
|
||||
{
|
||||
services.RemoveAll<IArtifactObjectStore>();
|
||||
services.AddSingleton<IArtifactObjectStore>(new InMemoryArtifactObjectStore());
|
||||
});
|
||||
}
|
||||
|
||||
private static string LoadFixtureBase64(string fileName)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(baseDirectory, "..", "..", "..", "..", ".."));
|
||||
var path = Path.Combine(
|
||||
repoRoot,
|
||||
"tests",
|
||||
"AirGap",
|
||||
"StellaOps.AirGap.Importer.Tests",
|
||||
"Reconciliation",
|
||||
"Fixtures",
|
||||
fileName);
|
||||
|
||||
Assert.True(File.Exists(path), $"Fixture not found at {path}.");
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private sealed class InMemoryArtifactObjectStore : IArtifactObjectStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
|
||||
public async Task PutAsync(ArtifactObjectDescriptor descriptor, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects[key] = buffer.ToArray();
|
||||
}
|
||||
|
||||
public Task<Stream?> GetAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
if (!_objects.TryGetValue(key, out var bytes))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(bytes, writable: false));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(ArtifactObjectDescriptor descriptor, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(descriptor);
|
||||
var key = $"{descriptor.Bucket}:{descriptor.Key}";
|
||||
_objects.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Scanner.Reachability.Slices;
|
||||
using StellaOps.Scanner.WebService.Endpoints;
|
||||
using StellaOps.Scanner.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for slice query and replay endpoints.
|
||||
/// </summary>
|
||||
public sealed class SliceEndpointsTests : IClassFixture<ScannerApplicationFixture>
|
||||
{
|
||||
private readonly ScannerApplicationFixture _fixture;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SliceEndpointsTests(ScannerApplicationFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
_client = fixture.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySlice_WithValidCve_ReturnsSlice()
|
||||
{
|
||||
// Arrange
|
||||
var request = new SliceQueryRequestDto
|
||||
{
|
||||
ScanId = "test-scan-001",
|
||||
CveId = "CVE-2024-1234",
|
||||
Symbols = new List<string> { "vulnerable_function" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/slices/query", request);
|
||||
|
||||
// Assert
|
||||
// Note: May return 404 if no test data, but validates endpoint registration
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.OK ||
|
||||
response.StatusCode == HttpStatusCode.NotFound ||
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"Unexpected status: {response.StatusCode}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySlice_WithoutScanId_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new SliceQueryRequestDto
|
||||
{
|
||||
CveId = "CVE-2024-1234"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/slices/query", request);
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.BadRequest ||
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"Expected BadRequest or Unauthorized, got {response.StatusCode}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QuerySlice_WithoutCveOrSymbols_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new SliceQueryRequestDto
|
||||
{
|
||||
ScanId = "test-scan-001"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/slices/query", request);
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.BadRequest ||
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"Expected BadRequest or Unauthorized, got {response.StatusCode}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSlice_WithValidDigest_ReturnsSlice()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:abc123";
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/slices/{digest}");
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.OK ||
|
||||
response.StatusCode == HttpStatusCode.NotFound ||
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"Unexpected status: {response.StatusCode}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSlice_WithDsseAccept_ReturnsDsseEnvelope()
|
||||
{
|
||||
// Arrange
|
||||
var digest = "sha256:abc123";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"/api/slices/{digest}");
|
||||
request.Headers.Add("Accept", "application/dsse+json");
|
||||
|
||||
// Act
|
||||
var response = await _client.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.OK ||
|
||||
response.StatusCode == HttpStatusCode.NotFound ||
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"Unexpected status: {response.StatusCode}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaySlice_WithValidDigest_ReturnsReplayResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new SliceReplayRequestDto
|
||||
{
|
||||
SliceDigest = "sha256:abc123"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/slices/replay", request);
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.OK ||
|
||||
response.StatusCode == HttpStatusCode.NotFound ||
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"Unexpected status: {response.StatusCode}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplaySlice_WithoutDigest_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new SliceReplayRequestDto();
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/slices/replay", request);
|
||||
|
||||
// Assert
|
||||
Assert.True(
|
||||
response.StatusCode == HttpStatusCode.BadRequest ||
|
||||
response.StatusCode == HttpStatusCode.Unauthorized,
|
||||
$"Expected BadRequest or Unauthorized, got {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SliceDiffComputer.
|
||||
/// </summary>
|
||||
public sealed class SliceDiffComputerTests
|
||||
{
|
||||
private readonly SliceDiffComputer _computer = new();
|
||||
|
||||
[Fact]
|
||||
public void Compare_IdenticalSlices_ReturnsMatch()
|
||||
{
|
||||
// Arrange
|
||||
var slice = CreateTestSlice();
|
||||
|
||||
// Act
|
||||
var result = _computer.Compare(slice, slice);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Match);
|
||||
Assert.Empty(result.MissingNodes);
|
||||
Assert.Empty(result.ExtraNodes);
|
||||
Assert.Empty(result.MissingEdges);
|
||||
Assert.Empty(result.ExtraEdges);
|
||||
Assert.Null(result.VerdictDiff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentNodes_ReturnsDiff()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTestSlice();
|
||||
var modified = original with
|
||||
{
|
||||
Subgraph = original.Subgraph with
|
||||
{
|
||||
Nodes = original.Subgraph.Nodes.Add(new SliceNode
|
||||
{
|
||||
Id = "extra-node",
|
||||
Symbol = "extra_func",
|
||||
Kind = SliceNodeKind.Intermediate
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _computer.Compare(original, modified);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Match);
|
||||
Assert.Empty(result.MissingNodes);
|
||||
Assert.Single(result.ExtraNodes);
|
||||
Assert.Contains("extra-node", result.ExtraNodes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentEdges_ReturnsDiff()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTestSlice();
|
||||
var modified = original with
|
||||
{
|
||||
Subgraph = original.Subgraph with
|
||||
{
|
||||
Edges = original.Subgraph.Edges.RemoveAt(0)
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _computer.Compare(original, modified);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Match);
|
||||
Assert.Single(result.MissingEdges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compare_DifferentVerdict_ReturnsDiff()
|
||||
{
|
||||
// Arrange
|
||||
var original = CreateTestSlice();
|
||||
var modified = original with
|
||||
{
|
||||
Verdict = original.Verdict with
|
||||
{
|
||||
Status = SliceVerdictStatus.Unreachable
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _computer.Compare(original, modified);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Match);
|
||||
Assert.NotNull(result.VerdictDiff);
|
||||
Assert.Contains("Status:", result.VerdictDiff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCacheKey_SameInputs_ReturnsSameKey()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new[] { "func_a", "func_b" };
|
||||
var entrypoints = new[] { "main" };
|
||||
|
||||
// Act
|
||||
var key1 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, entrypoints, null);
|
||||
var key2 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, entrypoints, null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeCacheKey_DifferentInputs_ReturnsDifferentKey()
|
||||
{
|
||||
// Arrange
|
||||
var symbols = new[] { "func_a", "func_b" };
|
||||
|
||||
// Act
|
||||
var key1 = SliceDiffComputer.ComputeCacheKey("scan1", "CVE-2024-1234", symbols, null, null);
|
||||
var key2 = SliceDiffComputer.ComputeCacheKey("scan2", "CVE-2024-1234", symbols, null, null);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(key1, key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_MatchingSlices_ReturnsMatchMessage()
|
||||
{
|
||||
// Arrange
|
||||
var result = new SliceDiffResult { Match = true };
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("match exactly", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToSummary_DifferingSlices_ReturnsDetailedDiff()
|
||||
{
|
||||
// Arrange
|
||||
var result = new SliceDiffResult
|
||||
{
|
||||
Match = false,
|
||||
MissingNodes = ImmutableArray.Create("node1", "node2"),
|
||||
ExtraEdges = ImmutableArray.Create("edge1"),
|
||||
VerdictDiff = "Status: reachable -> unreachable"
|
||||
};
|
||||
|
||||
// Act
|
||||
var summary = result.ToSummary();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Missing nodes", summary);
|
||||
Assert.Contains("Extra edges", summary);
|
||||
Assert.Contains("Verdict changed", summary);
|
||||
}
|
||||
|
||||
private static ReachabilitySlice CreateTestSlice()
|
||||
{
|
||||
return new ReachabilitySlice
|
||||
{
|
||||
Inputs = new SliceInputs
|
||||
{
|
||||
GraphDigest = "sha256:graph123"
|
||||
},
|
||||
Query = new SliceQuery
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
TargetSymbols = ImmutableArray.Create("vulnerable_func"),
|
||||
Entrypoints = ImmutableArray.Create("main")
|
||||
},
|
||||
Subgraph = new SliceSubgraph
|
||||
{
|
||||
Nodes = ImmutableArray.Create(
|
||||
new SliceNode { Id = "main", Symbol = "main", Kind = SliceNodeKind.Entrypoint },
|
||||
new SliceNode { Id = "vuln", Symbol = "vulnerable_func", Kind = SliceNodeKind.Target }
|
||||
),
|
||||
Edges = ImmutableArray.Create(
|
||||
new SliceEdge { From = "main", To = "vuln", Kind = SliceEdgeKind.Direct, Confidence = 1.0 }
|
||||
)
|
||||
},
|
||||
Verdict = new SliceVerdict
|
||||
{
|
||||
Status = SliceVerdictStatus.Reachable,
|
||||
Confidence = 0.95
|
||||
},
|
||||
Manifest = new Scanner.Core.ScanManifest()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for SliceCache.
|
||||
/// </summary>
|
||||
public sealed class SliceCacheTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGet_EmptyCache_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
|
||||
using var cache = new SliceCache(options);
|
||||
|
||||
// Act
|
||||
var found = cache.TryGet("nonexistent", out var entry);
|
||||
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_ThenGet_ReturnsEntry()
|
||||
{
|
||||
// Arrange
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
|
||||
using var cache = new SliceCache(options);
|
||||
var slice = CreateTestSlice();
|
||||
|
||||
// Act
|
||||
cache.Set("key1", slice, "sha256:abc123");
|
||||
var found = cache.TryGet("key1", out var entry);
|
||||
|
||||
// Assert
|
||||
Assert.True(found);
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal("sha256:abc123", entry.Digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_IncrementsCacheStats()
|
||||
{
|
||||
// Arrange
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
|
||||
using var cache = new SliceCache(options);
|
||||
var slice = CreateTestSlice();
|
||||
cache.Set("key1", slice, "sha256:abc123");
|
||||
|
||||
// Act
|
||||
cache.TryGet("key1", out _); // hit
|
||||
cache.TryGet("missing", out _); // miss
|
||||
|
||||
var stats = cache.GetStats();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, stats.HitCount);
|
||||
Assert.Equal(1, stats.MissCount);
|
||||
Assert.Equal(0.5, stats.HitRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllEntries()
|
||||
{
|
||||
// Arrange
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
|
||||
using var cache = new SliceCache(options);
|
||||
var slice = CreateTestSlice();
|
||||
cache.Set("key1", slice, "sha256:abc123");
|
||||
cache.Set("key2", slice, "sha256:def456");
|
||||
|
||||
// Act
|
||||
cache.Clear();
|
||||
var stats = cache.GetStats();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, stats.ItemCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_RemovesSpecificEntry()
|
||||
{
|
||||
// Arrange
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions());
|
||||
using var cache = new SliceCache(options);
|
||||
var slice = CreateTestSlice();
|
||||
cache.Set("key1", slice, "sha256:abc123");
|
||||
cache.Set("key2", slice, "sha256:def456");
|
||||
|
||||
// Act
|
||||
cache.Invalidate("key1");
|
||||
|
||||
// Assert
|
||||
Assert.False(cache.TryGet("key1", out _));
|
||||
Assert.True(cache.TryGet("key2", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_NeverCaches()
|
||||
{
|
||||
// Arrange
|
||||
var options = Microsoft.Extensions.Options.Options.Create(new SliceCacheOptions { Enabled = false });
|
||||
using var cache = new SliceCache(options);
|
||||
var slice = CreateTestSlice();
|
||||
|
||||
// Act
|
||||
cache.Set("key1", slice, "sha256:abc123");
|
||||
var found = cache.TryGet("key1", out _);
|
||||
|
||||
// Assert
|
||||
Assert.False(found);
|
||||
}
|
||||
|
||||
private static ReachabilitySlice CreateTestSlice()
|
||||
{
|
||||
return new ReachabilitySlice
|
||||
{
|
||||
Inputs = new SliceInputs { GraphDigest = "sha256:graph123" },
|
||||
Query = new SliceQuery(),
|
||||
Subgraph = new SliceSubgraph(),
|
||||
Verdict = new SliceVerdict { Status = SliceVerdictStatus.Unknown, Confidence = 0.0 },
|
||||
Manifest = new Scanner.Core.ScanManifest()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user