audit notes work completed, test fixes work (95% done), new sprints, new data sources setup and configuration

This commit is contained in:
master
2026-01-14 10:48:00 +02:00
parent d7be6ba34b
commit 95d5898650
379 changed files with 40695 additions and 19041 deletions

View File

@@ -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
{

View File

@@ -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. |

View File

@@ -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

View File

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

View File

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