old sprints work, new sprints for exposing functionality via cli, improve code_of_conduct and other agents instructions

This commit is contained in:
master
2026-01-15 18:37:59 +02:00
parent c631bacee2
commit 88a85cdd92
208 changed files with 32271 additions and 2287 deletions

View File

@@ -138,6 +138,7 @@ public sealed record ApplyRemediationRequest
/// <summary>
/// API response for PR creation.
/// Sprint: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-003)
/// </summary>
public sealed record PullRequestApiResponse
{
@@ -147,6 +148,10 @@ public sealed record PullRequestApiResponse
public required string BranchName { get; init; }
public required string Status { get; init; }
public string? StatusMessage { get; init; }
/// <summary>
/// PR body/description content for reference.
/// </summary>
public string? PrBody { get; init; }
public BuildResultResponse? BuildResult { get; init; }
public TestResultResponse? TestResult { get; init; }
public DeltaVerdictResponse? DeltaVerdict { get; init; }
@@ -163,6 +168,7 @@ public sealed record PullRequestApiResponse
BranchName = result.BranchName,
Status = result.Status.ToString(),
StatusMessage = result.StatusMessage,
PrBody = result.PrBody,
BuildResult = result.BuildResult != null ? new BuildResultResponse
{
Success = result.BuildResult.Success,

View File

@@ -1,26 +1,33 @@
using System.Globalization;
using StellaOps.AdvisoryAI.Remediation.ScmConnector;
namespace StellaOps.AdvisoryAI.Remediation;
/// <summary>
/// GitHub implementation of pull request generator.
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot
/// Task: REMEDY-09
/// Sprint: SPRINT_20251226_016_AI_remedy_autopilot (REMEDY-09)
/// Updated: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-002)
/// </summary>
public sealed class GitHubPullRequestGenerator : IPullRequestGenerator
{
private readonly IRemediationPlanStore _planStore;
private readonly IScmConnector? _scmConnector;
private readonly PrTemplateBuilder _templateBuilder;
private readonly TimeProvider _timeProvider;
private readonly Func<Guid> _guidFactory;
private readonly Func<int, int, int> _randomFactory;
public GitHubPullRequestGenerator(
IRemediationPlanStore planStore,
IScmConnector? scmConnector = null,
PrTemplateBuilder? templateBuilder = null,
TimeProvider? timeProvider = null,
Func<Guid>? guidFactory = null,
Func<int, int, int>? randomFactory = null)
{
_planStore = planStore;
_scmConnector = scmConnector;
_templateBuilder = templateBuilder ?? new PrTemplateBuilder();
_timeProvider = timeProvider ?? TimeProvider.System;
_guidFactory = guidFactory ?? Guid.NewGuid;
_randomFactory = randomFactory ?? Random.Shared.Next;
@@ -33,6 +40,7 @@ public sealed class GitHubPullRequestGenerator : IPullRequestGenerator
CancellationToken cancellationToken = default)
{
var nowStr = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
// Validate plan is PR-ready
if (!plan.PrReady)
{
@@ -49,89 +57,254 @@ public sealed class GitHubPullRequestGenerator : IPullRequestGenerator
};
}
// Generate branch name
var branchName = GenerateBranchName(plan);
// Generate branch name and PR content using the template builder
var branchName = _templateBuilder.BuildBranchName(plan);
var prTitle = _templateBuilder.BuildPrTitle(plan);
var prBody = _templateBuilder.BuildPrBody(plan);
// In a real implementation, this would:
// 1. Create a new branch
// 2. Apply remediation steps (update files)
// 3. Commit changes
// 4. Create PR via GitHub API
// Extract owner/repo from URL
var (owner, repo) = ExtractOwnerRepo(plan.Request.RepositoryUrl);
var baseBranch = plan.Request.TargetBranch;
var prId = $"gh-pr-{_guidFactory():N}";
return new PullRequestResult
// If no SCM connector configured, return placeholder result
if (_scmConnector is null)
{
PrId = prId,
PrNumber = _randomFactory(1000, 9999), // Placeholder
Url = $"https://github.com/{ExtractOwnerRepo(plan.Request.RepositoryUrl)}/pull/{prId}",
BranchName = branchName,
Status = PullRequestStatus.Creating,
StatusMessage = "Pull request is being created",
CreatedAt = nowStr,
UpdatedAt = nowStr
};
var prId = $"gh-pr-{_guidFactory():N}";
return new PullRequestResult
{
PrId = prId,
PrNumber = 0,
Url = string.Empty,
BranchName = branchName,
Status = PullRequestStatus.Failed,
StatusMessage = "SCM connector not configured",
PrBody = prBody,
CreatedAt = nowStr,
UpdatedAt = nowStr
};
}
try
{
// Step 1: Create branch
var branchResult = await _scmConnector.CreateBranchAsync(
owner, repo, branchName, baseBranch, cancellationToken);
if (!branchResult.Success)
{
return new PullRequestResult
{
PrId = $"pr-{_guidFactory():N}",
PrNumber = 0,
Url = string.Empty,
BranchName = branchName,
Status = PullRequestStatus.Failed,
StatusMessage = branchResult.ErrorMessage ?? "Failed to create branch",
CreatedAt = nowStr,
UpdatedAt = nowStr
};
}
// Step 2: Apply remediation steps (update files)
foreach (var step in plan.Steps.OrderBy(s => s.Order))
{
if (string.IsNullOrEmpty(step.FilePath) || string.IsNullOrEmpty(step.NewValue))
continue;
var commitMessage = $"fix({plan.Request.VulnerabilityId}): {step.Description}";
var fileResult = await _scmConnector.UpdateFileAsync(
owner, repo, branchName, step.FilePath, step.NewValue, commitMessage, cancellationToken);
if (!fileResult.Success)
{
return new PullRequestResult
{
PrId = $"pr-{_guidFactory():N}",
PrNumber = 0,
Url = string.Empty,
BranchName = branchName,
Status = PullRequestStatus.Failed,
StatusMessage = $"Failed to update file {step.FilePath}: {fileResult.ErrorMessage}",
CreatedAt = nowStr,
UpdatedAt = nowStr
};
}
}
// Step 3: Create pull request
var prResult = await _scmConnector.CreatePullRequestAsync(
owner, repo, branchName, baseBranch, prTitle, prBody, cancellationToken);
if (!prResult.Success)
{
return new PullRequestResult
{
PrId = $"pr-{_guidFactory():N}",
PrNumber = 0,
Url = string.Empty,
BranchName = branchName,
Status = PullRequestStatus.Failed,
StatusMessage = prResult.ErrorMessage ?? "Failed to create PR",
PrBody = prBody,
CreatedAt = nowStr,
UpdatedAt = nowStr
};
}
// Success
var prId = $"gh-pr-{prResult.PrNumber}";
return new PullRequestResult
{
PrId = prId,
PrNumber = prResult.PrNumber,
Url = prResult.PrUrl ?? $"https://github.com/{owner}/{repo}/pull/{prResult.PrNumber}",
BranchName = branchName,
Status = PullRequestStatus.Open,
StatusMessage = "Pull request created successfully",
PrBody = prBody,
CreatedAt = nowStr,
UpdatedAt = nowStr
};
}
catch (Exception ex)
{
return new PullRequestResult
{
PrId = $"pr-{_guidFactory():N}",
PrNumber = 0,
Url = string.Empty,
BranchName = branchName,
Status = PullRequestStatus.Failed,
StatusMessage = $"Unexpected error: {ex.Message}",
CreatedAt = nowStr,
UpdatedAt = nowStr
};
}
}
public Task<PullRequestResult> GetStatusAsync(
public async Task<PullRequestResult> GetStatusAsync(
string prId,
CancellationToken cancellationToken = default)
{
// In a real implementation, this would query GitHub API
var now = _timeProvider.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);
return Task.FromResult(new PullRequestResult
// Extract PR number from prId (format: gh-pr-1234)
if (!int.TryParse(prId.Replace("gh-pr-", ""), out var prNumber))
{
return new PullRequestResult
{
PrId = prId,
PrNumber = 0,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Failed,
StatusMessage = $"Invalid PR ID format: {prId}",
CreatedAt = now,
UpdatedAt = now
};
}
if (_scmConnector is null)
{
return new PullRequestResult
{
PrId = prId,
PrNumber = prNumber,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Open,
StatusMessage = "Status check not available (no SCM connector)",
CreatedAt = now,
UpdatedAt = now
};
}
// Note: We would need the owner/repo from context to make the actual API call
// For now, return a placeholder
return new PullRequestResult
{
PrId = prId,
PrNumber = 0,
PrNumber = prNumber,
Url = string.Empty,
BranchName = string.Empty,
Status = PullRequestStatus.Open,
StatusMessage = "Waiting for CI",
CreatedAt = now,
UpdatedAt = now
});
};
}
public Task UpdateWithDeltaVerdictAsync(
public async Task UpdateWithDeltaVerdictAsync(
string prId,
DeltaVerdictResult deltaVerdict,
CancellationToken cancellationToken = default)
{
// In a real implementation, this would update PR description via GitHub API
return Task.CompletedTask;
if (_scmConnector is null)
return;
// Extract PR number from prId
if (!int.TryParse(prId.Replace("gh-pr-", ""), out var prNumber))
return;
// Build a comment with the delta verdict
var comment = BuildDeltaVerdictComment(deltaVerdict);
// Note: We would need owner/repo from context. Storing for later enhancement.
// For now, this is a placeholder for when context is available.
await Task.CompletedTask;
}
public Task ClosePullRequestAsync(
public async Task ClosePullRequestAsync(
string prId,
string reason,
CancellationToken cancellationToken = default)
{
// In a real implementation, this would close PR via GitHub API
return Task.CompletedTask;
if (_scmConnector is null)
return;
// Extract PR number from prId
if (!int.TryParse(prId.Replace("gh-pr-", ""), out var prNumber))
return;
// Note: We would need owner/repo from context to close the PR
await Task.CompletedTask;
}
private string GenerateBranchName(RemediationPlan plan)
private static string BuildDeltaVerdictComment(DeltaVerdictResult verdict)
{
var vulnId = plan.Request.VulnerabilityId.Replace(":", "-").ToLowerInvariant();
var timestamp = _timeProvider.GetUtcNow().ToString("yyyyMMdd", CultureInfo.InvariantCulture);
return $"stellaops/fix-{vulnId}-{timestamp}";
var lines = new System.Text.StringBuilder();
lines.AppendLine("## StellaOps Delta Verdict");
lines.AppendLine();
lines.AppendLine($"**Improved:** {verdict.Improved}");
lines.AppendLine($"**Vulnerabilities Fixed:** {verdict.VulnerabilitiesFixed}");
lines.AppendLine($"**Vulnerabilities Introduced:** {verdict.VulnerabilitiesIntroduced}");
lines.AppendLine($"**Verdict ID:** {verdict.VerdictId}");
lines.AppendLine($"**Computed At:** {verdict.ComputedAt}");
return lines.ToString();
}
private static string ExtractOwnerRepo(string? repositoryUrl)
private static (string owner, string repo) ExtractOwnerRepo(string? repositoryUrl)
{
if (string.IsNullOrEmpty(repositoryUrl))
{
return "owner/repo";
return ("owner", "repo");
}
// Extract owner/repo from GitHub URL
var uri = new Uri(repositoryUrl);
var path = uri.AbsolutePath.Trim('/');
if (path.EndsWith(".git"))
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
{
path = path[..^4];
}
return path;
var parts = path.Split('/');
if (parts.Length >= 2)
{
return (parts[0], parts[1]);
}
return ("owner", "repo");
}
}

View File

@@ -96,6 +96,12 @@ public sealed record PullRequestResult
/// </summary>
public string? StatusMessage { get; init; }
/// <summary>
/// PR body/description content.
/// Sprint: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-002)
/// </summary>
public string? PrBody { get; init; }
/// <summary>
/// Build result if available.
/// </summary>

View File

@@ -0,0 +1,336 @@
// <copyright file="GitHubPullRequestGeneratorTests.cs" company="StellaOps">
// SPDX-License-Identifier: AGPL-3.0-or-later
// Sprint: SPRINT_20260112_007_BE_remediation_pr_generator (REMEDY-BE-004)
// </copyright>
using System.Globalization;
using Moq;
using StellaOps.AdvisoryAI.Remediation;
using StellaOps.AdvisoryAI.Remediation.ScmConnector;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
/// <summary>
/// Tests for <see cref="GitHubPullRequestGenerator"/> covering SCM connector wiring and determinism.
/// </summary>
[Trait("Category", "Unit")]
public sealed class GitHubPullRequestGeneratorTests
{
private readonly Mock<IRemediationPlanStore> _mockPlanStore;
private readonly Mock<IScmConnector> _mockScmConnector;
private readonly FakeTimeProvider _timeProvider;
private readonly Func<Guid> _guidFactory;
private int _guidCounter;
public GitHubPullRequestGeneratorTests()
{
_mockPlanStore = new Mock<IRemediationPlanStore>();
_mockScmConnector = new Mock<IScmConnector>();
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 14, 12, 0, 0, TimeSpan.Zero));
_guidCounter = 0;
_guidFactory = () => new Guid(++_guidCounter, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
}
[Fact]
public async Task CreatePullRequestAsync_NotPrReady_ReturnsFailed()
{
// Arrange
var plan = CreateTestPlan(prReady: false, notReadyReason: "Missing repo URL");
var generator = CreateGenerator(withScmConnector: false);
// Act
var result = await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal(PullRequestStatus.Failed, result.Status);
Assert.Equal("Missing repo URL", result.StatusMessage);
}
[Fact]
public async Task CreatePullRequestAsync_NoScmConnector_ReturnsFailedWithBody()
{
// Arrange
var plan = CreateTestPlan(prReady: true);
var generator = CreateGenerator(withScmConnector: false);
// Act
var result = await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal(PullRequestStatus.Failed, result.Status);
Assert.Equal("SCM connector not configured", result.StatusMessage);
Assert.NotNull(result.PrBody);
Assert.Contains("Security Remediation", result.PrBody);
}
[Fact]
public async Task CreatePullRequestAsync_BranchCreationFails_ReturnsFailed()
{
// Arrange
var plan = CreateTestPlan(prReady: true);
_mockScmConnector.Setup(c => c.CreateBranchAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BranchResult { Success = false, BranchName = "test", ErrorMessage = "Branch exists" });
var generator = CreateGenerator(withScmConnector: true);
// Act
var result = await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal(PullRequestStatus.Failed, result.Status);
Assert.Equal("Branch exists", result.StatusMessage);
}
[Fact]
public async Task CreatePullRequestAsync_FileUpdateFails_ReturnsFailed()
{
// Arrange
var plan = CreateTestPlan(prReady: true, withSteps: true);
_mockScmConnector.Setup(c => c.CreateBranchAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BranchResult { Success = true, BranchName = "test", CommitSha = "abc123" });
_mockScmConnector.Setup(c => c.UpdateFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FileUpdateResult { Success = false, FilePath = "package.json", ErrorMessage = "Permission denied" });
var generator = CreateGenerator(withScmConnector: true);
// Act
var result = await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal(PullRequestStatus.Failed, result.Status);
Assert.Contains("package.json", result.StatusMessage);
Assert.Contains("Permission denied", result.StatusMessage);
}
[Fact]
public async Task CreatePullRequestAsync_PrCreationFails_ReturnsFailed()
{
// Arrange
var plan = CreateTestPlan(prReady: true);
SetupSuccessfulBranchAndFile();
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PrCreateResult { Success = false, PrNumber = 0, PrUrl = string.Empty, ErrorMessage = "Rate limited" });
var generator = CreateGenerator(withScmConnector: true);
// Act
var result = await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal(PullRequestStatus.Failed, result.Status);
Assert.Equal("Rate limited", result.StatusMessage);
}
[Fact]
public async Task CreatePullRequestAsync_Success_ReturnsOpenWithPrBody()
{
// Arrange
var plan = CreateTestPlan(prReady: true);
SetupSuccessfulBranchAndFile();
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PrCreateResult { Success = true, PrNumber = 42, PrUrl = "https://github.com/owner/repo/pull/42" });
var generator = CreateGenerator(withScmConnector: true);
// Act
var result = await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal(PullRequestStatus.Open, result.Status);
Assert.Equal("Pull request created successfully", result.StatusMessage);
Assert.Equal(42, result.PrNumber);
Assert.Equal("gh-pr-42", result.PrId);
Assert.Equal("https://github.com/owner/repo/pull/42", result.Url);
Assert.NotNull(result.PrBody);
}
[Fact]
public async Task CreatePullRequestAsync_UsesPrTemplateBuilder_Deterministically()
{
// Arrange
var plan = CreateTestPlan(prReady: true);
SetupSuccessfulBranchAndFile();
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new PrCreateResult { Success = true, PrNumber = 1, PrUrl = "https://github.com/o/r/pull/1" });
var generator = CreateGenerator(withScmConnector: true);
// Act
var result1 = await generator.CreatePullRequestAsync(plan);
var result2 = await generator.CreatePullRequestAsync(plan);
// Assert - PR bodies should be identical for the same plan
Assert.Equal(result1.PrBody, result2.PrBody);
}
[Fact]
public async Task CreatePullRequestAsync_CallsScmConnectorInOrder()
{
// Arrange
var plan = CreateTestPlan(prReady: true, withSteps: true);
var callOrder = new List<string>();
_mockScmConnector.Setup(c => c.CreateBranchAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => callOrder.Add("CreateBranch"))
.ReturnsAsync(new BranchResult { Success = true, BranchName = "test", CommitSha = "abc" });
_mockScmConnector.Setup(c => c.UpdateFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => callOrder.Add("UpdateFile"))
.ReturnsAsync(new FileUpdateResult { Success = true, FilePath = "test", CommitSha = "def" });
_mockScmConnector.Setup(c => c.CreatePullRequestAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.Callback(() => callOrder.Add("CreatePR"))
.ReturnsAsync(new PrCreateResult { Success = true, PrNumber = 1, PrUrl = "" });
var generator = CreateGenerator(withScmConnector: true);
// Act
await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal(["CreateBranch", "UpdateFile", "CreatePR"], callOrder);
}
[Fact]
public async Task CreatePullRequestAsync_TimestampsAreDeterministic()
{
// Arrange
var plan = CreateTestPlan(prReady: true);
var generator = CreateGenerator(withScmConnector: false);
// Act
var result = await generator.CreatePullRequestAsync(plan);
// Assert
Assert.Equal("2026-01-14T12:00:00.0000000+00:00", result.CreatedAt);
Assert.Equal("2026-01-14T12:00:00.0000000+00:00", result.UpdatedAt);
}
[Fact]
public async Task GetStatusAsync_InvalidPrIdFormat_ReturnsFailed()
{
// Arrange
var generator = CreateGenerator(withScmConnector: false);
// Act
var result = await generator.GetStatusAsync("invalid-pr-id");
// Assert
Assert.Equal(PullRequestStatus.Failed, result.Status);
Assert.Contains("Invalid PR ID format", result.StatusMessage);
}
[Fact]
public async Task GetStatusAsync_NoScmConnector_ReturnsOpenWithPlaceholder()
{
// Arrange
var generator = CreateGenerator(withScmConnector: false);
// Act
var result = await generator.GetStatusAsync("gh-pr-123");
// Assert
Assert.Equal(PullRequestStatus.Open, result.Status);
Assert.Equal(123, result.PrNumber);
Assert.Contains("no SCM connector", result.StatusMessage);
}
private GitHubPullRequestGenerator CreateGenerator(bool withScmConnector)
{
return new GitHubPullRequestGenerator(
_mockPlanStore.Object,
withScmConnector ? _mockScmConnector.Object : null,
new PrTemplateBuilder(),
_timeProvider,
_guidFactory);
}
private void SetupSuccessfulBranchAndFile()
{
_mockScmConnector.Setup(c => c.CreateBranchAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new BranchResult { Success = true, BranchName = "test", CommitSha = "abc123" });
_mockScmConnector.Setup(c => c.UpdateFileAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new FileUpdateResult { Success = true, FilePath = "test", CommitSha = "def456" });
}
private static RemediationPlan CreateTestPlan(
bool prReady = true,
string? notReadyReason = null,
bool withSteps = false)
{
var steps = new List<RemediationStep>();
if (withSteps)
{
steps.Add(new RemediationStep
{
Order = 1,
ActionType = "update_package",
FilePath = "package.json",
Description = "Update lodash to 4.17.21",
NewValue = "{ \"dependencies\": { \"lodash\": \"4.17.21\" } }"
});
}
return new RemediationPlan
{
PlanId = "plan-test-001",
GeneratedAt = "2026-01-14T10:00:00Z",
Authority = RemediationAuthority.Suggestion,
RiskAssessment = RemediationRisk.Low,
ConfidenceScore = 0.85,
PrReady = prReady,
NotReadyReason = notReadyReason,
ModelId = "test-model-v1",
Steps = steps,
InputHashes = ["sha256:input1", "sha256:input2"],
EvidenceRefs = ["evidence:ref1"],
TestRequirements = new RemediationTestRequirements
{
TestSuites = ["unit", "integration"],
MinCoverage = 0.80,
RequireAllPass = true
},
ExpectedDelta = new ExpectedSbomDelta
{
Added = Array.Empty<string>(),
Removed = Array.Empty<string>(),
Upgraded = new Dictionary<string, string>
{
["pkg:npm/lodash@4.17.20"] = "pkg:npm/lodash@4.17.21"
},
NetVulnerabilityChange = -1
},
Request = new RemediationPlanRequest
{
FindingId = "FIND-001",
ArtifactDigest = "sha256:abc",
VulnerabilityId = "CVE-2024-1234",
ComponentPurl = "pkg:npm/lodash@4.17.20",
RepositoryUrl = "https://github.com/owner/repo",
TargetBranch = "main"
}
};
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FakeTimeProvider(DateTimeOffset fixedTime)
{
_fixedTime = fixedTime;
}
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
}

View File

@@ -0,0 +1,358 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260112_005_BE_evidence_card_api (EVPCARD-BE-003)
// Task: Integration tests for evidence-card export content type and signed payload
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using StellaOps.Determinism;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
using StellaOps.Evidence.Pack.Storage;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests.Integration;
/// <summary>
/// Integration tests for evidence-card export functionality.
/// </summary>
[Trait("Category", "Integration")]
public class EvidenceCardExportIntegrationTests
{
private static readonly DateTimeOffset FixedTime = new(2026, 1, 14, 12, 0, 0, TimeSpan.Zero);
private static readonly Guid FixedGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc");
[Fact]
public async Task ExportAsync_EvidenceCard_ReturnsCorrectContentType()
{
// Arrange
var services = CreateServiceProvider();
var packService = services.GetRequiredService<IEvidencePackService>();
var pack = await CreateTestEvidencePack(packService);
// Act
var export = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCard,
CancellationToken.None);
// Assert
Assert.Equal("application/vnd.stellaops.evidence-card+json", export.ContentType);
Assert.EndsWith(".evidence-card.json", export.FileName);
}
[Fact]
public async Task ExportAsync_EvidenceCardCompact_ReturnsCompactContentType()
{
// Arrange
var services = CreateServiceProvider();
var packService = services.GetRequiredService<IEvidencePackService>();
var pack = await CreateTestEvidencePack(packService);
// Act
var export = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCardCompact,
CancellationToken.None);
// Assert
Assert.Equal("application/vnd.stellaops.evidence-card-compact+json", export.ContentType);
Assert.EndsWith(".evidence-card-compact.json", export.FileName);
}
[Fact]
public async Task ExportAsync_EvidenceCard_ContainsRequiredFields()
{
// Arrange
var services = CreateServiceProvider();
var packService = services.GetRequiredService<IEvidencePackService>();
var pack = await CreateTestEvidencePack(packService);
// Act
var export = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCard,
CancellationToken.None);
// Assert
var json = System.Text.Encoding.UTF8.GetString(export.Content);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("cardId", out _), "Missing cardId");
Assert.True(root.TryGetProperty("version", out _), "Missing version");
Assert.True(root.TryGetProperty("packId", out _), "Missing packId");
Assert.True(root.TryGetProperty("createdAt", out _), "Missing createdAt");
Assert.True(root.TryGetProperty("subject", out _), "Missing subject");
Assert.True(root.TryGetProperty("contentDigest", out _), "Missing contentDigest");
}
[Fact]
public async Task ExportAsync_EvidenceCard_ContainsSubjectMetadata()
{
// Arrange
var services = CreateServiceProvider();
var packService = services.GetRequiredService<IEvidencePackService>();
var pack = await CreateTestEvidencePack(packService);
// Act
var export = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCard,
CancellationToken.None);
// Assert
var json = System.Text.Encoding.UTF8.GetString(export.Content);
using var doc = JsonDocument.Parse(json);
var subject = doc.RootElement.GetProperty("subject");
Assert.True(subject.TryGetProperty("type", out var typeElement));
Assert.Equal("finding", typeElement.GetString());
Assert.True(subject.TryGetProperty("findingId", out var findingIdElement));
Assert.Equal("FIND-001", findingIdElement.GetString());
Assert.True(subject.TryGetProperty("cveId", out var cveIdElement));
Assert.Equal("CVE-2024-1234", cveIdElement.GetString());
}
[Fact]
public async Task ExportAsync_EvidenceCard_ContentDigestIsDeterministic()
{
// Arrange
var services = CreateServiceProvider();
var packService = services.GetRequiredService<IEvidencePackService>();
var pack = await CreateTestEvidencePack(packService);
// Act
var export1 = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCard,
CancellationToken.None);
var export2 = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCard,
CancellationToken.None);
// Assert - same input should produce same digest
var json1 = System.Text.Encoding.UTF8.GetString(export1.Content);
var json2 = System.Text.Encoding.UTF8.GetString(export2.Content);
using var doc1 = JsonDocument.Parse(json1);
using var doc2 = JsonDocument.Parse(json2);
var digest1 = doc1.RootElement.GetProperty("contentDigest").GetString();
var digest2 = doc2.RootElement.GetProperty("contentDigest").GetString();
Assert.Equal(digest1, digest2);
Assert.StartsWith("sha256:", digest1);
}
[Fact]
public async Task ExportAsync_EvidenceCard_IncludesSbomExcerptWhenAvailable()
{
// Arrange
var services = CreateServiceProvider();
var packService = services.GetRequiredService<IEvidencePackService>();
var pack = await CreateTestEvidencePackWithSbom(packService);
// Act
var export = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCard,
CancellationToken.None);
// Assert
var json = System.Text.Encoding.UTF8.GetString(export.Content);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("sbomExcerpt", out var sbomExcerpt))
{
Assert.True(sbomExcerpt.TryGetProperty("componentPurl", out _));
}
// Note: sbomExcerpt may be null if not available
}
[Fact]
public async Task ExportAsync_EvidenceCardCompact_ExcludesFullSbom()
{
// Arrange
var services = CreateServiceProvider();
var packService = services.GetRequiredService<IEvidencePackService>();
var pack = await CreateTestEvidencePackWithSbom(packService);
// Act
var fullExport = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCard,
CancellationToken.None);
var compactExport = await packService.ExportAsync(
pack.PackId,
EvidencePackExportFormat.EvidenceCardCompact,
CancellationToken.None);
// Assert - compact should be smaller or equal
Assert.True(compactExport.Content.Length <= fullExport.Content.Length,
"Compact export should be smaller or equal to full export");
}
private static ServiceProvider CreateServiceProvider()
{
var services = new ServiceCollection();
// Add deterministic time and guid providers
var timeProvider = new FakeTimeProvider(FixedTime);
var guidProvider = new FakeGuidProvider(FixedGuid);
services.AddSingleton<TimeProvider>(timeProvider);
services.AddSingleton<IGuidProvider>(guidProvider);
// Add evidence pack services
services.AddSingleton<IEvidencePackStore, InMemoryEvidencePackStore>();
services.AddEvidencePack();
// Mock signer
var signerMock = new Mock<IEvidencePackSigner>();
signerMock.Setup(s => s.SignAsync(It.IsAny<EvidencePack>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new DsseEnvelope
{
PayloadType = "application/vnd.stellaops.evidence-pack+json",
Payload = "e30=", // Base64 for "{}"
PayloadDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
Signatures = ImmutableArray<DsseSignature>.Empty
});
services.AddSingleton(signerMock.Object);
return services.BuildServiceProvider();
}
private static async Task<EvidencePack> CreateTestEvidencePack(IEvidencePackService packService)
{
var subject = new EvidenceSubject
{
Type = EvidenceSubjectType.Finding,
FindingId = "FIND-001",
CveId = "CVE-2024-1234",
Component = "pkg:npm/lodash@4.17.20"
};
var claims = new[]
{
new EvidenceClaim
{
ClaimId = "claim-001",
Text = "Vulnerability is not reachable in this deployment",
Type = ClaimType.Reachability,
Status = "not_affected",
Confidence = 0.85,
EvidenceIds = ImmutableArray.Create("ev-001"),
Source = "system"
}
};
var evidence = new[]
{
new EvidenceItem
{
EvidenceId = "ev-001",
Type = EvidenceType.Reachability,
Uri = "stellaops://reachability/FIND-001",
Digest = "sha256:abc123",
CollectedAt = FixedTime.AddHours(-1),
Snapshot = EvidenceSnapshot.Reachability("Unreachable", confidence: 0.85)
}
};
var context = new EvidencePackContext
{
TenantId = "test-tenant",
GeneratedBy = "EvidenceCardExportIntegrationTests"
};
return await packService.CreateAsync(claims, evidence, subject, context, CancellationToken.None);
}
private static async Task<EvidencePack> CreateTestEvidencePackWithSbom(IEvidencePackService packService)
{
var subject = new EvidenceSubject
{
Type = EvidenceSubjectType.Finding,
FindingId = "FIND-002",
CveId = "CVE-2024-5678",
Component = "pkg:npm/express@4.18.2"
};
var claims = new[]
{
new EvidenceClaim
{
ClaimId = "claim-002",
Text = "Fixed version available",
Type = ClaimType.FixAvailability,
Status = "fixed",
Confidence = 0.95,
EvidenceIds = ImmutableArray.Create("ev-sbom-001"),
Source = "system"
}
};
var evidence = new[]
{
new EvidenceItem
{
EvidenceId = "ev-sbom-001",
Type = EvidenceType.Sbom,
Uri = "stellaops://sbom/image-abc123",
Digest = "sha256:def456",
CollectedAt = FixedTime.AddHours(-2),
Snapshot = EvidenceSnapshot.Sbom(
"spdx",
"2.3",
componentCount: 150,
imageDigest: "sha256:abc123")
}
};
var context = new EvidencePackContext
{
TenantId = "test-tenant",
GeneratedBy = "EvidenceCardExportIntegrationTests"
};
return await packService.CreateAsync(claims, evidence, subject, context, CancellationToken.None);
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _fixedTime;
public FakeTimeProvider(DateTimeOffset fixedTime) => _fixedTime = fixedTime;
public override DateTimeOffset GetUtcNow() => _fixedTime;
}
private sealed class FakeGuidProvider : IGuidProvider
{
private readonly Guid _fixedGuid;
private int _counter;
public FakeGuidProvider(Guid fixedGuid) => _fixedGuid = fixedGuid;
public Guid NewGuid()
{
// Return deterministic GUIDs for each call
var bytes = _fixedGuid.ToByteArray();
bytes[^1] = (byte)Interlocked.Increment(ref _counter);
return new Guid(bytes);
}
}
}