This commit is contained in:
master
2026-01-08 20:48:20 +02:00
7 changed files with 1504 additions and 9 deletions

View File

@@ -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)
}
};
}
}

View File

@@ -110,17 +110,38 @@ public sealed class WorkerRepository : RepositoryBase<SchedulerDataSource>, IWor
public async Task<WorkerEntity> UpsertAsync(WorkerEntity worker, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO scheduler.workers (id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, metadata)
VALUES (@id, @tenant_id, @hostname, @process_id, @job_types, @max_concurrent_jobs, @metadata::jsonb)
INSERT INTO scheduler.workers (
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
tenant_id = EXCLUDED.tenant_id,
hostname = EXCLUDED.hostname,
process_id = EXCLUDED.process_id,
job_types = EXCLUDED.job_types,
max_concurrent_jobs = EXCLUDED.max_concurrent_jobs,
current_jobs = EXCLUDED.current_jobs,
metadata = EXCLUDED.metadata,
last_heartbeat_at = NOW(),
status = 'active'
status = EXCLUDED.status
RETURNING *
""";
@@ -133,6 +154,8 @@ public sealed class WorkerRepository : RepositoryBase<SchedulerDataSource>, IWor
AddParameter(command, "process_id", worker.ProcessId);
AddTextArrayParameter(command, "job_types", worker.JobTypes);
AddParameter(command, "max_concurrent_jobs", worker.MaxConcurrentJobs);
AddParameter(command, "current_jobs", worker.CurrentJobs);
AddParameter(command, "status", worker.Status);
AddJsonbParameter(command, "metadata", worker.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -12,6 +12,7 @@ namespace StellaOps.Scheduler.Persistence.Postgres.Tests;
public sealed class TriggerRepositoryTests : IAsyncLifetime
{
private readonly SchedulerPostgresFixture _fixture;
private readonly SchedulerDataSource _dataSource;
private readonly TriggerRepository _repository;
private readonly string _tenantId = Guid.NewGuid().ToString();
@@ -21,8 +22,8 @@ public sealed class TriggerRepositoryTests : IAsyncLifetime
var options = fixture.Fixture.CreateOptions();
options.SchemaName = fixture.SchemaName;
var dataSource = new SchedulerDataSource(Options.Create(options), NullLogger<SchedulerDataSource>.Instance);
_repository = new TriggerRepository(dataSource, NullLogger<TriggerRepository>.Instance);
_dataSource = new SchedulerDataSource(Options.Create(options), NullLogger<SchedulerDataSource>.Instance);
_repository = new TriggerRepository(_dataSource, NullLogger<TriggerRepository>.Instance);
}
public ValueTask InitializeAsync() => new(_fixture.TruncateAllTablesAsync());
@@ -141,6 +142,17 @@ public sealed class TriggerRepositoryTests : IAsyncLifetime
var trigger = CreateTrigger("fire-test", "* * * * *");
await _repository.CreateAsync(trigger);
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);
// Act

View 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.

View 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). |