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