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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Seed data fixtures for /advisories/{key}/chunks endpoint tests.
|
||||
/// Per CONCELIER-WEB-AOC-19-005.
|
||||
/// </summary>
|
||||
public static class AdvisoryChunkSeedData
|
||||
{
|
||||
public const string DefaultTenant = "chunk-test-tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a complete set of seed documents for testing the chunks endpoint.
|
||||
/// </summary>
|
||||
public static AdvisoryChunkSeedSet CreateSeedSet(string tenant = DefaultTenant)
|
||||
{
|
||||
var advisories = CreateAdvisories(tenant);
|
||||
var observations = CreateObservations(tenant);
|
||||
var aliases = CreateAliases(tenant);
|
||||
var rawDocuments = CreateRawDocuments(tenant);
|
||||
|
||||
return new AdvisoryChunkSeedSet(advisories, observations, aliases, rawDocuments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advisory documents for seed data.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<AdvisorySeedDocument> CreateAdvisories(string tenant = DefaultTenant)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new AdvisorySeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
AdvisoryKey = "CVE-2024-0001",
|
||||
Source = "nvd",
|
||||
Severity = "critical",
|
||||
Title = "Remote Code Execution in Example Package",
|
||||
Description = "A critical vulnerability allows remote attackers to execute arbitrary code.",
|
||||
Published = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2024, 1, 20, 0, 0, 0, DateTimeKind.Utc),
|
||||
Fingerprint = ComputeFingerprint("CVE-2024-0001", "nvd")
|
||||
},
|
||||
new AdvisorySeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
AdvisoryKey = "CVE-2024-0002",
|
||||
Source = "github",
|
||||
Severity = "high",
|
||||
Title = "SQL Injection in Database Layer",
|
||||
Description = "SQL injection vulnerability in the database abstraction layer.",
|
||||
Published = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2024, 2, 5, 0, 0, 0, DateTimeKind.Utc),
|
||||
Fingerprint = ComputeFingerprint("CVE-2024-0002", "github")
|
||||
},
|
||||
new AdvisorySeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
AdvisoryKey = "GHSA-xxxx-yyyy-zzzz",
|
||||
Source = "github",
|
||||
Severity = "medium",
|
||||
Title = "Cross-Site Scripting in Frontend",
|
||||
Description = "Stored XSS vulnerability in user profile fields.",
|
||||
Published = new DateTime(2024, 3, 10, 0, 0, 0, DateTimeKind.Utc),
|
||||
Modified = new DateTime(2024, 3, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Fingerprint = ComputeFingerprint("GHSA-xxxx-yyyy-zzzz", "github")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observation documents for seed data.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ObservationSeedDocument> CreateObservations(string tenant = DefaultTenant)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
// CVE-2024-0001 observations
|
||||
new ObservationSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
ObservationId = "obs-001-nvd",
|
||||
AdvisoryKey = "CVE-2024-0001",
|
||||
Source = "nvd",
|
||||
Format = "OSV",
|
||||
RawContent = CreateRawContent("CVE-2024-0001", "nvd", "critical"),
|
||||
CreatedAt = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
new ObservationSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
ObservationId = "obs-001-github",
|
||||
AdvisoryKey = "CVE-2024-0001",
|
||||
Source = "github",
|
||||
Format = "OSV",
|
||||
RawContent = CreateRawContent("CVE-2024-0001", "github", "critical"),
|
||||
CreatedAt = new DateTime(2024, 1, 16, 10, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
// CVE-2024-0002 observations
|
||||
new ObservationSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
ObservationId = "obs-002-github",
|
||||
AdvisoryKey = "CVE-2024-0002",
|
||||
Source = "github",
|
||||
Format = "OSV",
|
||||
RawContent = CreateRawContent("CVE-2024-0002", "github", "high"),
|
||||
CreatedAt = new DateTime(2024, 2, 1, 10, 0, 0, DateTimeKind.Utc)
|
||||
},
|
||||
// GHSA observations
|
||||
new ObservationSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
ObservationId = "obs-ghsa-001",
|
||||
AdvisoryKey = "GHSA-xxxx-yyyy-zzzz",
|
||||
Source = "github",
|
||||
Format = "GHSA",
|
||||
RawContent = CreateGhsaRawContent("GHSA-xxxx-yyyy-zzzz", "medium"),
|
||||
CreatedAt = new DateTime(2024, 3, 10, 10, 0, 0, DateTimeKind.Utc)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alias documents for seed data.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<AliasSeedDocument> CreateAliases(string tenant = DefaultTenant)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new AliasSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
Alias = "CVE-2024-0001",
|
||||
CanonicalId = "CVE-2024-0001",
|
||||
Aliases = new[] { "CVE-2024-0001", "GHSA-aaaa-bbbb-cccc" }
|
||||
},
|
||||
new AliasSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
Alias = "GHSA-aaaa-bbbb-cccc",
|
||||
CanonicalId = "CVE-2024-0001",
|
||||
Aliases = new[] { "CVE-2024-0001", "GHSA-aaaa-bbbb-cccc" }
|
||||
},
|
||||
new AliasSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
Alias = "CVE-2024-0002",
|
||||
CanonicalId = "CVE-2024-0002",
|
||||
Aliases = new[] { "CVE-2024-0002" }
|
||||
},
|
||||
new AliasSeedDocument
|
||||
{
|
||||
TenantId = tenant,
|
||||
Alias = "GHSA-xxxx-yyyy-zzzz",
|
||||
CanonicalId = "GHSA-xxxx-yyyy-zzzz",
|
||||
Aliases = new[] { "GHSA-xxxx-yyyy-zzzz" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raw documents for seed data (these resolve to chunks).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<AdvisoryRawDocument> CreateRawDocuments(string tenant = DefaultTenant)
|
||||
{
|
||||
var documents = new List<AdvisoryRawDocument>();
|
||||
|
||||
foreach (var obs in CreateObservations(tenant))
|
||||
{
|
||||
documents.Add(CreateRawDocumentFromObservation(obs, tenant));
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
private static AdvisoryRawDocument CreateRawDocumentFromObservation(
|
||||
ObservationSeedDocument obs,
|
||||
string tenant)
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(obs.RawContent);
|
||||
return new AdvisoryRawDocument(
|
||||
Tenant: tenant,
|
||||
Source: new RawSourceMetadata(obs.Source, "connector", "1.0.0"),
|
||||
Upstream: new RawUpstreamMetadata(
|
||||
UpstreamId: obs.AdvisoryKey,
|
||||
DocumentVersion: "1",
|
||||
RetrievedAt: obs.CreatedAt,
|
||||
ContentHash: $"sha256:{ComputeHash(obs.RawContent)}",
|
||||
Signature: new RawSignatureMetadata(false),
|
||||
Provenance: ImmutableDictionary<string, string>.Empty),
|
||||
Content: new RawContent(
|
||||
Format: obs.Format,
|
||||
SpecVersion: "1.0",
|
||||
Raw: jsonDoc.RootElement.Clone()),
|
||||
Identifiers: new RawIdentifiers(
|
||||
Aliases: ImmutableArray.Create(obs.AdvisoryKey),
|
||||
PrimaryId: obs.AdvisoryKey),
|
||||
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 CreateRawContent(string advisoryId, string source, string severity)
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"id": "{{advisoryId}}",
|
||||
"modified": "2024-01-20T00:00:00Z",
|
||||
"published": "2024-01-15T00:00:00Z",
|
||||
"aliases": ["{{advisoryId}}"],
|
||||
"summary": "Test vulnerability summary for {{advisoryId}}",
|
||||
"details": "Detailed description of the vulnerability. This provides comprehensive information about the security issue, affected components, and potential impact. The vulnerability was discovered by security researchers and affects multiple versions of the software.",
|
||||
"severity": [
|
||||
{
|
||||
"type": "CVSS_V3",
|
||||
"score": "{{severity == "critical" ? "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" : severity == "high" ? "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N" : "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"}}"
|
||||
}
|
||||
],
|
||||
"affected": [
|
||||
{
|
||||
"package": {
|
||||
"ecosystem": "npm",
|
||||
"name": "example-package"
|
||||
},
|
||||
"ranges": [
|
||||
{
|
||||
"type": "SEMVER",
|
||||
"events": [
|
||||
{"introduced": "0"},
|
||||
{"fixed": "2.0.0"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"type": "ADVISORY",
|
||||
"url": "https://nvd.nist.gov/vuln/detail/{{advisoryId}}"
|
||||
}
|
||||
],
|
||||
"database_specific": {
|
||||
"source": "{{source}}"
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string CreateGhsaRawContent(string ghsaId, string severity)
|
||||
{
|
||||
return $$"""
|
||||
{
|
||||
"id": "{{ghsaId}}",
|
||||
"modified": "2024-03-15T00:00:00Z",
|
||||
"published": "2024-03-10T00:00:00Z",
|
||||
"aliases": ["{{ghsaId}}"],
|
||||
"summary": "XSS vulnerability in frontend components",
|
||||
"details": "A cross-site scripting (XSS) vulnerability exists in the frontend user interface. An attacker can inject malicious scripts through user profile fields that are not properly sanitized before rendering. This can lead to session hijacking, data theft, or defacement.",
|
||||
"severity": [
|
||||
{
|
||||
"type": "CVSS_V3",
|
||||
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"
|
||||
}
|
||||
],
|
||||
"affected": [
|
||||
{
|
||||
"package": {
|
||||
"ecosystem": "npm",
|
||||
"name": "@example/frontend"
|
||||
},
|
||||
"ranges": [
|
||||
{
|
||||
"type": "SEMVER",
|
||||
"events": [
|
||||
{"introduced": "1.0.0"},
|
||||
{"fixed": "1.5.3"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"type": "ADVISORY",
|
||||
"url": "https://github.com/advisories/{{ghsaId}}"
|
||||
}
|
||||
],
|
||||
"database_specific": {
|
||||
"github_reviewed": true,
|
||||
"github_reviewed_at": "2024-03-10T10:00:00Z",
|
||||
"nvd_published_at": null
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string ComputeFingerprint(string advisoryKey, string source)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var data = System.Text.Encoding.UTF8.GetBytes($"{advisoryKey}:{source}");
|
||||
var hash = sha.ComputeHash(data);
|
||||
return Convert.ToHexStringLower(hash)[..16];
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var data = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
var hash = sha.ComputeHash(data);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Complete seed data set for chunks endpoint tests.
|
||||
/// </summary>
|
||||
public sealed record AdvisoryChunkSeedSet(
|
||||
IReadOnlyList<AdvisorySeedDocument> Advisories,
|
||||
IReadOnlyList<ObservationSeedDocument> Observations,
|
||||
IReadOnlyList<AliasSeedDocument> Aliases,
|
||||
IReadOnlyList<AdvisoryRawDocument> RawDocuments);
|
||||
|
||||
/// <summary>
|
||||
/// Advisory document for seeding.
|
||||
/// </summary>
|
||||
public sealed class AdvisorySeedDocument
|
||||
{
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("advisoryKey")]
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("severity")]
|
||||
public string Severity { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("title")]
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("published")]
|
||||
public DateTime Published { get; init; }
|
||||
|
||||
[BsonElement("modified")]
|
||||
public DateTime Modified { get; init; }
|
||||
|
||||
[BsonElement("fingerprint")]
|
||||
public string Fingerprint { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observation document for seeding.
|
||||
/// </summary>
|
||||
public sealed class ObservationSeedDocument
|
||||
{
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("observationId")]
|
||||
public string ObservationId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("advisoryKey")]
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("source")]
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("format")]
|
||||
public string Format { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("rawContent")]
|
||||
public string RawContent { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("createdAt")]
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alias document for seeding.
|
||||
/// </summary>
|
||||
public sealed class AliasSeedDocument
|
||||
{
|
||||
[BsonElement("tenantId")]
|
||||
public string TenantId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("alias")]
|
||||
public string Alias { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("canonicalId")]
|
||||
public string CanonicalId { get; init; } = string.Empty;
|
||||
|
||||
[BsonElement("aliases")]
|
||||
public IReadOnlyList<string> Aliases { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
namespace StellaOps.Concelier.WebService.Tests.Fixtures;
|
||||
|
||||
/// <summary>
|
||||
/// Test fixtures for auth/tenant configuration alignment.
|
||||
/// Per CONCELIER-WEB-AOC-19-006.
|
||||
/// </summary>
|
||||
public static class AuthTenantTestFixtures
|
||||
{
|
||||
/// <summary>
|
||||
/// Valid tenant identifiers that pass validation.
|
||||
/// Use these in test configurations.
|
||||
/// </summary>
|
||||
public static class ValidTenants
|
||||
{
|
||||
public const string TestTenant = "test-tenant";
|
||||
public const string DevTenant = "dev-tenant";
|
||||
public const string StagingTenant = "staging-tenant";
|
||||
public const string ProdTenant = "prod-tenant";
|
||||
public const string ChunkTestTenant = "chunk-test-tenant";
|
||||
public const string AocTestTenant = "aoc-test-tenant";
|
||||
public const string IntegrationTenant = "integration-tenant";
|
||||
|
||||
public static readonly string[] AllTestTenants =
|
||||
[
|
||||
TestTenant,
|
||||
DevTenant,
|
||||
StagingTenant,
|
||||
ChunkTestTenant,
|
||||
AocTestTenant,
|
||||
IntegrationTenant
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalid tenant identifiers for negative tests.
|
||||
/// </summary>
|
||||
public static class InvalidTenants
|
||||
{
|
||||
public const string EmptyTenant = "";
|
||||
public const string WhitespaceTenant = " ";
|
||||
public const string UppercaseTenant = "Test-Tenant"; // Uppercase not allowed
|
||||
public const string SpecialCharTenant = "test_tenant"; // Underscore not allowed
|
||||
public const string DotTenant = "test.tenant"; // Dot not allowed
|
||||
public const string SpaceTenant = "test tenant"; // Space not allowed
|
||||
public const string LongTenant = "this-tenant-identifier-is-way-too-long-and-exceeds-the-maximum-allowed-length";
|
||||
|
||||
public static readonly string[] AllInvalidTenants =
|
||||
[
|
||||
EmptyTenant,
|
||||
WhitespaceTenant,
|
||||
UppercaseTenant,
|
||||
SpecialCharTenant,
|
||||
DotTenant,
|
||||
SpaceTenant,
|
||||
LongTenant
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an authority configuration with the given required tenants.
|
||||
/// </summary>
|
||||
public static AuthorityTestConfiguration CreateAuthorityConfig(params string[] requiredTenants)
|
||||
{
|
||||
return new AuthorityTestConfiguration
|
||||
{
|
||||
RequiredTenants = requiredTenants.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a default test authority configuration.
|
||||
/// </summary>
|
||||
public static AuthorityTestConfiguration CreateDefaultAuthorityConfig()
|
||||
{
|
||||
return CreateAuthorityConfig(ValidTenants.AllTestTenants);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a minimal authority configuration for single-tenant tests.
|
||||
/// </summary>
|
||||
public static AuthorityTestConfiguration CreateSingleTenantConfig(string tenant = ValidTenants.TestTenant)
|
||||
{
|
||||
return CreateAuthorityConfig(tenant);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a tenant ID meets the allowlist requirements.
|
||||
/// </summary>
|
||||
public static (bool IsValid, string? Error) ValidateTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
return (false, "Tenant ID cannot be null or empty");
|
||||
}
|
||||
|
||||
if (tenantId.Length > 64)
|
||||
{
|
||||
return (false, "Tenant ID exceeds maximum length of 64 characters");
|
||||
}
|
||||
|
||||
foreach (var ch in tenantId)
|
||||
{
|
||||
var isAlpha = ch is >= 'a' and <= 'z';
|
||||
var isDigit = ch is >= '0' and <= '9';
|
||||
if (!isAlpha && !isDigit && ch != '-')
|
||||
{
|
||||
return (false, $"Tenant ID contains invalid character '{ch}'. Use lowercase letters, digits, or '-'");
|
||||
}
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test authority configuration.
|
||||
/// </summary>
|
||||
public sealed class AuthorityTestConfiguration
|
||||
{
|
||||
public IList<string> RequiredTenants { get; init; } = [];
|
||||
public bool Enabled { get; init; } = true;
|
||||
public string? Audience { get; init; } = "concelier-api";
|
||||
public string? Issuer { get; init; } = "https://test-authority.stellaops.local";
|
||||
}
|
||||
Reference in New Issue
Block a user