303 lines
12 KiB
C#
303 lines
12 KiB
C#
// <copyright file="FacetQuotaVexWorkflowE2ETests.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
|
// </copyright>
|
|
// Sprint: SPRINT_20260105_002_003_FACET (QTA-024)
|
|
// Description: E2E test: Quota breach -> VEX draft -> approval workflow
|
|
|
|
using System.Collections.Immutable;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Facet.Tests;
|
|
|
|
/// <summary>
|
|
/// End-to-end tests for the facet quota breach to VEX approval workflow.
|
|
/// Tests the complete pipeline: quota breach detection -> VEX draft emission -> draft approval.
|
|
/// </summary>
|
|
[Trait("Category", "E2E")]
|
|
public sealed class FacetQuotaVexWorkflowE2ETests
|
|
{
|
|
private readonly FakeTimeProvider _timeProvider;
|
|
private readonly InMemoryFacetDriftVexDraftStore _draftStore;
|
|
private readonly FacetDriftVexEmitter _vexEmitter;
|
|
private readonly FacetDriftVexWorkflow _workflow;
|
|
|
|
public FacetQuotaVexWorkflowE2ETests()
|
|
{
|
|
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 7, 12, 0, 0, TimeSpan.Zero));
|
|
_draftStore = new InMemoryFacetDriftVexDraftStore(_timeProvider);
|
|
_vexEmitter = new FacetDriftVexEmitter(FacetDriftVexEmitterOptions.Default, _timeProvider);
|
|
_workflow = new FacetDriftVexWorkflow(_vexEmitter, _draftStore);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_QuotaBreach_GeneratesVexDraft_CanBeApproved()
|
|
{
|
|
// Arrange - Create a drift report with quota breach requiring VEX
|
|
var driftReport = CreateQuotaBreachingDriftReport(
|
|
imageDigest: "sha256:e2e-test-image",
|
|
facetId: "os-packages-dpkg",
|
|
churnPercent: 35m);
|
|
|
|
// Act - Step 1: Process drift and emit VEX drafts
|
|
var workflowResult = await _workflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
|
|
// Assert - Step 1: VEX draft was created
|
|
Assert.True(workflowResult.Success);
|
|
Assert.Equal(1, workflowResult.NewDraftsCreated);
|
|
Assert.Single(workflowResult.CreatedDraftIds);
|
|
var draftId = workflowResult.CreatedDraftIds[0];
|
|
|
|
// Verify draft was stored
|
|
var storedDraft = await _draftStore.FindByIdAsync(draftId, CancellationToken.None);
|
|
Assert.NotNull(storedDraft);
|
|
Assert.True(storedDraft.RequiresReview);
|
|
|
|
// Verify draft has pending status
|
|
var allDrafts = _draftStore.GetAllForTesting();
|
|
var draftWithReview = allDrafts.First(d => d.Draft.DraftId == draftId);
|
|
Assert.Equal(FacetDriftVexReviewStatus.Pending, draftWithReview.ReviewStatus);
|
|
|
|
// Act - Step 2: Approve the draft
|
|
var approvalResult = await _workflow.ApproveAsync(
|
|
draftId,
|
|
reviewedBy: "security-team@example.com",
|
|
notes: "Reviewed file changes, approved as authorized security patch",
|
|
CancellationToken.None);
|
|
|
|
// Assert - Step 2: Draft was approved
|
|
Assert.True(approvalResult);
|
|
var approvedDraftWrapper = _draftStore.GetAllForTesting().First(d => d.Draft.DraftId == draftId);
|
|
Assert.Equal(FacetDriftVexReviewStatus.Approved, approvedDraftWrapper.ReviewStatus);
|
|
Assert.Equal("security-team@example.com", approvedDraftWrapper.ReviewedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_MultipleFacetBreaches_AllGenerateDrafts()
|
|
{
|
|
// Arrange - Multiple facets exceeding quotas
|
|
var facetDrifts = new[]
|
|
{
|
|
CreateFacetDrift("os-packages-dpkg", QuotaVerdict.RequiresVex, 25m),
|
|
CreateFacetDrift("lang-deps-npm", QuotaVerdict.RequiresVex, 30m),
|
|
CreateFacetDrift("binaries-usr", QuotaVerdict.Ok, 2m) // OK, should not generate draft
|
|
};
|
|
|
|
var driftReport = new FacetDriftReport
|
|
{
|
|
ImageDigest = "sha256:multi-facet-test",
|
|
BaselineSealId = "seal-multi-001",
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = [.. facetDrifts],
|
|
OverallVerdict = QuotaVerdict.RequiresVex
|
|
};
|
|
|
|
// Act
|
|
var result = await _workflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.True(result.Success);
|
|
Assert.Equal(2, result.NewDraftsCreated);
|
|
Assert.Equal(2, result.CreatedDraftIds.Length);
|
|
|
|
// Verify correct facets generated drafts
|
|
var allDrafts = _draftStore.GetAllForTesting();
|
|
Assert.Contains(allDrafts, d => d.Draft.FacetId == "os-packages-dpkg");
|
|
Assert.Contains(allDrafts, d => d.Draft.FacetId == "lang-deps-npm");
|
|
Assert.DoesNotContain(allDrafts, d => d.Draft.FacetId == "binaries-usr");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_DraftRejection_MarksAsRejected()
|
|
{
|
|
// Arrange
|
|
var driftReport = CreateQuotaBreachingDriftReport(
|
|
imageDigest: "sha256:rejection-test",
|
|
facetId: "suspicious-facet",
|
|
churnPercent: 80m);
|
|
|
|
var workflowResult = await _workflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
var draftId = workflowResult.CreatedDraftIds[0];
|
|
|
|
// Act - Reject the draft
|
|
var result = await _workflow.RejectAsync(
|
|
draftId,
|
|
reviewedBy: "security-team@example.com",
|
|
reason: "Unauthorized change detected - requires investigation",
|
|
CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.True(result);
|
|
var rejectedDraft = _draftStore.GetAllForTesting().First(d => d.Draft.DraftId == draftId);
|
|
Assert.Equal(FacetDriftVexReviewStatus.Rejected, rejectedDraft.ReviewStatus);
|
|
Assert.Equal("security-team@example.com", rejectedDraft.ReviewedBy);
|
|
Assert.Contains("Unauthorized", rejectedDraft.ReviewNotes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_DraftExpiration_AfterTtl()
|
|
{
|
|
// Arrange - Use shorter TTL for testing
|
|
var shortTtlOptions = new FacetDriftVexEmitterOptions { DraftTtl = TimeSpan.FromDays(3) };
|
|
var shortTtlEmitter = new FacetDriftVexEmitter(shortTtlOptions, _timeProvider);
|
|
var shortTtlWorkflow = new FacetDriftVexWorkflow(shortTtlEmitter, _draftStore);
|
|
|
|
var driftReport = CreateQuotaBreachingDriftReport(
|
|
imageDigest: "sha256:expiry-test-short-ttl",
|
|
facetId: "test-facet",
|
|
churnPercent: 20m);
|
|
|
|
var workflowResult = await shortTtlWorkflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
var draftId = workflowResult.CreatedDraftIds[0];
|
|
|
|
// Get the draft's expiration time
|
|
var draft = await _draftStore.FindByIdAsync(draftId, CancellationToken.None);
|
|
Assert.NotNull(draft);
|
|
var expiresAt = draft.ExpiresAt;
|
|
|
|
// Act - Advance time past TTL (3 days + 1 day buffer)
|
|
_timeProvider.Advance(TimeSpan.FromDays(4));
|
|
|
|
// Assert - Draft's ExpiresAt should be before current time
|
|
var now = _timeProvider.GetUtcNow();
|
|
Assert.True(expiresAt < now, $"Draft should be expired: ExpiresAt={expiresAt}, Now={now}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_OverdueDrafts_CanBeQueried()
|
|
{
|
|
// Arrange - Use short review SLA for testing
|
|
var shortSlaOptions = new FacetDriftVexEmitterOptions { ReviewSlaDays = 2 };
|
|
var shortSlaEmitter = new FacetDriftVexEmitter(shortSlaOptions, _timeProvider);
|
|
var shortSlaWorkflow = new FacetDriftVexWorkflow(shortSlaEmitter, _draftStore);
|
|
|
|
var driftReport = CreateQuotaBreachingDriftReport(
|
|
imageDigest: "sha256:overdue-test-short-sla",
|
|
facetId: "overdue-facet",
|
|
churnPercent: 25m);
|
|
|
|
await shortSlaWorkflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
|
|
// Act - Advance time past review deadline (2 days + 1 day buffer)
|
|
_timeProvider.Advance(TimeSpan.FromDays(3));
|
|
|
|
// Query using the store directly with advanced time
|
|
var asOf = _timeProvider.GetUtcNow();
|
|
var overdueDrafts = await _draftStore.GetOverdueAsync(asOf, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.Single(overdueDrafts);
|
|
Assert.Equal("sha256:overdue-test-short-sla", overdueDrafts[0].ImageDigest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_DraftContainsAuditTrail()
|
|
{
|
|
// Arrange
|
|
var driftReport = CreateQuotaBreachingDriftReport(
|
|
imageDigest: "sha256:audit-test",
|
|
facetId: "audited-facet",
|
|
churnPercent: 25m);
|
|
|
|
// Act
|
|
var workflowResult = await _workflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
var draftId = workflowResult.CreatedDraftIds[0];
|
|
var draft = await _draftStore.FindByIdAsync(draftId, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.NotNull(draft);
|
|
Assert.Equal("sha256:audit-test", draft.ImageDigest);
|
|
Assert.NotNull(draft.DriftSummary);
|
|
Assert.Equal(25m, draft.DriftSummary.ChurnPercent);
|
|
Assert.NotEmpty(draft.EvidenceLinks);
|
|
Assert.Contains(draft.EvidenceLinks, l => l.Type == "facet_drift_analysis");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_PendingDraftsCanBeQueried()
|
|
{
|
|
// Arrange - Create multiple drafts
|
|
var driftReport1 = CreateQuotaBreachingDriftReport("sha256:pending-test-image-001", "facet-1", 25m);
|
|
var driftReport2 = CreateQuotaBreachingDriftReport("sha256:pending-test-image-002", "facet-2", 30m);
|
|
|
|
await _workflow.ExecuteAsync(driftReport1, skipExisting: true, CancellationToken.None);
|
|
await _workflow.ExecuteAsync(driftReport2, skipExisting: true, CancellationToken.None);
|
|
|
|
// Act
|
|
var pendingDrafts = await _workflow.GetPendingDraftsAsync(ct: CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.Equal(2, pendingDrafts.Length);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task E2E_SkipExistingDrafts_PreventsDuplicates()
|
|
{
|
|
// Arrange
|
|
var driftReport = CreateQuotaBreachingDriftReport(
|
|
imageDigest: "sha256:duplicate-test",
|
|
facetId: "test-facet",
|
|
churnPercent: 20m);
|
|
|
|
// Act - Execute workflow twice
|
|
var result1 = await _workflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
var result2 = await _workflow.ExecuteAsync(driftReport, skipExisting: true, CancellationToken.None);
|
|
|
|
// Assert
|
|
Assert.Equal(1, result1.NewDraftsCreated);
|
|
Assert.Equal(0, result2.NewDraftsCreated);
|
|
Assert.Equal(1, result2.ExistingDraftsSkipped);
|
|
}
|
|
|
|
#region Helper Methods
|
|
|
|
private FacetDriftReport CreateQuotaBreachingDriftReport(string imageDigest, string facetId, decimal churnPercent)
|
|
{
|
|
var addedCount = (int)churnPercent;
|
|
var addedFiles = Enumerable.Range(0, addedCount)
|
|
.Select(i => new FacetFileEntry($"/pkg/added{i}.deb", $"sha256:added{i}", 1024, null))
|
|
.ToImmutableArray();
|
|
|
|
var facetDrift = new FacetDrift
|
|
{
|
|
FacetId = facetId,
|
|
Added = addedFiles,
|
|
Removed = [],
|
|
Modified = [],
|
|
DriftScore = churnPercent,
|
|
QuotaVerdict = QuotaVerdict.RequiresVex,
|
|
BaselineFileCount = 100
|
|
};
|
|
|
|
return new FacetDriftReport
|
|
{
|
|
ImageDigest = imageDigest,
|
|
BaselineSealId = $"seal-{imageDigest.Substring(7, 8)}",
|
|
AnalyzedAt = _timeProvider.GetUtcNow(),
|
|
FacetDrifts = [facetDrift],
|
|
OverallVerdict = QuotaVerdict.RequiresVex
|
|
};
|
|
}
|
|
|
|
private static FacetDrift CreateFacetDrift(string facetId, QuotaVerdict verdict, decimal churnPercent)
|
|
{
|
|
var addedCount = (int)churnPercent;
|
|
var addedFiles = Enumerable.Range(0, addedCount)
|
|
.Select(i => new FacetFileEntry($"/{facetId}/file{i}", $"sha256:{facetId}{i}", 100, null))
|
|
.ToImmutableArray();
|
|
|
|
return new FacetDrift
|
|
{
|
|
FacetId = facetId,
|
|
Added = addedFiles,
|
|
Removed = [],
|
|
Modified = [],
|
|
DriftScore = churnPercent,
|
|
QuotaVerdict = verdict,
|
|
BaselineFileCount = 100
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|