audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration
This commit is contained in:
@@ -202,7 +202,8 @@ public sealed class ExportScopeResolver : IExportScopeResolver
|
||||
}
|
||||
|
||||
// Warn about large exports
|
||||
if (!scope.MaxItems.HasValue && scope.Sampling?.Strategy == SamplingStrategy.None)
|
||||
if (!scope.MaxItems.HasValue &&
|
||||
(scope.Sampling is null || scope.Sampling.Strategy == SamplingStrategy.None))
|
||||
{
|
||||
errors.Add(new ExportValidationError
|
||||
{
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0333-M | DONE | Revalidated 2026-01-07; maintainability audit for ExportCenter.Core. |
|
||||
| AUDIT-0333-T | DONE | Revalidated 2026-01-07; test coverage audit for ExportCenter.Core. |
|
||||
| AUDIT-0333-A | TODO | Pending approval (non-test project; revalidated 2026-01-07). |
|
||||
| AUDIT-0333-A | DONE | Applied 2026-01-13; determinism verified, tests added for LineageEvidencePackService/ExportPlanner/ExportScopeResolver, large export warning fix. |
|
||||
|
||||
@@ -254,6 +254,106 @@ public sealed class ExportPlannerTests
|
||||
Assert.Equal("test-user", result.Plan.InitiatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithInvalidScopeJson_FallsBackToDefaultScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Invalid Scope Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
ScopeJson = """{ this is not valid json }"""
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
// Should succeed with default scope (fallback behavior)
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
Assert.NotNull(result.Plan.ResolvedScope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithInvalidFormatJson_FallsBackToDefaultFormat()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Invalid Format Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
FormatJson = """{ invalid: json: here }"""
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
// Should succeed with default format (fallback behavior)
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
Assert.NotNull(result.Plan.Format);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithNullScopeJson_UsesDefaultScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Null Scope Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
ScopeJson = null
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreatePlanAsync_WithEmptyScopeJson_UsesDefaultScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var profile = await _profileRepository.CreateAsync(new ExportProfile
|
||||
{
|
||||
ProfileId = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = "Empty Scope Profile",
|
||||
Kind = ExportProfileKind.AdHoc,
|
||||
Status = ExportProfileStatus.Active,
|
||||
ScopeJson = ""
|
||||
});
|
||||
|
||||
var result = await _planner.CreatePlanAsync(new ExportPlanRequest
|
||||
{
|
||||
ProfileId = profile.ProfileId,
|
||||
TenantId = tenantId
|
||||
});
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Plan);
|
||||
}
|
||||
|
||||
private async Task<ExportProfile> CreateTestProfile(Guid tenantId)
|
||||
{
|
||||
return await _profileRepository.CreateAsync(new ExportProfile
|
||||
|
||||
@@ -218,4 +218,118 @@ public sealed class ExportScopeResolverTests
|
||||
Assert.True(estimate.EstimatedSizeBytes > 0);
|
||||
Assert.True(estimate.EstimatedProcessingTime > TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_WithNullSeed_UsesDeterministicSeedFromScope()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
Sampling = new SamplingConfig
|
||||
{
|
||||
Strategy = SamplingStrategy.Random,
|
||||
Size = 10,
|
||||
Seed = null // null seed should be derived deterministically
|
||||
},
|
||||
TargetKinds = ["sbom"]
|
||||
};
|
||||
|
||||
var result1 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
var result2 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.NotNull(result1.SamplingMetadata);
|
||||
Assert.NotNull(result2.SamplingMetadata);
|
||||
Assert.Equal(result1.SamplingMetadata.Seed, result2.SamplingMetadata.Seed);
|
||||
Assert.Equal(result1.Items.Count, result2.Items.Count);
|
||||
|
||||
// Same items in same order due to deterministic seeding
|
||||
for (var i = 0; i < result1.Items.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Items[i].ItemId, result2.Items[i].ItemId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_DifferentTenants_ProduceDifferentSeeds()
|
||||
{
|
||||
var tenantId1 = Guid.NewGuid();
|
||||
var tenantId2 = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
Sampling = new SamplingConfig
|
||||
{
|
||||
Strategy = SamplingStrategy.Random,
|
||||
Size = 10,
|
||||
Seed = null
|
||||
},
|
||||
TargetKinds = ["sbom"]
|
||||
};
|
||||
|
||||
var result1 = await _resolver.ResolveAsync(tenantId1, scope);
|
||||
var result2 = await _resolver.ResolveAsync(tenantId2, scope);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.NotNull(result1.SamplingMetadata);
|
||||
Assert.NotNull(result2.SamplingMetadata);
|
||||
|
||||
// Seeds should differ due to different tenant IDs
|
||||
Assert.NotEqual(result1.SamplingMetadata.Seed, result2.SamplingMetadata.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_ItemIds_AreDeterministic()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
SourceRefs = ["ref-001", "ref-002"]
|
||||
};
|
||||
|
||||
var result1 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
var result2 = await _resolver.ResolveAsync(tenantId, scope);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
|
||||
// Item IDs should be deterministic based on tenant/sourceRef/kind
|
||||
for (var i = 0; i < result1.Items.Count; i++)
|
||||
{
|
||||
Assert.Equal(result1.Items[i].ItemId, result2.Items[i].ItemId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveAsync_WithTimeProvider_UsesProvidedTime()
|
||||
{
|
||||
var fixedNow = new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FixedTimeProvider(fixedNow);
|
||||
var resolver = new ExportScopeResolver(
|
||||
NullLogger<ExportScopeResolver>.Instance,
|
||||
timeProvider);
|
||||
|
||||
var tenantId = Guid.NewGuid();
|
||||
var scope = new ExportScope
|
||||
{
|
||||
SourceRefs = ["test-ref"]
|
||||
};
|
||||
|
||||
var result = await resolver.ResolveAsync(tenantId, scope);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotEmpty(result.Items);
|
||||
// Items should have timestamps based on the fixed time
|
||||
Assert.All(result.Items, item => Assert.True(item.CreatedAt <= fixedNow));
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Domain;
|
||||
using StellaOps.ExportCenter.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="LineageEvidencePackService"/> with focus on determinism.
|
||||
/// </summary>
|
||||
public sealed class LineageEvidencePackServiceTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2025, 1, 15, 10, 30, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_UsesInjectedTimeProvider()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("11111111-1111-1111-1111-111111111111"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await service.GeneratePackAsync(
|
||||
"sha256:abcdef1234567890",
|
||||
"tenant-001",
|
||||
new EvidencePackOptions { IncludeCycloneDx = true });
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Pack);
|
||||
Assert.Equal(FixedNow, result.Pack.GeneratedAt);
|
||||
// PackId is the first GUID generated by the sequential provider
|
||||
Assert.StartsWith("11111111-1111-1111-1111-1111", result.Pack.PackId.ToString("D"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_ProducesDeterministicResults()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider1 = new SequentialGuidProvider(Guid.Parse("22222222-2222-2222-2222-222222222222"));
|
||||
var guidProvider2 = new SequentialGuidProvider(Guid.Parse("22222222-2222-2222-2222-222222222222"));
|
||||
|
||||
var service1 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider1);
|
||||
|
||||
var service2 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider2);
|
||||
|
||||
var options = new EvidencePackOptions
|
||||
{
|
||||
IncludeCycloneDx = true,
|
||||
IncludeSpdx = true,
|
||||
IncludeVex = true
|
||||
};
|
||||
|
||||
var result1 = await service1.GeneratePackAsync("sha256:abc123", "tenant-1", options);
|
||||
var result2 = await service2.GeneratePackAsync("sha256:abc123", "tenant-1", options);
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.Pack!.PackId, result2.Pack!.PackId);
|
||||
Assert.Equal(result1.Pack.GeneratedAt, result2.Pack.GeneratedAt);
|
||||
Assert.Equal(result1.Pack.Manifest?.MerkleRoot, result2.Pack.Manifest?.MerkleRoot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_ManifestEntriesAreSortedDeterministically()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("33333333-3333-3333-3333-333333333333"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var options = new EvidencePackOptions
|
||||
{
|
||||
IncludeCycloneDx = true,
|
||||
IncludeSpdx = true,
|
||||
IncludeVex = true,
|
||||
IncludeAttestations = true
|
||||
};
|
||||
|
||||
var result = await service.GeneratePackAsync("sha256:test", "tenant-1", options);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Pack?.Manifest);
|
||||
|
||||
// Verify entries are sorted by path
|
||||
var paths = result.Pack.Manifest!.Entries.Select(e => e.Path).ToList();
|
||||
var sortedPaths = paths.OrderBy(p => p, StringComparer.Ordinal).ToList();
|
||||
Assert.Equal(sortedPaths, paths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_ReplayHashIsDeterministic()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider1 = new SequentialGuidProvider(Guid.Parse("44444444-4444-4444-4444-444444444444"));
|
||||
var guidProvider2 = new SequentialGuidProvider(Guid.Parse("44444444-4444-4444-4444-444444444444"));
|
||||
|
||||
var service1 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider1);
|
||||
|
||||
var service2 = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider2);
|
||||
|
||||
var result1 = await service1.GeneratePackAsync("sha256:replay123", "tenant-replay");
|
||||
var result2 = await service2.GeneratePackAsync("sha256:replay123", "tenant-replay");
|
||||
|
||||
Assert.True(result1.Success);
|
||||
Assert.True(result2.Success);
|
||||
Assert.Equal(result1.Pack!.ReplayHash, result2.Pack!.ReplayHash);
|
||||
Assert.NotNull(result1.Pack.ReplayHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPackAsync_ReturnsNullForNonexistentPack()
|
||||
{
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance);
|
||||
|
||||
var result = await service.GetPackAsync(Guid.NewGuid(), "tenant-1");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPackAsync_ReturnsCachedPack()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("55555555-5555-5555-5555-555555555555"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var generateResult = await service.GeneratePackAsync("sha256:cached", "tenant-cache");
|
||||
Assert.True(generateResult.Success);
|
||||
|
||||
var getResult = await service.GetPackAsync(generateResult.Pack!.PackId, "tenant-cache");
|
||||
|
||||
Assert.NotNull(getResult);
|
||||
Assert.Equal(generateResult.Pack.PackId, getResult.PackId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPackAsync_EnforcesTenantIsolation()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("66666666-6666-6666-6666-666666666666"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var generateResult = await service.GeneratePackAsync("sha256:tenant-test", "tenant-a");
|
||||
Assert.True(generateResult.Success);
|
||||
|
||||
// Try to access with different tenant
|
||||
var getResult = await service.GetPackAsync(generateResult.Pack!.PackId, "tenant-b");
|
||||
|
||||
Assert.Null(getResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GeneratePackAsync_WithNoOptions_UsesDefaults()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("77777777-7777-7777-7777-777777777777"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var result = await service.GeneratePackAsync("sha256:defaults", "tenant-default");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.Pack);
|
||||
Assert.NotNull(result.Pack.Manifest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyPackAsync_ReturnsTrueForValidPack()
|
||||
{
|
||||
var timeProvider = new FixedTimeProvider(FixedNow);
|
||||
var guidProvider = new SequentialGuidProvider(Guid.Parse("88888888-8888-8888-8888-888888888888"));
|
||||
|
||||
var service = new LineageEvidencePackService(
|
||||
NullLogger<LineageEvidencePackService>.Instance,
|
||||
timeProvider,
|
||||
guidProvider);
|
||||
|
||||
var tenantId = "tenant-verify";
|
||||
var generateResult = await service.GeneratePackAsync("sha256:verify", tenantId);
|
||||
Assert.True(generateResult.Success);
|
||||
|
||||
var verifyResult = await service.VerifyPackAsync(generateResult.Pack!.PackId, tenantId);
|
||||
|
||||
Assert.True(verifyResult.Valid);
|
||||
}
|
||||
|
||||
private sealed class FixedTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _now;
|
||||
|
||||
public FixedTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user