Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
This commit is contained in:
@@ -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)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
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