Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Aoc;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests ensuring AOC verify consistently emits ERR_AOC_001 and maintains
|
||||
/// mapper/guard parity across all violation scenarios.
|
||||
/// Per CONCELIER-WEB-AOC-19-007.
|
||||
/// </summary>
|
||||
public sealed class AocVerifyRegressionTests
|
||||
{
|
||||
private static readonly AocGuardOptions GuardOptions = AocGuardOptions.Default;
|
||||
|
||||
[Fact]
|
||||
public void Verify_ForbiddenField_EmitsErrAoc001()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithForbiddenField("severity", "high");
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var violation = Assert.Single(result.Violations.Where(v => v.Path == "/severity"));
|
||||
Assert.Equal("ERR_AOC_001", violation.ErrorCode);
|
||||
Assert.Equal(AocViolationCode.ForbiddenField, violation.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("severity")]
|
||||
[InlineData("cvss")]
|
||||
[InlineData("cvss_vector")]
|
||||
[InlineData("merged_from")]
|
||||
[InlineData("consensus_provider")]
|
||||
[InlineData("reachability")]
|
||||
[InlineData("asset_criticality")]
|
||||
[InlineData("risk_score")]
|
||||
public void Verify_AllForbiddenFields_EmitErrAoc001(string forbiddenField)
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithForbiddenField(forbiddenField, "forbidden_value");
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var violation = result.Violations.FirstOrDefault(v => v.Path == $"/{forbiddenField}");
|
||||
Assert.NotNull(violation);
|
||||
Assert.Equal("ERR_AOC_001", violation.ErrorCode);
|
||||
Assert.Equal(AocViolationCode.ForbiddenField, violation.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_DerivedField_EmitsErrAoc006()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithDerivedField("effective_status", "affected");
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var violation = result.Violations.FirstOrDefault(v =>
|
||||
v.Path == "/effective_status" && v.ErrorCode == "ERR_AOC_006");
|
||||
Assert.NotNull(violation);
|
||||
Assert.Equal(AocViolationCode.DerivedFindingDetected, violation.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("effective_status")]
|
||||
[InlineData("effective_range")]
|
||||
[InlineData("effective_severity")]
|
||||
[InlineData("effective_cvss")]
|
||||
public void Verify_AllDerivedFields_EmitErrAoc006(string derivedField)
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithDerivedField(derivedField, "derived_value");
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var violation = result.Violations.FirstOrDefault(v =>
|
||||
v.Path == $"/{derivedField}" && v.ErrorCode == "ERR_AOC_006");
|
||||
Assert.NotNull(violation);
|
||||
Assert.Equal(AocViolationCode.DerivedFindingDetected, violation.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_UnknownField_EmitsErrAoc007()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithUnknownField("completely_unknown_field", "some_value");
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var violation = Assert.Single(result.Violations.Where(v =>
|
||||
v.Path == "/completely_unknown_field" && v.ErrorCode == "ERR_AOC_007"));
|
||||
Assert.Equal(AocViolationCode.UnknownField, violation.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_MergeAttempt_EmitsErrAoc002()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithMergedFrom(["obs-1", "obs-2"]);
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
// merged_from triggers ERR_AOC_001 (forbidden field)
|
||||
var violation = result.Violations.FirstOrDefault(v => v.Path == "/merged_from");
|
||||
Assert.NotNull(violation);
|
||||
Assert.Equal("ERR_AOC_001", violation.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_MultipleViolations_EmitsAllErrorCodes()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithMultipleViolations();
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
|
||||
// Should have ERR_AOC_001 for forbidden field
|
||||
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_001");
|
||||
|
||||
// Should have ERR_AOC_006 for derived field
|
||||
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_006");
|
||||
|
||||
// Should have ERR_AOC_007 for unknown field
|
||||
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_007");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ValidDocument_NoViolations()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateValidJson();
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Empty(result.Violations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ErrorCodeConsistency_AcrossMultipleRuns()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithForbiddenField("severity", "critical");
|
||||
|
||||
// Run validation multiple times
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => guard.Validate(json.RootElement, GuardOptions))
|
||||
.ToList();
|
||||
|
||||
// All should produce same error code
|
||||
var allErrorCodes = results
|
||||
.SelectMany(r => r.Violations)
|
||||
.Select(v => v.ErrorCode)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.Single(allErrorCodes);
|
||||
Assert.Equal("ERR_AOC_001", allErrorCodes[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_PathConsistency_AcrossMultipleRuns()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithForbiddenField("cvss", "9.8");
|
||||
|
||||
// Run validation multiple times
|
||||
var results = Enumerable.Range(0, 10)
|
||||
.Select(_ => guard.Validate(json.RootElement, GuardOptions))
|
||||
.ToList();
|
||||
|
||||
// All should produce same path
|
||||
var allPaths = results
|
||||
.SelectMany(r => r.Violations)
|
||||
.Select(v => v.Path)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
Assert.Single(allPaths);
|
||||
Assert.Equal("/cvss", allPaths[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_MapperGuardParity_ValidationResultsMatch()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var validator = new AdvisorySchemaValidator(guard, Options.Create(GuardOptions));
|
||||
|
||||
// Create document with forbidden field
|
||||
var json = CreateJsonWithForbiddenField("severity", "high");
|
||||
|
||||
// Validate with guard directly
|
||||
var guardResult = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
// Both should detect the violation
|
||||
Assert.False(guardResult.IsValid);
|
||||
Assert.Contains(guardResult.Violations, v =>
|
||||
v.ErrorCode == "ERR_AOC_001" && v.Path == "/severity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_ViolationMessage_ContainsMeaningfulDetails()
|
||||
{
|
||||
var guard = new AocWriteGuard();
|
||||
var json = CreateJsonWithForbiddenField("severity", "high");
|
||||
|
||||
var result = guard.Validate(json.RootElement, GuardOptions);
|
||||
|
||||
var violation = result.Violations.First(v => v.ErrorCode == "ERR_AOC_001");
|
||||
|
||||
// Message should not be empty
|
||||
Assert.False(string.IsNullOrWhiteSpace(violation.Message));
|
||||
|
||||
// Path should be correct
|
||||
Assert.Equal("/severity", violation.Path);
|
||||
}
|
||||
|
||||
private static JsonDocument CreateJsonWithForbiddenField(string field, string value)
|
||||
{
|
||||
return JsonDocument.Parse($$"""
|
||||
{
|
||||
"tenant": "test",
|
||||
"{{field}}": "{{value}}",
|
||||
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
|
||||
"upstream": {
|
||||
"upstream_id": "CVE-2024-0001",
|
||||
"content_hash": "sha256:abc",
|
||||
"retrieved_at": "2024-01-01T00:00:00Z",
|
||||
"signature": {"present": false},
|
||||
"provenance": {}
|
||||
},
|
||||
"content": {"format": "OSV", "raw": {}},
|
||||
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
|
||||
"linkset": {}
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static JsonDocument CreateJsonWithDerivedField(string field, string value)
|
||||
{
|
||||
return JsonDocument.Parse($$"""
|
||||
{
|
||||
"tenant": "test",
|
||||
"{{field}}": "{{value}}",
|
||||
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
|
||||
"upstream": {
|
||||
"upstream_id": "CVE-2024-0001",
|
||||
"content_hash": "sha256:abc",
|
||||
"retrieved_at": "2024-01-01T00:00:00Z",
|
||||
"signature": {"present": false},
|
||||
"provenance": {}
|
||||
},
|
||||
"content": {"format": "OSV", "raw": {}},
|
||||
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
|
||||
"linkset": {}
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static JsonDocument CreateJsonWithUnknownField(string field, string value)
|
||||
{
|
||||
return JsonDocument.Parse($$"""
|
||||
{
|
||||
"tenant": "test",
|
||||
"{{field}}": "{{value}}",
|
||||
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
|
||||
"upstream": {
|
||||
"upstream_id": "CVE-2024-0001",
|
||||
"content_hash": "sha256:abc",
|
||||
"retrieved_at": "2024-01-01T00:00:00Z",
|
||||
"signature": {"present": false},
|
||||
"provenance": {}
|
||||
},
|
||||
"content": {"format": "OSV", "raw": {}},
|
||||
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
|
||||
"linkset": {}
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static JsonDocument CreateJsonWithMergedFrom(string[] mergedFrom)
|
||||
{
|
||||
var mergedArray = string.Join(", ", mergedFrom.Select(m => $"\"{m}\""));
|
||||
return JsonDocument.Parse($$"""
|
||||
{
|
||||
"tenant": "test",
|
||||
"merged_from": [{{mergedArray}}],
|
||||
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
|
||||
"upstream": {
|
||||
"upstream_id": "CVE-2024-0001",
|
||||
"content_hash": "sha256:abc",
|
||||
"retrieved_at": "2024-01-01T00:00:00Z",
|
||||
"signature": {"present": false},
|
||||
"provenance": {}
|
||||
},
|
||||
"content": {"format": "OSV", "raw": {}},
|
||||
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
|
||||
"linkset": {}
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static JsonDocument CreateJsonWithMultipleViolations()
|
||||
{
|
||||
return JsonDocument.Parse("""
|
||||
{
|
||||
"tenant": "test",
|
||||
"severity": "high",
|
||||
"effective_status": "affected",
|
||||
"unknown_custom_field": "value",
|
||||
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
|
||||
"upstream": {
|
||||
"upstream_id": "CVE-2024-0001",
|
||||
"content_hash": "sha256:abc",
|
||||
"retrieved_at": "2024-01-01T00:00:00Z",
|
||||
"signature": {"present": false},
|
||||
"provenance": {}
|
||||
},
|
||||
"content": {"format": "OSV", "raw": {}},
|
||||
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
|
||||
"linkset": {}
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static JsonDocument CreateValidJson()
|
||||
{
|
||||
return JsonDocument.Parse("""
|
||||
{
|
||||
"tenant": "test",
|
||||
"source": {"vendor": "test", "connector": "test", "version": "1.0"},
|
||||
"upstream": {
|
||||
"upstream_id": "CVE-2024-0001",
|
||||
"content_hash": "sha256:abc",
|
||||
"retrieved_at": "2024-01-01T00:00:00Z",
|
||||
"signature": {"present": false},
|
||||
"provenance": {}
|
||||
},
|
||||
"content": {"format": "OSV", "raw": {}},
|
||||
"identifiers": {"aliases": [], "primary": "CVE-2024-0001"},
|
||||
"linkset": {}
|
||||
}
|
||||
""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Aoc;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for large-batch ingest reproducibility.
|
||||
/// Per CONCELIER-WEB-AOC-19-004.
|
||||
/// </summary>
|
||||
public sealed class LargeBatchIngestTests
|
||||
{
|
||||
private static readonly AocGuardOptions GuardOptions = AocGuardOptions.Default;
|
||||
|
||||
[Fact]
|
||||
public void LargeBatch_ValidDocuments_AllPassValidation()
|
||||
{
|
||||
var validator = CreateValidator();
|
||||
var documents = GenerateValidDocuments(1000);
|
||||
|
||||
var results = documents.Select(validator.ValidateSchema).ToList();
|
||||
|
||||
Assert.All(results, r => Assert.True(r.IsValid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBatch_MixedDocuments_DetectsViolationsReproducibly()
|
||||
{
|
||||
var validator = CreateValidator();
|
||||
var (validDocs, invalidDocs) = GenerateMixedBatch(500, 500);
|
||||
var allDocs = validDocs.Concat(invalidDocs).ToList();
|
||||
|
||||
// First pass
|
||||
var results1 = allDocs.Select(validator.ValidateSchema).ToList();
|
||||
|
||||
// Second pass (same order)
|
||||
var results2 = allDocs.Select(validator.ValidateSchema).ToList();
|
||||
|
||||
// Results should be identical (reproducible)
|
||||
for (int i = 0; i < results1.Count; i++)
|
||||
{
|
||||
Assert.Equal(results1[i].IsValid, results2[i].IsValid);
|
||||
Assert.Equal(results1[i].Violations.Count, results2[i].Violations.Count);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBatch_DeterministicViolationOrdering()
|
||||
{
|
||||
var validator = CreateValidator();
|
||||
var documents = GenerateDocumentsWithMultipleViolations(100);
|
||||
|
||||
// Run validation twice
|
||||
var results1 = documents.Select(validator.ValidateSchema).ToList();
|
||||
var results2 = documents.Select(validator.ValidateSchema).ToList();
|
||||
|
||||
// Violations should be in same order
|
||||
for (int i = 0; i < results1.Count; i++)
|
||||
{
|
||||
var violations1 = results1[i].Violations;
|
||||
var violations2 = results2[i].Violations;
|
||||
|
||||
Assert.Equal(violations1.Count, violations2.Count);
|
||||
for (int j = 0; j < violations1.Count; j++)
|
||||
{
|
||||
Assert.Equal(violations1[j].ErrorCode, violations2[j].ErrorCode);
|
||||
Assert.Equal(violations1[j].Path, violations2[j].Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBatch_ParallelValidation_Reproducible()
|
||||
{
|
||||
var validator = CreateValidator();
|
||||
var documents = GenerateValidDocuments(1000);
|
||||
|
||||
// Sequential validation
|
||||
var sequentialResults = documents.Select(validator.ValidateSchema).ToList();
|
||||
|
||||
// Parallel validation
|
||||
var parallelResults = documents.AsParallel()
|
||||
.AsOrdered()
|
||||
.Select(validator.ValidateSchema)
|
||||
.ToList();
|
||||
|
||||
// Results should be identical
|
||||
Assert.Equal(sequentialResults.Count, parallelResults.Count);
|
||||
for (int i = 0; i < sequentialResults.Count; i++)
|
||||
{
|
||||
Assert.Equal(sequentialResults[i].IsValid, parallelResults[i].IsValid);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBatch_ContentHashConsistency()
|
||||
{
|
||||
var documents = GenerateValidDocuments(100);
|
||||
var hashes1 = documents.Select(ComputeDocumentHash).ToList();
|
||||
var hashes2 = documents.Select(ComputeDocumentHash).ToList();
|
||||
|
||||
// Hashes should be identical for same documents
|
||||
for (int i = 0; i < hashes1.Count; i++)
|
||||
{
|
||||
Assert.Equal(hashes1[i], hashes2[i]);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(100)]
|
||||
[InlineData(500)]
|
||||
[InlineData(1000)]
|
||||
public void LargeBatch_ScalesLinearly(int batchSize)
|
||||
{
|
||||
var validator = CreateValidator();
|
||||
var documents = GenerateValidDocuments(batchSize);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var results = documents.Select(validator.ValidateSchema).ToList();
|
||||
sw.Stop();
|
||||
|
||||
// All should pass
|
||||
Assert.Equal(batchSize, results.Count);
|
||||
Assert.All(results, r => Assert.True(r.IsValid));
|
||||
|
||||
// Should complete in reasonable time (less than 100ms per 100 docs)
|
||||
var expectedMaxMs = batchSize;
|
||||
Assert.True(sw.ElapsedMilliseconds < expectedMaxMs,
|
||||
$"Validation took {sw.ElapsedMilliseconds}ms for {batchSize} docs (expected < {expectedMaxMs}ms)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeBatch_ViolationCounts_Deterministic()
|
||||
{
|
||||
var validator = CreateValidator();
|
||||
|
||||
// Generate same batch twice
|
||||
var batch1 = GenerateMixedBatch(250, 250);
|
||||
var batch2 = GenerateMixedBatch(250, 250);
|
||||
|
||||
var allDocs1 = batch1.Valid.Concat(batch1.Invalid).ToList();
|
||||
var allDocs2 = batch2.Valid.Concat(batch2.Invalid).ToList();
|
||||
|
||||
var results1 = allDocs1.Select(validator.ValidateSchema).ToList();
|
||||
var results2 = allDocs2.Select(validator.ValidateSchema).ToList();
|
||||
|
||||
// Same generation should produce same violation counts
|
||||
var validCount1 = results1.Count(r => r.IsValid);
|
||||
var validCount2 = results2.Count(r => r.IsValid);
|
||||
var violationCount1 = results1.Sum(r => r.Violations.Count);
|
||||
var violationCount2 = results2.Sum(r => r.Violations.Count);
|
||||
|
||||
Assert.Equal(validCount1, validCount2);
|
||||
Assert.Equal(violationCount1, violationCount2);
|
||||
}
|
||||
|
||||
private static AdvisorySchemaValidator CreateValidator()
|
||||
=> new(new AocWriteGuard(), Options.Create(GuardOptions));
|
||||
|
||||
private static List<AdvisoryRawDocument> GenerateValidDocuments(int count)
|
||||
{
|
||||
var documents = new List<AdvisoryRawDocument>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
documents.Add(CreateValidDocument($"tenant-{i % 10}", $"GHSA-{i:0000}"));
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static (List<AdvisoryRawDocument> Valid, List<AdvisoryRawDocument> Invalid) GenerateMixedBatch(
|
||||
int validCount, int invalidCount)
|
||||
{
|
||||
var valid = GenerateValidDocuments(validCount);
|
||||
var invalid = GenerateInvalidDocuments(invalidCount);
|
||||
return (valid, invalid);
|
||||
}
|
||||
|
||||
private static List<AdvisoryRawDocument> GenerateInvalidDocuments(int count)
|
||||
{
|
||||
var documents = new List<AdvisoryRawDocument>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
documents.Add(CreateDocumentWithForbiddenField($"tenant-{i % 10}", $"CVE-{i:0000}"));
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static List<AdvisoryRawDocument> GenerateDocumentsWithMultipleViolations(int count)
|
||||
{
|
||||
var documents = new List<AdvisoryRawDocument>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
documents.Add(CreateDocumentWithMultipleViolations($"tenant-{i % 10}", $"CVE-MULTI-{i:0000}"));
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static AdvisoryRawDocument CreateValidDocument(string tenant, string advisoryId)
|
||||
{
|
||||
using var rawDocument = JsonDocument.Parse($$"""{"id":"{{advisoryId}}"}""");
|
||||
return new AdvisoryRawDocument(
|
||||
Tenant: tenant,
|
||||
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
|
||||
Upstream: new RawUpstreamMetadata(
|
||||
UpstreamId: advisoryId,
|
||||
DocumentVersion: "1",
|
||||
RetrievedAt: DateTimeOffset.UtcNow,
|
||||
ContentHash: $"sha256:{advisoryId}",
|
||||
Signature: new RawSignatureMetadata(false),
|
||||
Provenance: ImmutableDictionary<string, string>.Empty),
|
||||
Content: new RawContent(
|
||||
Format: "OSV",
|
||||
SpecVersion: "1.0",
|
||||
Raw: rawDocument.RootElement.Clone()),
|
||||
Identifiers: new RawIdentifiers(
|
||||
Aliases: ImmutableArray.Create(advisoryId),
|
||||
PrimaryId: advisoryId),
|
||||
Linkset: new RawLinkset
|
||||
{
|
||||
Aliases = ImmutableArray<string>.Empty,
|
||||
PackageUrls = ImmutableArray<string>.Empty,
|
||||
Cpes = ImmutableArray<string>.Empty,
|
||||
References = ImmutableArray<RawReference>.Empty,
|
||||
ReconciledFrom = ImmutableArray<string>.Empty,
|
||||
Notes = ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
Links: ImmutableArray<RawLink>.Empty);
|
||||
}
|
||||
|
||||
private static AdvisoryRawDocument CreateDocumentWithForbiddenField(string tenant, string advisoryId)
|
||||
{
|
||||
// Create document with forbidden "severity" field
|
||||
using var rawDocument = JsonDocument.Parse($$"""{"id":"{{advisoryId}}","severity":"high"}""");
|
||||
return new AdvisoryRawDocument(
|
||||
Tenant: tenant,
|
||||
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
|
||||
Upstream: new RawUpstreamMetadata(
|
||||
UpstreamId: advisoryId,
|
||||
DocumentVersion: "1",
|
||||
RetrievedAt: DateTimeOffset.UtcNow,
|
||||
ContentHash: $"sha256:{advisoryId}",
|
||||
Signature: new RawSignatureMetadata(false),
|
||||
Provenance: ImmutableDictionary<string, string>.Empty),
|
||||
Content: new RawContent(
|
||||
Format: "OSV",
|
||||
SpecVersion: "1.0",
|
||||
Raw: rawDocument.RootElement.Clone()),
|
||||
Identifiers: new RawIdentifiers(
|
||||
Aliases: ImmutableArray.Create(advisoryId),
|
||||
PrimaryId: advisoryId),
|
||||
Linkset: new RawLinkset
|
||||
{
|
||||
Aliases = ImmutableArray<string>.Empty,
|
||||
PackageUrls = ImmutableArray<string>.Empty,
|
||||
Cpes = ImmutableArray<string>.Empty,
|
||||
References = ImmutableArray<RawReference>.Empty,
|
||||
ReconciledFrom = ImmutableArray<string>.Empty,
|
||||
Notes = ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
Links: ImmutableArray<RawLink>.Empty);
|
||||
}
|
||||
|
||||
private static AdvisoryRawDocument CreateDocumentWithMultipleViolations(string tenant, string advisoryId)
|
||||
{
|
||||
// Create document with multiple violations: forbidden, derived, and unknown fields
|
||||
using var rawDocument = JsonDocument.Parse($$"""
|
||||
{
|
||||
"id": "{{advisoryId}}",
|
||||
"severity": "high",
|
||||
"effective_status": "affected",
|
||||
"unknown_field": "value"
|
||||
}
|
||||
""");
|
||||
return new AdvisoryRawDocument(
|
||||
Tenant: tenant,
|
||||
Source: new RawSourceMetadata("vendor-x", "connector-y", "1.0.0"),
|
||||
Upstream: new RawUpstreamMetadata(
|
||||
UpstreamId: advisoryId,
|
||||
DocumentVersion: "1",
|
||||
RetrievedAt: DateTimeOffset.UtcNow,
|
||||
ContentHash: $"sha256:{advisoryId}",
|
||||
Signature: new RawSignatureMetadata(false),
|
||||
Provenance: ImmutableDictionary<string, string>.Empty),
|
||||
Content: new RawContent(
|
||||
Format: "OSV",
|
||||
SpecVersion: "1.0",
|
||||
Raw: rawDocument.RootElement.Clone()),
|
||||
Identifiers: new RawIdentifiers(
|
||||
Aliases: ImmutableArray.Create(advisoryId),
|
||||
PrimaryId: advisoryId),
|
||||
Linkset: new RawLinkset
|
||||
{
|
||||
Aliases = ImmutableArray<string>.Empty,
|
||||
PackageUrls = ImmutableArray<string>.Empty,
|
||||
Cpes = ImmutableArray<string>.Empty,
|
||||
References = ImmutableArray<RawReference>.Empty,
|
||||
ReconciledFrom = ImmutableArray<string>.Empty,
|
||||
Notes = ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
Links: ImmutableArray<RawLink>.Empty);
|
||||
}
|
||||
|
||||
private static string ComputeDocumentHash(AdvisoryRawDocument doc)
|
||||
{
|
||||
// Simple hash combining key fields
|
||||
var data = $"{doc.Tenant}|{doc.Upstream.UpstreamId}|{doc.Upstream.ContentHash}";
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(data);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Aoc;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for tenant allowlist enforcement.
|
||||
/// Per CONCELIER-WEB-AOC-19-006.
|
||||
/// </summary>
|
||||
public sealed class TenantAllowlistTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("test-tenant")]
|
||||
[InlineData("dev-tenant")]
|
||||
[InlineData("tenant-123")]
|
||||
[InlineData("a")]
|
||||
[InlineData("tenant-with-dashes-in-name")]
|
||||
public void ValidateTenantId_ValidTenant_ReturnsValid(string tenantId)
|
||||
{
|
||||
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(tenantId);
|
||||
|
||||
Assert.True(isValid);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "cannot be null or empty")]
|
||||
[InlineData("Test-Tenant", "invalid character 'T'")] // Uppercase
|
||||
[InlineData("test_tenant", "invalid character '_'")] // Underscore
|
||||
[InlineData("test.tenant", "invalid character '.'")] // Dot
|
||||
[InlineData("test tenant", "invalid character ' '")] // Space
|
||||
[InlineData("test@tenant", "invalid character '@'")] // Special char
|
||||
public void ValidateTenantId_InvalidTenant_ReturnsError(string tenantId, string expectedErrorPart)
|
||||
{
|
||||
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(tenantId);
|
||||
|
||||
Assert.False(isValid);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedErrorPart, error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTenantId_TooLong_ReturnsError()
|
||||
{
|
||||
var longTenant = new string('a', 65); // 65 chars, max is 64
|
||||
|
||||
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(longTenant);
|
||||
|
||||
Assert.False(isValid);
|
||||
Assert.Contains("exceeds maximum length", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateTenantId_MaxLength_ReturnsValid()
|
||||
{
|
||||
var maxTenant = new string('a', 64); // Exactly 64 chars
|
||||
|
||||
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(maxTenant);
|
||||
|
||||
Assert.True(isValid);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateDefaultAuthorityConfig_ContainsAllTestTenants()
|
||||
{
|
||||
var config = AuthTenantTestFixtures.CreateDefaultAuthorityConfig();
|
||||
|
||||
Assert.NotEmpty(config.RequiredTenants);
|
||||
Assert.Contains(AuthTenantTestFixtures.ValidTenants.TestTenant, config.RequiredTenants);
|
||||
Assert.Contains(AuthTenantTestFixtures.ValidTenants.ChunkTestTenant, config.RequiredTenants);
|
||||
Assert.Contains(AuthTenantTestFixtures.ValidTenants.AocTestTenant, config.RequiredTenants);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSingleTenantConfig_ContainsOnlySpecifiedTenant()
|
||||
{
|
||||
var tenant = "single-test";
|
||||
var config = AuthTenantTestFixtures.CreateSingleTenantConfig(tenant);
|
||||
|
||||
Assert.Single(config.RequiredTenants);
|
||||
Assert.Equal(tenant, config.RequiredTenants[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllValidTenants_PassValidation()
|
||||
{
|
||||
foreach (var tenant in AuthTenantTestFixtures.ValidTenants.AllTestTenants)
|
||||
{
|
||||
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(tenant);
|
||||
|
||||
Assert.True(isValid, $"Tenant '{tenant}' should be valid but got error: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllInvalidTenants_FailValidation()
|
||||
{
|
||||
foreach (var tenant in AuthTenantTestFixtures.InvalidTenants.AllInvalidTenants)
|
||||
{
|
||||
var (isValid, _) = AuthTenantTestFixtures.ValidateTenantId(tenant);
|
||||
|
||||
Assert.False(isValid, $"Tenant '{tenant}' should be invalid");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthorityTestConfiguration_DefaultValuesAreSet()
|
||||
{
|
||||
var config = AuthTenantTestFixtures.CreateAuthorityConfig("test");
|
||||
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal("concelier-api", config.Audience);
|
||||
Assert.Equal("https://test-authority.stellaops.local", config.Issuer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedDataFixtures_UseTenantsThatPassValidation()
|
||||
{
|
||||
// Verify that seed data fixtures use valid tenant IDs
|
||||
var chunkSeedTenant = AdvisoryChunkSeedData.DefaultTenant;
|
||||
var (isValid, error) = AuthTenantTestFixtures.ValidateTenantId(chunkSeedTenant);
|
||||
|
||||
Assert.True(isValid, $"Chunk seed tenant '{chunkSeedTenant}' should be valid but got error: {error}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user