finish off sprint advisories and sprints
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_039_Scanner_runtime_linkage_verification
|
||||
// Task: RLV-009 - Platform API: Function Map Endpoints
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Scanner.Reachability.FunctionMap;
|
||||
using StellaOps.Scanner.Reachability.FunctionMap.Verification;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class FunctionMapEndpointsTests
|
||||
{
|
||||
private readonly IFunctionMapService _service;
|
||||
private readonly PlatformRequestContext _context = new("test-tenant", "test-actor", null);
|
||||
|
||||
public FunctionMapEndpointsTests()
|
||||
{
|
||||
var verifier = new ClaimVerifier(NullLogger<ClaimVerifier>.Instance);
|
||||
_service = new FunctionMapService(verifier);
|
||||
}
|
||||
|
||||
#region Create
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ReturnsDetailWithId()
|
||||
{
|
||||
var request = new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://registry/app@sha256:abc123",
|
||||
ServiceName = "myservice",
|
||||
HotFunctions = ["SSL_read", "SSL_write"]
|
||||
};
|
||||
|
||||
var result = await _service.CreateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.StartsWith("fmap-", result.Value.Id);
|
||||
Assert.Equal("myservice", result.Value.ServiceName);
|
||||
Assert.Equal("oci://registry/app@sha256:abc123", result.Value.SbomRef);
|
||||
Assert.StartsWith("sha256:", result.Value.PredicateDigest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_WithOptions_SetsThresholds()
|
||||
{
|
||||
var request = new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://registry/app@sha256:abc123",
|
||||
ServiceName = "myservice",
|
||||
Options = new FunctionMapOptionsDto
|
||||
{
|
||||
MinObservationRate = 0.90,
|
||||
WindowSeconds = 3600,
|
||||
FailOnUnexpected = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.CreateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.Coverage);
|
||||
Assert.Equal(0.90, result.Value.Coverage!.MinObservationRate);
|
||||
Assert.Equal(3600, result.Value.Coverage.WindowSeconds);
|
||||
Assert.True(result.Value.Coverage.FailOnUnexpected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region List
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_Empty_ReturnsEmptyList()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
var result = await svc.ListAsync(_context);
|
||||
|
||||
Assert.Empty(result.Value);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_AfterCreate_ReturnsCreatedMap()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
await svc.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "svc1"
|
||||
});
|
||||
|
||||
var result = await svc.ListAsync(_context);
|
||||
|
||||
Assert.Single(result.Value);
|
||||
Assert.Equal("svc1", result.Value[0].ServiceName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_MultiTenant_IsolatesByTenant()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
var tenantA = new PlatformRequestContext("tenant-a", "actor", null);
|
||||
var tenantB = new PlatformRequestContext("tenant-b", "actor", null);
|
||||
|
||||
await svc.CreateAsync(tenantA, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://a",
|
||||
ServiceName = "svc-a"
|
||||
});
|
||||
await svc.CreateAsync(tenantB, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://b",
|
||||
ServiceName = "svc-b"
|
||||
});
|
||||
|
||||
var resultA = await svc.ListAsync(tenantA);
|
||||
var resultB = await svc.ListAsync(tenantB);
|
||||
|
||||
Assert.Single(resultA.Value);
|
||||
Assert.Equal("svc-a", resultA.Value[0].ServiceName);
|
||||
Assert.Single(resultB.Value);
|
||||
Assert.Equal("svc-b", resultB.Value[0].ServiceName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_WithPagination_RespectsLimitAndOffset()
|
||||
{
|
||||
var svc = CreateFreshService();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
await svc.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = $"oci://test{i}",
|
||||
ServiceName = $"svc{i}"
|
||||
});
|
||||
}
|
||||
|
||||
var page1 = await svc.ListAsync(_context, limit: 2, offset: 0);
|
||||
var page2 = await svc.ListAsync(_context, limit: 2, offset: 2);
|
||||
|
||||
Assert.Equal(2, page1.Value.Count);
|
||||
Assert.Equal(2, page2.Value.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetById
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetById_Existing_ReturnsDetail()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "myservice"
|
||||
});
|
||||
|
||||
var result = await _service.GetByIdAsync(_context, created.Value.Id);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal(created.Value.Id, result.Value!.Id);
|
||||
Assert.Equal("myservice", result.Value.ServiceName);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetById_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetByIdAsync(_context, "fmap-nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_Existing_ReturnsTrue()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "todelete"
|
||||
});
|
||||
|
||||
var result = await _service.DeleteAsync(_context, created.Value.Id);
|
||||
|
||||
Assert.True(result.Value);
|
||||
|
||||
var getResult = await _service.GetByIdAsync(_context, created.Value.Id);
|
||||
Assert.Null(getResult.Value);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Delete_NonExistent_ReturnsFalse()
|
||||
{
|
||||
var result = await _service.DeleteAsync(_context, "fmap-nonexistent");
|
||||
|
||||
Assert.False(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Verify
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_EmptyObservations_ReturnsNotVerified()
|
||||
{
|
||||
// Empty function map with no expected paths: ClaimVerifier returns rate=0.0 which
|
||||
// is below the default threshold (0.95), so verification fails.
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "empty-map"
|
||||
});
|
||||
|
||||
var verifyRequest = new VerifyFunctionMapRequest
|
||||
{
|
||||
Observations = []
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(_context, created.Value.Id, verifyRequest);
|
||||
|
||||
Assert.False(result.Value.Verified);
|
||||
Assert.Equal(0, result.Value.PathCount);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_NonExistentMap_ReturnsNotVerified()
|
||||
{
|
||||
var verifyRequest = new VerifyFunctionMapRequest
|
||||
{
|
||||
Observations = []
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(_context, "fmap-nonexistent", verifyRequest);
|
||||
|
||||
Assert.False(result.Value.Verified);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_UpdatesLastVerifiedTimestamp()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "verify-ts"
|
||||
});
|
||||
|
||||
Assert.Null(created.Value.LastVerifiedAt);
|
||||
|
||||
await _service.VerifyAsync(_context, created.Value.Id, new VerifyFunctionMapRequest());
|
||||
|
||||
var updated = await _service.GetByIdAsync(_context, created.Value.Id);
|
||||
Assert.NotNull(updated.Value!.LastVerifiedAt);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Verify_WithOptions_PassesOverrides()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "verify-opts",
|
||||
Options = new FunctionMapOptionsDto { MinObservationRate = 0.99 }
|
||||
});
|
||||
|
||||
var verifyRequest = new VerifyFunctionMapRequest
|
||||
{
|
||||
Options = new VerifyOptionsDto
|
||||
{
|
||||
MinObservationRateOverride = 0.50
|
||||
},
|
||||
Observations = []
|
||||
};
|
||||
|
||||
var result = await _service.VerifyAsync(_context, created.Value.Id, verifyRequest);
|
||||
|
||||
// With 0 expected paths and 0 observations, rate=0.0 which is below even the
|
||||
// overridden 0.50 threshold. Verify the override target is applied correctly.
|
||||
Assert.Equal(0.50, result.Value.TargetRate);
|
||||
Assert.Equal(0.0, result.Value.ObservationRate);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Coverage
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetCoverage_EmptyMap_ReturnsZeroCoverage()
|
||||
{
|
||||
var created = await _service.CreateAsync(_context, new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://test",
|
||||
ServiceName = "cov-empty"
|
||||
});
|
||||
|
||||
var result = await _service.GetCoverageAsync(_context, created.Value.Id);
|
||||
|
||||
Assert.Equal(0, result.Value.TotalPaths);
|
||||
Assert.Equal(0, result.Value.ObservedPaths);
|
||||
Assert.Equal(0, result.Value.TotalExpectedCalls);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetCoverage_NonExistentMap_ReturnsZero()
|
||||
{
|
||||
var result = await _service.GetCoverageAsync(_context, "fmap-nonexistent");
|
||||
|
||||
Assert.Equal(0, result.Value.TotalPaths);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Determinism
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_PredicateDigest_IsDeterministic()
|
||||
{
|
||||
var svc1 = CreateFreshService();
|
||||
var svc2 = CreateFreshService();
|
||||
|
||||
var request = new CreateFunctionMapRequest
|
||||
{
|
||||
SbomRef = "oci://registry/app@sha256:deterministic",
|
||||
ServiceName = "determ-svc",
|
||||
Options = new FunctionMapOptionsDto
|
||||
{
|
||||
MinObservationRate = 0.95,
|
||||
WindowSeconds = 1800
|
||||
}
|
||||
};
|
||||
|
||||
var result1 = await svc1.CreateAsync(_context, request);
|
||||
var result2 = await svc2.CreateAsync(_context, request);
|
||||
|
||||
// Same inputs should produce same predicate digest
|
||||
Assert.Equal(result1.Value.PredicateDigest, result2.Value.PredicateDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private static FunctionMapService CreateFreshService()
|
||||
{
|
||||
var verifier = new ClaimVerifier(NullLogger<ClaimVerifier>.Instance);
|
||||
return new FunctionMapService(verifier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego
|
||||
// Task: TASK-07 - Platform API Endpoints
|
||||
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class PolicyInteropEndpointsTests
|
||||
{
|
||||
private readonly IPolicyInteropService _service = new PolicyInteropService();
|
||||
private readonly PlatformRequestContext _context = new("test-tenant", "test-actor", null);
|
||||
|
||||
private const string GoldenPolicyJson = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test-policy", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [
|
||||
{
|
||||
"id": "cvss-threshold",
|
||||
"type": "CvssThresholdGate",
|
||||
"enabled": true,
|
||||
"config": { "threshold": 7.0 }
|
||||
},
|
||||
{
|
||||
"id": "signature-required",
|
||||
"type": "SignatureRequiredGate",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private const string SampleRego = """
|
||||
package stella.release
|
||||
|
||||
import rego.v1
|
||||
|
||||
default allow := false
|
||||
|
||||
deny contains msg if {
|
||||
input.cvss.score >= 7.0
|
||||
msg := "CVSS too high"
|
||||
}
|
||||
|
||||
deny contains msg if {
|
||||
not input.dsse.verified
|
||||
msg := "DSSE missing"
|
||||
}
|
||||
|
||||
allow if { count(deny) == 0 }
|
||||
""";
|
||||
|
||||
#region Export
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_ToJson_ReturnsContent()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("json", result.Format);
|
||||
Assert.NotNull(result.Content);
|
||||
Assert.Contains("policy.stellaops.io/v2", result.Content);
|
||||
Assert.NotNull(result.Digest);
|
||||
Assert.StartsWith("sha256:", result.Digest);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_ToRego_ReturnsRegoSource()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Format = "rego"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("rego", result.Format);
|
||||
Assert.NotNull(result.Content);
|
||||
Assert.Contains("package stella.release", result.Content);
|
||||
Assert.Contains("deny contains msg if", result.Content);
|
||||
Assert.Contains("input.cvss.score >= 7.0", result.Content);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_EmptyContent_ReturnsFalse()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = "",
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Diagnostics);
|
||||
Assert.Contains(result.Diagnostics, d => d.Code == "EMPTY_INPUT");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_InvalidJson_ReturnsFalse()
|
||||
{
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = "not json",
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Diagnostics);
|
||||
Assert.Contains(result.Diagnostics, d => d.Code == "PARSE_ERROR");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Export_ToRego_WithEnvironment_UsesEnvThresholds()
|
||||
{
|
||||
var policyWithEnvs = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v2",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": {
|
||||
"settings": { "defaultAction": "block" },
|
||||
"gates": [{
|
||||
"id": "cvss",
|
||||
"type": "CvssThresholdGate",
|
||||
"enabled": true,
|
||||
"config": { "threshold": 7.0 },
|
||||
"environments": {
|
||||
"staging": { "threshold": 8.0 }
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var request = new PolicyExportApiRequest
|
||||
{
|
||||
PolicyContent = policyWithEnvs,
|
||||
Format = "rego",
|
||||
Environment = "staging"
|
||||
};
|
||||
|
||||
var result = await _service.ExportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Contains("input.cvss.score >= 8.0", result.Content);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Import
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Import_JsonContent_ReturnsSuccess()
|
||||
{
|
||||
var request = new PolicyImportApiRequest
|
||||
{
|
||||
Content = GoldenPolicyJson,
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ImportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("json", result.SourceFormat);
|
||||
Assert.Equal(2, result.GatesImported);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Import_RegoContent_MapsToNativeGates()
|
||||
{
|
||||
var request = new PolicyImportApiRequest
|
||||
{
|
||||
Content = SampleRego,
|
||||
Format = "rego"
|
||||
};
|
||||
|
||||
var result = await _service.ImportAsync(_context, request);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("rego", result.SourceFormat);
|
||||
Assert.True(result.NativeMapped > 0);
|
||||
Assert.NotNull(result.Mappings);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Import_EmptyContent_ReturnsFalse()
|
||||
{
|
||||
var request = new PolicyImportApiRequest { Content = "" };
|
||||
|
||||
var result = await _service.ImportAsync(_context, request);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Diagnostics);
|
||||
Assert.Contains(result.Diagnostics, d => d.Code == "EMPTY_INPUT");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ValidJson_ReturnsValid()
|
||||
{
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = GoldenPolicyJson,
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.Equal("json", result.DetectedFormat);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_ValidRego_ReturnsValid()
|
||||
{
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = SampleRego,
|
||||
Format = "rego"
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.True(result.Valid);
|
||||
Assert.Equal("rego", result.DetectedFormat);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_InvalidJson_ReturnsInvalid()
|
||||
{
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = "not valid json",
|
||||
Format = "json"
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.NotNull(result.Errors);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Validate_Strict_WrongVersion_ReturnsInvalid()
|
||||
{
|
||||
var v1Policy = """
|
||||
{
|
||||
"apiVersion": "policy.stellaops.io/v1",
|
||||
"kind": "PolicyPack",
|
||||
"metadata": { "name": "test", "version": "1.0.0" },
|
||||
"spec": { "settings": {}, "gates": [] }
|
||||
}
|
||||
""";
|
||||
|
||||
var request = new PolicyValidateApiRequest
|
||||
{
|
||||
Content = v1Policy,
|
||||
Format = "json",
|
||||
Strict = true
|
||||
};
|
||||
|
||||
var result = await _service.ValidateAsync(_context, request);
|
||||
|
||||
Assert.False(result.Valid);
|
||||
Assert.NotNull(result.Errors);
|
||||
Assert.Contains(result.Errors, e => e.Code == "VERSION_MISMATCH");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Evaluate
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_AllGatesPass_ReturnsAllow()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 5.0,
|
||||
DsseVerified = true,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("allow", result.Decision);
|
||||
Assert.NotNull(result.Gates);
|
||||
Assert.All(result.Gates, g => Assert.True(g.Passed));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_CvssExceeds_ReturnsBlock()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 9.0,
|
||||
DsseVerified = true,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("block", result.Decision);
|
||||
Assert.Contains(result.Gates!, g => !g.Passed && g.GateType == "CvssThresholdGate");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_SignatureMissing_ReturnsBlock()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = GoldenPolicyJson,
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 5.0,
|
||||
DsseVerified = false,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("block", result.Decision);
|
||||
Assert.Contains(result.Gates!, g => !g.Passed && g.GateType == "SignatureRequiredGate");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_EmptyPolicy_ReturnsBlock()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = "",
|
||||
Input = new PolicyEvaluationInputDto { CvssScore = 5.0 }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal("block", result.Decision);
|
||||
Assert.NotNull(result.Remediation);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Evaluate_RegoPolicy_ImportsThenEvaluates()
|
||||
{
|
||||
var request = new PolicyEvaluateApiRequest
|
||||
{
|
||||
PolicyContent = SampleRego,
|
||||
Format = "rego",
|
||||
Input = new PolicyEvaluationInputDto
|
||||
{
|
||||
CvssScore = 5.0,
|
||||
DsseVerified = true,
|
||||
RekorVerified = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
// After importing the Rego, the CVSS gate with threshold 7.0 should pass for score 5.0
|
||||
Assert.Equal("allow", result.Decision);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-005 - Platform API Endpoints (Score Evaluate)
|
||||
// Task: TSF-011 - Score Replay & Verification Endpoint
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
using StellaOps.Signals.UnifiedScore.Replay;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for score evaluation endpoints via <see cref="ScoreEvaluationService"/>.
|
||||
/// Covers TSF-005 (score evaluate endpoints) and TSF-011 (replay/verify endpoints).
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
public sealed class ScoreEndpointsTests
|
||||
{
|
||||
private readonly IUnifiedScoreService _unifiedScoreService;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly IReplayLogBuilder _replayLogBuilder;
|
||||
private readonly IReplayVerifier _replayVerifier;
|
||||
private readonly ScoreEvaluationService _service;
|
||||
private readonly PlatformRequestContext _context = new("test-tenant", "test-actor", null);
|
||||
|
||||
public ScoreEndpointsTests()
|
||||
{
|
||||
_unifiedScoreService = Substitute.For<IUnifiedScoreService>();
|
||||
_manifestLoader = Substitute.For<IWeightManifestLoader>();
|
||||
_replayLogBuilder = Substitute.For<IReplayLogBuilder>();
|
||||
_replayVerifier = Substitute.For<IReplayVerifier>();
|
||||
|
||||
// Default manifest setup
|
||||
var defaultManifest = WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "v-test");
|
||||
_manifestLoader
|
||||
.ListVersionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<string> { "v-test" });
|
||||
_manifestLoader
|
||||
.LoadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
_manifestLoader
|
||||
.LoadLatestAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
_manifestLoader
|
||||
.GetEffectiveAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
|
||||
// Default unified score result
|
||||
SetupDefaultScoreResult();
|
||||
|
||||
_service = new ScoreEvaluationService(
|
||||
_unifiedScoreService,
|
||||
_manifestLoader,
|
||||
_replayLogBuilder,
|
||||
_replayVerifier,
|
||||
NullLogger<ScoreEvaluationService>.Instance);
|
||||
}
|
||||
|
||||
#region TSF-005: EvaluateAsync
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithSignals_ReturnsScoreResponse()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.8,
|
||||
Runtime = 0.7,
|
||||
Backport = 0.5,
|
||||
Exploit = 0.3,
|
||||
Source = 0.6,
|
||||
Mitigation = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.StartsWith("score_", result.Value.ScoreId);
|
||||
Assert.InRange(result.Value.ScoreValue, 0, 100);
|
||||
Assert.NotEmpty(result.Value.Bucket);
|
||||
Assert.NotEmpty(result.Value.EwsDigest);
|
||||
Assert.False(result.Cached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithBreakdownOption_ReturnsBreakdown()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.9,
|
||||
Runtime = 0.8
|
||||
},
|
||||
Options = new ScoreEvaluateOptions { IncludeBreakdown = true }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.Breakdown);
|
||||
Assert.NotEmpty(result.Value.Breakdown);
|
||||
Assert.All(result.Value.Breakdown, b =>
|
||||
{
|
||||
Assert.NotEmpty(b.Dimension);
|
||||
Assert.NotEmpty(b.Symbol);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithoutBreakdownOption_ExcludesBreakdown()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 },
|
||||
Options = new ScoreEvaluateOptions { IncludeBreakdown = false }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Null(result.Value.Breakdown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsGuardrails()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.5,
|
||||
Runtime = 0.5
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.Guardrails);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsWeightManifestReference()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.WeightManifest);
|
||||
Assert.NotEmpty(result.Value.WeightManifest.Version);
|
||||
Assert.NotEmpty(result.Value.WeightManifest.ContentHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_ReturnsComputedAt()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.True(result.Value.ComputedAt <= DateTimeOffset.UtcNow);
|
||||
Assert.True(result.Value.ComputedAt > DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentTenants_ProduceDifferentScoreIds()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5, Runtime = 0.5 }
|
||||
};
|
||||
|
||||
var tenantA = new PlatformRequestContext("tenant-a", "actor", null);
|
||||
var tenantB = new PlatformRequestContext("tenant-b", "actor", null);
|
||||
|
||||
var resultA = await _service.EvaluateAsync(tenantA, request);
|
||||
var resultB = await _service.EvaluateAsync(tenantB, request);
|
||||
|
||||
Assert.NotEqual(resultA.Value.ScoreId, resultB.Value.ScoreId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithDeltaOption_IncludesDelta()
|
||||
{
|
||||
SetupScoreResultWithDelta();
|
||||
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 },
|
||||
Options = new ScoreEvaluateOptions { IncludeDelta = true }
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value.DeltaIfPresent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_WithNullSignals_UsesDefaults()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
CveId = "CVE-2024-0001"
|
||||
};
|
||||
|
||||
var result = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.InRange(result.Value.ScoreValue, 0, 100);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: GetByIdAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetByIdAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetByIdAsync(_context, "score_nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: ListWeightManifestsAsync
|
||||
|
||||
[Fact]
|
||||
public async Task ListWeightManifestsAsync_ReturnsSummaries()
|
||||
{
|
||||
var result = await _service.ListWeightManifestsAsync(_context);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Single(result.Value);
|
||||
Assert.Equal("v-test", result.Value[0].Version);
|
||||
Assert.Equal("production", result.Value[0].Profile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListWeightManifestsAsync_Empty_ReturnsEmptyList()
|
||||
{
|
||||
_manifestLoader
|
||||
.ListVersionsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<string>());
|
||||
|
||||
var result = await _service.ListWeightManifestsAsync(_context);
|
||||
|
||||
Assert.Empty(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: GetWeightManifestAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_Existing_ReturnsDetail()
|
||||
{
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-test");
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal("v-test", result.Value.Version);
|
||||
Assert.NotNull(result.Value.Weights);
|
||||
Assert.NotNull(result.Value.Weights.Legacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
_manifestLoader
|
||||
.LoadAsync("v-nonexistent", Arg.Any<CancellationToken>())
|
||||
.Returns((WeightManifest?)null);
|
||||
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_ReturnsLegacyWeights()
|
||||
{
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-test");
|
||||
|
||||
Assert.NotNull(result.Value?.Weights.Legacy);
|
||||
Assert.True(result.Value.Weights.Legacy.Rch > 0);
|
||||
Assert.True(result.Value.Weights.Legacy.Rts > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWeightManifestAsync_ReturnsAdvisoryWeights()
|
||||
{
|
||||
var result = await _service.GetWeightManifestAsync(_context, "v-test");
|
||||
|
||||
Assert.NotNull(result.Value?.Weights.Advisory);
|
||||
Assert.True(result.Value.Weights.Advisory.Cvss > 0);
|
||||
Assert.True(result.Value.Weights.Advisory.Epss > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-005: GetEffectiveWeightManifestAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveWeightManifestAsync_ReturnsManifest()
|
||||
{
|
||||
var result = await _service.GetEffectiveWeightManifestAsync(
|
||||
_context, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.Equal("v-test", result.Value.Version);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectiveWeightManifestAsync_NoManifest_ReturnsNull()
|
||||
{
|
||||
_manifestLoader
|
||||
.GetEffectiveAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns((WeightManifest?)null);
|
||||
|
||||
var result = await _service.GetEffectiveWeightManifestAsync(
|
||||
_context, DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-011: VerifyReplayAsync
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_WithInputs_ReturnsVerificationResult()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = new ScoreVerifyInputs
|
||||
{
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.8,
|
||||
Runtime = 0.7,
|
||||
Backport = 0.5,
|
||||
Exploit = 0.3,
|
||||
Source = 0.6,
|
||||
Mitigation = 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.True(result.Value.Verified);
|
||||
Assert.True(result.Value.ScoreMatches);
|
||||
Assert.True(result.Value.DigestMatches);
|
||||
Assert.InRange(result.Value.ReplayedScore, 0, 100);
|
||||
Assert.Equal(result.Value.ReplayedScore, result.Value.OriginalScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_WithNullInputs_UsesDefaultSignals()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = null
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.InRange(result.Value.ReplayedScore, 0, 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_ReturnsVerifiedAt()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = new ScoreVerifyInputs
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 }
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.True(result.Value.VerifiedAt <= DateTimeOffset.UtcNow);
|
||||
Assert.True(result.Value.VerifiedAt > DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyReplayAsync_WithWeightVersion_UsesSpecifiedVersion()
|
||||
{
|
||||
var request = new ScoreVerifyRequest
|
||||
{
|
||||
SignedReplayLogDsse = "eyJwYXlsb2FkIjoiZXlKMFpYTjBJam9pYUdWc2JHOGlmUT09In0=",
|
||||
OriginalInputs = new ScoreVerifyInputs
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.5 },
|
||||
WeightManifestVersion = "v-test"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await _service.VerifyReplayAsync(_context, request);
|
||||
|
||||
Assert.NotNull(result.Value);
|
||||
Assert.True(result.Value.Verified);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-011: GetReplayAsync
|
||||
|
||||
[Fact]
|
||||
public async Task GetReplayAsync_NonExistent_ReturnsNull()
|
||||
{
|
||||
var result = await _service.GetReplayAsync(_context, "score_nonexistent");
|
||||
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSF-011: Deterministic Score Computation
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_SameInputs_ProducesSameEwsDigest()
|
||||
{
|
||||
var request = new ScoreEvaluateRequest
|
||||
{
|
||||
CveId = "CVE-2024-1234",
|
||||
Signals = new SignalInputs
|
||||
{
|
||||
Reachability = 0.8,
|
||||
Runtime = 0.7,
|
||||
Backport = 0.5,
|
||||
Exploit = 0.3,
|
||||
Source = 0.6,
|
||||
Mitigation = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
var result1 = await _service.EvaluateAsync(_context, request);
|
||||
var result2 = await _service.EvaluateAsync(_context, request);
|
||||
|
||||
Assert.Equal(result1.Value.EwsDigest, result2.Value.EwsDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DifferentInputs_ProducesDifferentEwsDigest()
|
||||
{
|
||||
var request1 = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.8, Runtime = 0.7 }
|
||||
};
|
||||
var request2 = new ScoreEvaluateRequest
|
||||
{
|
||||
Signals = new SignalInputs { Reachability = 0.2, Runtime = 0.1 }
|
||||
};
|
||||
|
||||
// Setup different results for different inputs
|
||||
SetupScoreResultWithDigest("digest-high");
|
||||
var result1 = await _service.EvaluateAsync(_context, request1);
|
||||
|
||||
SetupScoreResultWithDigest("digest-low");
|
||||
var result2 = await _service.EvaluateAsync(_context, request2);
|
||||
|
||||
Assert.NotEqual(result1.Value.EwsDigest, result2.Value.EwsDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void SetupDefaultScoreResult()
|
||||
{
|
||||
var defaultResult = new UnifiedScoreResult
|
||||
{
|
||||
Score = 62,
|
||||
Bucket = ScoreBucket.Investigate,
|
||||
UnknownsFraction = 0.3,
|
||||
UnknownsBand = Signals.UnifiedScore.UnknownsBand.Adequate,
|
||||
Breakdown =
|
||||
[
|
||||
new DimensionContribution { Dimension = "Reachability", Symbol = "Rch", InputValue = 0.5, Weight = 0.30, Contribution = 15.0 },
|
||||
new DimensionContribution { Dimension = "Runtime", Symbol = "Rts", InputValue = 0.5, Weight = 0.25, Contribution = 12.5 },
|
||||
new DimensionContribution { Dimension = "Backport", Symbol = "Bkp", InputValue = 0.5, Weight = 0.15, Contribution = 7.5 },
|
||||
new DimensionContribution { Dimension = "Exploit", Symbol = "Xpl", InputValue = 0.5, Weight = 0.15, Contribution = 7.5 },
|
||||
new DimensionContribution { Dimension = "Source", Symbol = "Src", InputValue = 0.5, Weight = 0.10, Contribution = 5.0 },
|
||||
new DimensionContribution { Dimension = "Mitigation", Symbol = "Mit", InputValue = 0.0, Weight = 0.10, Contribution = 0.0 }
|
||||
],
|
||||
Guardrails = new AppliedGuardrails
|
||||
{
|
||||
SpeculativeCap = false,
|
||||
NotAffectedCap = false,
|
||||
RuntimeFloor = false,
|
||||
OriginalScore = 62,
|
||||
AdjustedScore = 62
|
||||
},
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = "v-test",
|
||||
ContentHash = "sha256:abc123"
|
||||
},
|
||||
EwsDigest = "sha256:deterministic-digest-test",
|
||||
DeterminizationFingerprint = "fp:test-fingerprint",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_unifiedScoreService
|
||||
.ComputeAsync(Arg.Any<UnifiedScoreRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultResult);
|
||||
}
|
||||
|
||||
private void SetupScoreResultWithDelta()
|
||||
{
|
||||
var resultWithDelta = new UnifiedScoreResult
|
||||
{
|
||||
Score = 45,
|
||||
Bucket = ScoreBucket.ScheduleNext,
|
||||
Breakdown =
|
||||
[
|
||||
new DimensionContribution { Dimension = "Reachability", Symbol = "Rch", InputValue = 0.5, Weight = 0.30, Contribution = 15.0 }
|
||||
],
|
||||
Guardrails = new AppliedGuardrails
|
||||
{
|
||||
SpeculativeCap = false,
|
||||
NotAffectedCap = false,
|
||||
RuntimeFloor = false,
|
||||
OriginalScore = 45,
|
||||
AdjustedScore = 45
|
||||
},
|
||||
DeltaIfPresent =
|
||||
[
|
||||
new SignalDelta
|
||||
{
|
||||
Signal = "Runtime",
|
||||
MinImpact = -10.0,
|
||||
MaxImpact = 15.0,
|
||||
Weight = 0.25,
|
||||
Description = "Runtime signal could shift score by -10 to +15"
|
||||
}
|
||||
],
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = "v-test",
|
||||
ContentHash = "sha256:abc123"
|
||||
},
|
||||
EwsDigest = "sha256:delta-digest",
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_unifiedScoreService
|
||||
.ComputeAsync(Arg.Any<UnifiedScoreRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(resultWithDelta);
|
||||
}
|
||||
|
||||
private void SetupScoreResultWithDigest(string digest)
|
||||
{
|
||||
var result = new UnifiedScoreResult
|
||||
{
|
||||
Score = 50,
|
||||
Bucket = ScoreBucket.Investigate,
|
||||
Breakdown =
|
||||
[
|
||||
new DimensionContribution { Dimension = "Reachability", Symbol = "Rch", InputValue = 0.5, Weight = 0.30, Contribution = 15.0 }
|
||||
],
|
||||
Guardrails = new AppliedGuardrails
|
||||
{
|
||||
SpeculativeCap = false,
|
||||
NotAffectedCap = false,
|
||||
RuntimeFloor = false,
|
||||
OriginalScore = 50,
|
||||
AdjustedScore = 50
|
||||
},
|
||||
WeightManifestRef = new WeightManifestRef
|
||||
{
|
||||
Version = "v-test",
|
||||
ContentHash = "sha256:abc123"
|
||||
},
|
||||
EwsDigest = digest,
|
||||
ComputedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_unifiedScoreService
|
||||
.ComputeAsync(Arg.Any<UnifiedScoreRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -8,8 +8,14 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NSubstitute" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Platform.WebService\StellaOps.Platform.WebService.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user