Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -73,7 +73,7 @@
|
|||||||
## Rebaseline Restart (2026-01-08)
|
## Rebaseline Restart (2026-01-08)
|
||||||
- Tracker resequenced to current 850 csproj inventory; audits restart linearly from DevOps services.
|
- Tracker resequenced to current 850 csproj inventory; audits restart linearly from DevOps services.
|
||||||
- New findings are recorded under "Findings (Rebaseline 2026-01-08 restart)" until the pass completes.
|
- New findings are recorded under "Findings (Rebaseline 2026-01-08 restart)" until the pass completes.
|
||||||
- Revalidated AUDIT-0001 to AUDIT-0103 (SimCryptoService, SimCryptoSmoke, CryptoProLinuxApi, NuGet prime v10/v9, SDK templates, Excititor connector template, Router doc samples + tests, Determinism analyzers/tests, AuditPack tests, Auth.Security tests, Canonicalization tests, Configuration tests, Cryptography.Kms tests, OfflineVerification plugin tests, Cryptography tests, DeltaVerdict tests, Eventing tests, Evidence.Persistence tests, Evidence tests, HybridLogicalClock tests, Infrastructure.Postgres tests, Metrics tests, Microservice.AspNetCore tests, Plugin tests, Provcache tests, Provenance tests, ReachGraph tests, Replay.Core tests, Replay tests, Signals tests, Spdx3 tests, Testing.Determinism tests, Testing.Manifests tests, TestKit tests, VersionComparison tests, Audit.ReplayToken, AuditPack, Auth.Security, Canonical.Json tests, Canonical.Json, Canonicalization, Configuration, Cryptography.DependencyInjection, Cryptography.Kms, Cryptography.Plugin.BouncyCastle, Cryptography.Plugin.CryptoPro, GostCryptography third-party library/tests, Cryptography.Plugin.EIDAS.Tests, Cryptography.Plugin.EIDAS, Cryptography.Plugin.OfflineVerification, Cryptography.Plugin.OpenSslGost, Cryptography.Plugin.Pkcs11Gost, Cryptography.Plugin.PqSoft, Cryptography.Plugin.SimRemote, Cryptography.Plugin.SmRemote.Tests, Cryptography.Plugin.SmRemote, Cryptography.Plugin.SmSoft.Tests, Cryptography.Plugin.SmSoft, Cryptography.Plugin.WineCsp, Cryptography.PluginLoader.Tests, Cryptography.PluginLoader, Cryptography.Providers.OfflineVerification, Cryptography.Tests (libraries), Cryptography (library), DeltaVerdict, DependencyInjection, Determinism.Abstractions, DistroIntel, Eventing, Evidence.Bundle, Evidence.Core.Tests, Evidence.Core, Evidence.Persistence, Evidence, Facet.Tests, Facet, HybridLogicalClock Benchmarks, HybridLogicalClock Tests, HybridLogicalClock, Infrastructure.EfCore, Infrastructure.Postgres, Ingestion.Telemetry, StellaOps.Interop, IssuerDirectory.Client, StellaOps.Metrics, Orchestrator.Schemas, StellaOps.Plugin, StellaOps.Policy.Tools, PolicyAuthoritySignals.Contracts, Provcache, Provcache.Api, Provcache.Postgres, Provcache.Valkey, Provenance, ReachGraph.Cache).
|
- Revalidated AUDIT-0001 to AUDIT-0104 (SimCryptoService, SimCryptoSmoke, CryptoProLinuxApi, NuGet prime v10/v9, SDK templates, Excititor connector template, Router doc samples + tests, Determinism analyzers/tests, AuditPack tests, Auth.Security tests, Canonicalization tests, Configuration tests, Cryptography.Kms tests, OfflineVerification plugin tests, Cryptography tests, DeltaVerdict tests, Eventing tests, Evidence.Persistence tests, Evidence tests, HybridLogicalClock tests, Infrastructure.Postgres tests, Metrics tests, Microservice.AspNetCore tests, Plugin tests, Provcache tests, Provenance tests, ReachGraph tests, Replay.Core tests, Replay tests, Signals tests, Spdx3 tests, Testing.Determinism tests, Testing.Manifests tests, TestKit tests, VersionComparison tests, Audit.ReplayToken, AuditPack, Auth.Security, Canonical.Json tests, Canonical.Json, Canonicalization, Configuration, Cryptography.DependencyInjection, Cryptography.Kms, Cryptography.Plugin.BouncyCastle, Cryptography.Plugin.CryptoPro, GostCryptography third-party library/tests, Cryptography.Plugin.EIDAS.Tests, Cryptography.Plugin.EIDAS, Cryptography.Plugin.OfflineVerification, Cryptography.Plugin.OpenSslGost, Cryptography.Plugin.Pkcs11Gost, Cryptography.Plugin.PqSoft, Cryptography.Plugin.SimRemote, Cryptography.Plugin.SmRemote.Tests, Cryptography.Plugin.SmRemote, Cryptography.Plugin.SmSoft.Tests, Cryptography.Plugin.SmSoft, Cryptography.Plugin.WineCsp, Cryptography.PluginLoader.Tests, Cryptography.PluginLoader, Cryptography.Providers.OfflineVerification, Cryptography.Tests (libraries), Cryptography (library), DeltaVerdict, DependencyInjection, Determinism.Abstractions, DistroIntel, Eventing, Evidence.Bundle, Evidence.Core.Tests, Evidence.Core, Evidence.Persistence, Evidence, Facet.Tests, Facet, HybridLogicalClock Benchmarks, HybridLogicalClock Tests, HybridLogicalClock, Infrastructure.EfCore, Infrastructure.Postgres, Ingestion.Telemetry, StellaOps.Interop, IssuerDirectory.Client, StellaOps.Metrics, Orchestrator.Schemas, StellaOps.Plugin, StellaOps.Policy.Tools, PolicyAuthoritySignals.Contracts, Provcache, Provcache.Api, Provcache.Postgres, Provcache.Valkey, Provenance, ReachGraph.Cache, ReachGraph.Persistence).
|
||||||
## Findings (Rebaseline 2026-01-08 restart)
|
## Findings (Rebaseline 2026-01-08 restart)
|
||||||
### devops/services/crypto/sim-crypto-service/SimCryptoService.csproj
|
### devops/services/crypto/sim-crypto-service/SimCryptoService.csproj
|
||||||
- MAINT: Shared ECDsa instance is reused across requests; ECDsa is not thread-safe and can race under concurrency. `devops/services/crypto/sim-crypto-service/Program.cs`
|
- MAINT: Shared ECDsa instance is reused across requests; ECDsa is not thread-safe and can race under concurrency. `devops/services/crypto/sim-crypto-service/Program.cs`
|
||||||
@@ -791,6 +791,19 @@
|
|||||||
- TEST: No tests cover in-memory repository ordering/filtering beyond a single continuation token or empty-store behavior. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/InMemoryAttestorEntryRepositoryTests.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Storage/InMemoryAttestorEntryRepository.cs`
|
- TEST: No tests cover in-memory repository ordering/filtering beyond a single continuation token or empty-store behavior. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/InMemoryAttestorEntryRepositoryTests.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Storage/InMemoryAttestorEntryRepository.cs`
|
||||||
- TEST: No tests validate DefaultDsseCanonicalizer behavior for empty signatures or missing payload fields. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/DefaultDsseCanonicalizerTests.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Submission/DefaultDsseCanonicalizer.cs`
|
- TEST: No tests validate DefaultDsseCanonicalizer behavior for empty signatures or missing payload fields. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/DefaultDsseCanonicalizerTests.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Submission/DefaultDsseCanonicalizer.cs`
|
||||||
- Disposition: revalidated 2026-01-07 (test project; apply waived); coverage extended 2026-01-08 for AUDIT-0055-A.
|
- Disposition: revalidated 2026-01-07 (test project; apply waived); coverage extended 2026-01-08 for AUDIT-0055-A.
|
||||||
|
- MAINT: PostgresRekorSubmissionQueue generates ids with Guid.NewGuid; inject IGuidGenerator for deterministic IDs and testability. `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Queue/PostgresRekorSubmissionQueue.cs`
|
||||||
|
- MAINT: PostgresRekorSubmissionQueue computes wait time using GetDateTime on created_at, which drops offset and can skew metrics; prefer DateTimeOffset via GetFieldValue<DateTimeOffset>. `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Queue/PostgresRekorSubmissionQueue.cs`
|
||||||
|
- QUALITY: HttpRekorClient parses checkpoint timestamps with DateTimeOffset.TryParse without InvariantCulture, making parsing locale-dependent. `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Rekor/HttpRekorClient.cs`
|
||||||
|
- SECURITY: HttpRekorClient VerifyInclusionAsync never validates checkpoint signatures and always reports checkpointSignatureValid=false; ensure downstream treats checkpoint as unverified or implement signature validation. `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Rekor/HttpRekorClient.cs`
|
||||||
|
- MAINT: Rekor backend construction logic is duplicated between verification and retry worker; centralize to avoid drift. `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Verification/AttestorVerificationService.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Workers/RekorRetryWorker.cs`
|
||||||
|
- TEST: Infrastructure tests exist but do not cover Rekor queue persistence/backoff, archive store metadata serialization, or submission/verification flows. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests`
|
||||||
|
- Disposition: revalidated 2026-01-06; apply reopened for remaining gaps.
|
||||||
|
### src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/StellaOps.Attestor.Infrastructure.Tests.csproj
|
||||||
|
- MAINT: No new issues on revalidation; tests use fixed timestamps and deterministic inputs. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/DefaultDsseCanonicalizerTests.cs` `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/InMemoryAttestorEntryRepositoryTests.cs`
|
||||||
|
- TEST: Coverage is limited to DSSE signature ordering, missing Rekor log index failure, and continuation-token paging; no tests cover Rekor submission success/conflict, proof parsing, or inclusion verification success/failure paths. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/HttpRekorClientTests.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Rekor/HttpRekorClient.cs`
|
||||||
|
- TEST: No tests cover in-memory repository ordering/filtering beyond a single continuation token or empty-store behavior. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/InMemoryAttestorEntryRepositoryTests.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Storage/InMemoryAttestorEntryRepository.cs`
|
||||||
|
- TEST: No tests validate DefaultDsseCanonicalizer behavior for empty signatures or missing payload fields. `src/Attestor/__Tests/StellaOps.Attestor.Infrastructure.Tests/DefaultDsseCanonicalizerTests.cs` `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.Infrastructure/Submission/DefaultDsseCanonicalizer.cs`
|
||||||
|
- Disposition: revalidated 2026-01-07 (test project; apply waived).
|
||||||
### src/Attestor/__Libraries/StellaOps.Attestor.Oci/StellaOps.Attestor.Oci.csproj
|
### src/Attestor/__Libraries/StellaOps.Attestor.Oci/StellaOps.Attestor.Oci.csproj
|
||||||
- QUALITY: OrasAttestationAttacher assumes imageRef.Digest is populated; when tag-only references are parsed, Digest is empty and no ResolveTagAsync call occurs, so attach/list/fetch/remove can target an empty digest. `src/Attestor/__Libraries/StellaOps.Attestor.Oci/Services/OrasAttestationAttacher.cs`
|
- QUALITY: OrasAttestationAttacher assumes imageRef.Digest is populated; when tag-only references are parsed, Digest is empty and no ResolveTagAsync call occurs, so attach/list/fetch/remove can target an empty digest. `src/Attestor/__Libraries/StellaOps.Attestor.Oci/Services/OrasAttestationAttacher.cs`
|
||||||
- QUALITY: ListAsync parses created timestamps with DateTimeOffset.TryParse without InvariantCulture, making ordering locale-dependent. `src/Attestor/__Libraries/StellaOps.Attestor.Oci/Services/OrasAttestationAttacher.cs`
|
- QUALITY: ListAsync parses created timestamps with DateTimeOffset.TryParse without InvariantCulture, making ordering locale-dependent. `src/Attestor/__Libraries/StellaOps.Attestor.Oci/Services/OrasAttestationAttacher.cs`
|
||||||
@@ -4255,6 +4268,7 @@
|
|||||||
- MAINT: CreateGlobRegex uses a control-character placeholder for "**", violating ASCII-only rules and making the regex brittle. `src/ReachGraph/StellaOps.ReachGraph.WebService/Services/ReachGraphSliceService.cs`
|
- MAINT: CreateGlobRegex uses a control-character placeholder for "**", violating ASCII-only rules and making the regex brittle. `src/ReachGraph/StellaOps.ReachGraph.WebService/Services/ReachGraphSliceService.cs`
|
||||||
- Proposed changes (pending approval): enforce authn/z with tenant-aware policies, scope cache by tenant, inject TimeProvider, add request validation and bounds, and replace the glob placeholder with ASCII plus tests.
|
- Proposed changes (pending approval): enforce authn/z with tenant-aware policies, scope cache by tenant, inject TimeProvider, add request validation and bounds, and replace the glob placeholder with ASCII plus tests.
|
||||||
- Disposition: pending implementation (non-test project; revalidated 2026-01-08; apply recommendations remain open).
|
- Disposition: pending implementation (non-test project; revalidated 2026-01-08; apply recommendations remain open).
|
||||||
|
- Disposition: pending implementation (non-test project; revalidated 2026-01-07; apply recommendations remain open).
|
||||||
### src/ReachGraph/__Tests/StellaOps.ReachGraph.WebService.Tests/StellaOps.ReachGraph.WebService.Tests.csproj
|
### src/ReachGraph/__Tests/StellaOps.ReachGraph.WebService.Tests/StellaOps.ReachGraph.WebService.Tests.csproj
|
||||||
- TEST: Coverage exists for upsert idempotency, get by digest/not found, slice by CVE/package, replay match, list by artifact, and delete. `src/ReachGraph/__Tests/StellaOps.ReachGraph.WebService.Tests/ReachGraphApiIntegrationTests.cs`
|
- TEST: Coverage exists for upsert idempotency, get by digest/not found, slice by CVE/package, replay match, list by artifact, and delete. `src/ReachGraph/__Tests/StellaOps.ReachGraph.WebService.Tests/ReachGraphApiIntegrationTests.cs`
|
||||||
- TEST: No coverage for entrypoint/file slices, invalid direction/depth, missing tenant header, or replay mismatch paths. `src/ReachGraph/__Tests/StellaOps.ReachGraph.WebService.Tests/ReachGraphApiIntegrationTests.cs`
|
- TEST: No coverage for entrypoint/file slices, invalid direction/depth, missing tenant header, or replay mismatch paths. `src/ReachGraph/__Tests/StellaOps.ReachGraph.WebService.Tests/ReachGraphApiIntegrationTests.cs`
|
||||||
@@ -4341,6 +4355,7 @@
|
|||||||
- TEST: No tests cover replay token endpoints, tenant header enforcement, or verdict replay endpoints. `src/Replay/StellaOps.Replay.WebService`
|
- TEST: No tests cover replay token endpoints, tenant header enforcement, or verdict replay endpoints. `src/Replay/StellaOps.Replay.WebService`
|
||||||
- Proposed changes (pending approval): enforce scopes from config, validate tenant against claims, clamp expiration, require authorization and path allowlisting for verdict replay, and add endpoint tests.
|
- Proposed changes (pending approval): enforce scopes from config, validate tenant against claims, clamp expiration, require authorization and path allowlisting for verdict replay, and add endpoint tests.
|
||||||
- Disposition: pending implementation (non-test project; revalidated 2026-01-08; apply recommendations remain open).
|
- Disposition: pending implementation (non-test project; revalidated 2026-01-08; apply recommendations remain open).
|
||||||
|
- Disposition: pending implementation (non-test project; revalidated 2026-01-07; apply recommendations remain open).
|
||||||
### src/__Libraries/StellaOps.Resolver/StellaOps.Resolver.csproj
|
### src/__Libraries/StellaOps.Resolver/StellaOps.Resolver.csproj
|
||||||
- MAINT: DeterministicResolver.Run uses DateTimeOffset.UtcNow; should use injected TimeProvider or require explicit resolvedAt for deterministic runs. `src/__Libraries/StellaOps.Resolver/DeterministicResolver.cs`
|
- MAINT: DeterministicResolver.Run uses DateTimeOffset.UtcNow; should use injected TimeProvider or require explicit resolvedAt for deterministic runs. `src/__Libraries/StellaOps.Resolver/DeterministicResolver.cs`
|
||||||
- Proposed changes (pending approval): inject TimeProvider and remove the DateTimeOffset.UtcNow default.
|
- Proposed changes (pending approval): inject TimeProvider and remove the DateTimeOffset.UtcNow default.
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||||
|
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using StellaOps.OpsMemory.Models;
|
||||||
|
using StellaOps.OpsMemory.Playbook;
|
||||||
|
using StellaOps.OpsMemory.Similarity;
|
||||||
|
using StellaOps.OpsMemory.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.OpsMemory.Tests.Unit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for PlaybookSuggestionService.
|
||||||
|
/// Sprint: SPRINT_20260107_006_004 Task OM-010
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class PlaybookSuggestionServiceTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IOpsMemoryStore> _storeMock;
|
||||||
|
private readonly SimilarityVectorGenerator _vectorGenerator;
|
||||||
|
private readonly PlaybookSuggestionService _service;
|
||||||
|
|
||||||
|
public PlaybookSuggestionServiceTests()
|
||||||
|
{
|
||||||
|
_storeMock = new Mock<IOpsMemoryStore>();
|
||||||
|
_vectorGenerator = new SimilarityVectorGenerator();
|
||||||
|
_service = new PlaybookSuggestionService(
|
||||||
|
_storeMock.Object,
|
||||||
|
_vectorGenerator,
|
||||||
|
NullLogger<PlaybookSuggestionService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSuggestionsAsync_WithNoSimilarRecords_ReturnsEmptySuggestions()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Array.Empty<SimilarityMatch>());
|
||||||
|
|
||||||
|
var request = new PlaybookSuggestionRequest
|
||||||
|
{
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
Situation = new SituationContext
|
||||||
|
{
|
||||||
|
CveId = "CVE-2023-12345",
|
||||||
|
Severity = "high"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSuggestionsAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Suggestions.Should().BeEmpty();
|
||||||
|
result.AnalyzedRecords.Should().Be(0);
|
||||||
|
result.HasSuggestions.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSuggestionsAsync_WithSimilarRecords_ReturnsSuggestions()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||||
|
var matches = new List<SimilarityMatch>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Record = pastRecord,
|
||||||
|
SimilarityScore = 0.85
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(matches);
|
||||||
|
|
||||||
|
var request = new PlaybookSuggestionRequest
|
||||||
|
{
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
Situation = new SituationContext
|
||||||
|
{
|
||||||
|
CveId = "CVE-2023-12345",
|
||||||
|
Severity = "high",
|
||||||
|
Reachability = ReachabilityStatus.Reachable
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSuggestionsAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Suggestions.Should().NotBeEmpty();
|
||||||
|
result.AnalyzedRecords.Should().Be(1);
|
||||||
|
result.HasSuggestions.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSuggestionsAsync_GroupsByAction_AndRanksBySuccessRate()
|
||||||
|
{
|
||||||
|
// Arrange - multiple records with same action
|
||||||
|
var remediateSuccess1 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||||
|
var remediateSuccess2 = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||||
|
var acceptPartial = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.PartialSuccess);
|
||||||
|
|
||||||
|
var matches = new List<SimilarityMatch>
|
||||||
|
{
|
||||||
|
new() { Record = remediateSuccess1, SimilarityScore = 0.9 },
|
||||||
|
new() { Record = remediateSuccess2, SimilarityScore = 0.85 },
|
||||||
|
new() { Record = acceptPartial, SimilarityScore = 0.8 }
|
||||||
|
};
|
||||||
|
|
||||||
|
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(matches);
|
||||||
|
|
||||||
|
var request = new PlaybookSuggestionRequest
|
||||||
|
{
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
Situation = new SituationContext { Severity = "high" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSuggestionsAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Suggestions.Should().NotBeEmpty();
|
||||||
|
// Remediate should rank higher due to 100% success rate
|
||||||
|
var firstSuggestion = result.Suggestions.First();
|
||||||
|
firstSuggestion.Action.Should().Be(DecisionAction.Remediate);
|
||||||
|
firstSuggestion.SuccessRate.Should().Be(1.0); // 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSuggestionsAsync_RespectsMaxSuggestionsLimit()
|
||||||
|
{
|
||||||
|
// Arrange - more actions than max suggestions
|
||||||
|
var remediate = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||||
|
var accept = CreatePastRecord(DecisionAction.Accept, OutcomeStatus.Success);
|
||||||
|
var mitigate = CreatePastRecord(DecisionAction.Mitigate, OutcomeStatus.Success);
|
||||||
|
var defer = CreatePastRecord(DecisionAction.Defer, OutcomeStatus.Success);
|
||||||
|
|
||||||
|
var matches = new List<SimilarityMatch>
|
||||||
|
{
|
||||||
|
new() { Record = remediate, SimilarityScore = 0.9 },
|
||||||
|
new() { Record = accept, SimilarityScore = 0.85 },
|
||||||
|
new() { Record = mitigate, SimilarityScore = 0.8 },
|
||||||
|
new() { Record = defer, SimilarityScore = 0.75 }
|
||||||
|
};
|
||||||
|
|
||||||
|
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(matches);
|
||||||
|
|
||||||
|
var request = new PlaybookSuggestionRequest
|
||||||
|
{
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
Situation = new SituationContext { Severity = "high" },
|
||||||
|
MaxSuggestions = 2
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSuggestionsAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Suggestions.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSuggestionsAsync_IncludesEvidenceRecords()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||||
|
var matches = new List<SimilarityMatch>
|
||||||
|
{
|
||||||
|
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||||
|
};
|
||||||
|
|
||||||
|
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(matches);
|
||||||
|
|
||||||
|
var request = new PlaybookSuggestionRequest
|
||||||
|
{
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
Situation = new SituationContext { Severity = "high" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSuggestionsAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Suggestions.Should().NotBeEmpty();
|
||||||
|
var suggestion = result.Suggestions.First();
|
||||||
|
suggestion.Evidence.Should().NotBeEmpty();
|
||||||
|
suggestion.Evidence.First().MemoryId.Should().Be(pastRecord.MemoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSuggestionsAsync_CalculatesConfidence()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||||
|
var matches = new List<SimilarityMatch>
|
||||||
|
{
|
||||||
|
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||||
|
};
|
||||||
|
|
||||||
|
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(matches);
|
||||||
|
|
||||||
|
var request = new PlaybookSuggestionRequest
|
||||||
|
{
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
Situation = new SituationContext { Severity = "high" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSuggestionsAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Suggestions.Should().NotBeEmpty();
|
||||||
|
var suggestion = result.Suggestions.First();
|
||||||
|
suggestion.Confidence.Should().BeGreaterThan(0);
|
||||||
|
suggestion.Confidence.Should().BeLessOrEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSuggestionsAsync_GeneratesRationale()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var pastRecord = CreatePastRecord(DecisionAction.Remediate, OutcomeStatus.Success);
|
||||||
|
var matches = new List<SimilarityMatch>
|
||||||
|
{
|
||||||
|
new() { Record = pastRecord, SimilarityScore = 0.9 }
|
||||||
|
};
|
||||||
|
|
||||||
|
_storeMock.Setup(s => s.FindSimilarAsync(It.IsAny<SimilarityQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(matches);
|
||||||
|
|
||||||
|
var request = new PlaybookSuggestionRequest
|
||||||
|
{
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
Situation = new SituationContext { Severity = "high" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _service.GetSuggestionsAsync(request);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Suggestions.Should().NotBeEmpty();
|
||||||
|
var suggestion = result.Suggestions.First();
|
||||||
|
suggestion.Rationale.Should().NotBeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OpsMemoryRecord CreatePastRecord(DecisionAction action, OutcomeStatus outcome)
|
||||||
|
{
|
||||||
|
var memoryId = Guid.NewGuid().ToString("N");
|
||||||
|
return new OpsMemoryRecord
|
||||||
|
{
|
||||||
|
MemoryId = memoryId,
|
||||||
|
TenantId = "tenant-1",
|
||||||
|
RecordedAt = DateTimeOffset.UtcNow.AddDays(-7),
|
||||||
|
Situation = new SituationContext
|
||||||
|
{
|
||||||
|
CveId = "CVE-2023-44487",
|
||||||
|
Severity = "high",
|
||||||
|
Reachability = ReachabilityStatus.Reachable
|
||||||
|
},
|
||||||
|
Decision = new DecisionRecord
|
||||||
|
{
|
||||||
|
Action = action,
|
||||||
|
Rationale = "Test decision rationale",
|
||||||
|
DecidedBy = "test-user",
|
||||||
|
DecidedAt = DateTimeOffset.UtcNow.AddDays(-7)
|
||||||
|
},
|
||||||
|
Outcome = new OutcomeRecord
|
||||||
|
{
|
||||||
|
Status = outcome,
|
||||||
|
ResolutionTime = TimeSpan.FromHours(4),
|
||||||
|
RecordedBy = "test-user",
|
||||||
|
RecordedAt = DateTimeOffset.UtcNow.AddDays(-5)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,17 +110,38 @@ public sealed class WorkerRepository : RepositoryBase<SchedulerDataSource>, IWor
|
|||||||
public async Task<WorkerEntity> UpsertAsync(WorkerEntity worker, CancellationToken cancellationToken = default)
|
public async Task<WorkerEntity> UpsertAsync(WorkerEntity worker, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
INSERT INTO scheduler.workers (id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, metadata)
|
INSERT INTO scheduler.workers (
|
||||||
VALUES (@id, @tenant_id, @hostname, @process_id, @job_types, @max_concurrent_jobs, @metadata::jsonb)
|
id,
|
||||||
|
tenant_id,
|
||||||
|
hostname,
|
||||||
|
process_id,
|
||||||
|
job_types,
|
||||||
|
max_concurrent_jobs,
|
||||||
|
current_jobs,
|
||||||
|
status,
|
||||||
|
metadata
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@id,
|
||||||
|
@tenant_id,
|
||||||
|
@hostname,
|
||||||
|
@process_id,
|
||||||
|
@job_types,
|
||||||
|
@max_concurrent_jobs,
|
||||||
|
@current_jobs,
|
||||||
|
@status,
|
||||||
|
@metadata::jsonb
|
||||||
|
)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
tenant_id = EXCLUDED.tenant_id,
|
tenant_id = EXCLUDED.tenant_id,
|
||||||
hostname = EXCLUDED.hostname,
|
hostname = EXCLUDED.hostname,
|
||||||
process_id = EXCLUDED.process_id,
|
process_id = EXCLUDED.process_id,
|
||||||
job_types = EXCLUDED.job_types,
|
job_types = EXCLUDED.job_types,
|
||||||
max_concurrent_jobs = EXCLUDED.max_concurrent_jobs,
|
max_concurrent_jobs = EXCLUDED.max_concurrent_jobs,
|
||||||
|
current_jobs = EXCLUDED.current_jobs,
|
||||||
metadata = EXCLUDED.metadata,
|
metadata = EXCLUDED.metadata,
|
||||||
last_heartbeat_at = NOW(),
|
last_heartbeat_at = NOW(),
|
||||||
status = 'active'
|
status = EXCLUDED.status
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -133,6 +154,8 @@ public sealed class WorkerRepository : RepositoryBase<SchedulerDataSource>, IWor
|
|||||||
AddParameter(command, "process_id", worker.ProcessId);
|
AddParameter(command, "process_id", worker.ProcessId);
|
||||||
AddTextArrayParameter(command, "job_types", worker.JobTypes);
|
AddTextArrayParameter(command, "job_types", worker.JobTypes);
|
||||||
AddParameter(command, "max_concurrent_jobs", worker.MaxConcurrentJobs);
|
AddParameter(command, "max_concurrent_jobs", worker.MaxConcurrentJobs);
|
||||||
|
AddParameter(command, "current_jobs", worker.CurrentJobs);
|
||||||
|
AddParameter(command, "status", worker.Status);
|
||||||
AddJsonbParameter(command, "metadata", worker.Metadata);
|
AddJsonbParameter(command, "metadata", worker.Metadata);
|
||||||
|
|
||||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace StellaOps.Scheduler.Persistence.Postgres.Tests;
|
|||||||
public sealed class TriggerRepositoryTests : IAsyncLifetime
|
public sealed class TriggerRepositoryTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly SchedulerPostgresFixture _fixture;
|
private readonly SchedulerPostgresFixture _fixture;
|
||||||
|
private readonly SchedulerDataSource _dataSource;
|
||||||
private readonly TriggerRepository _repository;
|
private readonly TriggerRepository _repository;
|
||||||
private readonly string _tenantId = Guid.NewGuid().ToString();
|
private readonly string _tenantId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
@@ -21,8 +22,8 @@ public sealed class TriggerRepositoryTests : IAsyncLifetime
|
|||||||
|
|
||||||
var options = fixture.Fixture.CreateOptions();
|
var options = fixture.Fixture.CreateOptions();
|
||||||
options.SchemaName = fixture.SchemaName;
|
options.SchemaName = fixture.SchemaName;
|
||||||
var dataSource = new SchedulerDataSource(Options.Create(options), NullLogger<SchedulerDataSource>.Instance);
|
_dataSource = new SchedulerDataSource(Options.Create(options), NullLogger<SchedulerDataSource>.Instance);
|
||||||
_repository = new TriggerRepository(dataSource, NullLogger<TriggerRepository>.Instance);
|
_repository = new TriggerRepository(_dataSource, NullLogger<TriggerRepository>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
|
||||||
@@ -141,6 +142,17 @@ public sealed class TriggerRepositoryTests : IAsyncLifetime
|
|||||||
var trigger = CreateTrigger("fire-test", "* * * * *");
|
var trigger = CreateTrigger("fire-test", "* * * * *");
|
||||||
await _repository.CreateAsync(trigger);
|
await _repository.CreateAsync(trigger);
|
||||||
var jobId = Guid.NewGuid();
|
var jobId = Guid.NewGuid();
|
||||||
|
var jobRepository = new JobRepository(_dataSource, NullLogger<JobRepository>.Instance);
|
||||||
|
await jobRepository.CreateAsync(new JobEntity
|
||||||
|
{
|
||||||
|
Id = jobId,
|
||||||
|
TenantId = _tenantId,
|
||||||
|
JobType = "scan",
|
||||||
|
PayloadDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
IdempotencyKey = $"job-{jobId}",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
Payload = "{}"
|
||||||
|
});
|
||||||
var nextFireAt = DateTimeOffset.UtcNow.AddMinutes(1);
|
var nextFireAt = DateTimeOffset.UtcNow.AddMinutes(1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
|||||||
24
src/__Libraries/StellaOps.ReachGraph.Persistence/AGENTS.md
Normal file
24
src/__Libraries/StellaOps.ReachGraph.Persistence/AGENTS.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# ReachGraph Persistence Library Charter
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
- Provide PostgreSQL persistence for reachability graphs and replay logs.
|
||||||
|
|
||||||
|
## Responsibilities
|
||||||
|
- Propagate cancellation tokens and enforce query limits.
|
||||||
|
- Use deterministic serialization for stored JSON fields.
|
||||||
|
- Keep tenant scoping consistent for all queries.
|
||||||
|
|
||||||
|
## Required Reading
|
||||||
|
- docs/modules/reach-graph/architecture.md
|
||||||
|
- docs/modules/platform/architecture-overview.md
|
||||||
|
|
||||||
|
## Working Directory & Scope
|
||||||
|
- Primary: src/__Libraries/StellaOps.ReachGraph.Persistence
|
||||||
|
|
||||||
|
## Testing Expectations
|
||||||
|
- Repository tests for store/get/delete/list/find/replay flows.
|
||||||
|
- Coverage for tenant scoping, limit validation, and scope parsing.
|
||||||
|
|
||||||
|
## Working Agreement
|
||||||
|
- Update sprint status in docs/implplan/SPRINT_*.md and local TASKS.md.
|
||||||
|
- Keep outputs deterministic and ASCII-only in comments and logs.
|
||||||
10
src/__Libraries/StellaOps.ReachGraph.Persistence/TASKS.md
Normal file
10
src/__Libraries/StellaOps.ReachGraph.Persistence/TASKS.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# ReachGraph Persistence Task Board
|
||||||
|
|
||||||
|
This board mirrors active sprint tasks for this module.
|
||||||
|
Source of truth: `docs/implplan/permament/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
|
||||||
|
|
||||||
|
| Task ID | Status | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| AUDIT-0104-M | DONE | Revalidated 2026-01-08; maintainability audit for ReachGraph.Persistence. |
|
||||||
|
| AUDIT-0104-T | DONE | Revalidated 2026-01-08; test coverage audit for ReachGraph.Persistence. |
|
||||||
|
| AUDIT-0104-A | TODO | Pending approval (revalidated 2026-01-08). |
|
||||||
Reference in New Issue
Block a user