using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; using StellaOps.Scheduler.ImpactIndex; namespace StellaOps.Scheduler.Worker.Tests; public sealed class ImpactTargetingServiceTests { [Fact] public async Task ResolveByPurlsAsync_DeduplicatesKeysAndInvokesIndex() { var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"); var expected = CreateEmptyImpactSet(selector, usageOnly: false); IEnumerable? capturedKeys = null; var index = new StubImpactIndex { OnResolveByPurls = (purls, usageOnly, sel, _) => { capturedKeys = purls.ToArray(); Assert.False(usageOnly); Assert.Equal(selector, sel); return ValueTask.FromResult(expected); } }; var service = new ImpactTargetingService(index); var result = await service.ResolveByPurlsAsync( new[] { "pkg:npm/a", "pkg:npm/A ", null!, "pkg:npm/b" }, usageOnly: false, selector); Assert.Equal(expected, result); Assert.Equal(new[] { "pkg:npm/a", "pkg:npm/b" }, capturedKeys); } [Fact] public async Task ResolveByVulnerabilitiesAsync_ReturnsEmptyWhenNoIds() { var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"); var index = new StubImpactIndex(); var service = new ImpactTargetingService(index); var result = await service.ResolveByVulnerabilitiesAsync(Array.Empty(), usageOnly: true, selector); Assert.Empty(result.Images); Assert.True(result.UsageOnly); Assert.Null(index.LastVulnerabilityIds); } [Fact] public async Task ResolveAllAsync_DelegatesToIndex() { var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"); var expected = CreateEmptyImpactSet(selector, usageOnly: true); var index = new StubImpactIndex { OnResolveAll = (sel, usageOnly, _) => { Assert.Equal(selector, sel); Assert.True(usageOnly); return ValueTask.FromResult(expected); } }; var service = new ImpactTargetingService(index); var result = await service.ResolveAllAsync(selector, usageOnly: true); Assert.Equal(expected, result); } [Fact] public async Task ResolveByPurlsAsync_DeduplicatesImpactImagesByDigest() { var selector = new Selector(SelectorScope.AllImages, tenantId: "tenant-alpha"); var indexResult = new ImpactSet( selector, new[] { new ImpactImage( "sha256:111", "registry-1", "repo/app", namespaces: new[] { "team-a" }, tags: new[] { "v1" }, usedByEntrypoint: false, labels: new[] { KeyValuePair.Create("env", "prod") }), new ImpactImage( "sha256:111", "registry-1", "repo/app", namespaces: new[] { "team-b" }, tags: new[] { "v2" }, usedByEntrypoint: true, labels: new[] { KeyValuePair.Create("env", "prod"), KeyValuePair.Create("component", "api") }) }, usageOnly: false, DateTimeOffset.UtcNow, total: 2, snapshotId: "snap-1", schemaVersion: SchedulerSchemaVersions.ImpactSet); var index = new StubImpactIndex { OnResolveByPurls = (_, _, _, _) => ValueTask.FromResult(indexResult) }; var service = new ImpactTargetingService(index); var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: false, selector); Assert.Single(result.Images); var image = result.Images[0]; Assert.Equal("sha256:111", image.ImageDigest); Assert.Equal(new[] { "team-a", "team-b" }, image.Namespaces); Assert.Equal(new[] { "v1", "v2" }, image.Tags); Assert.True(image.UsedByEntrypoint); Assert.Equal("registry-1", image.Registry); Assert.Equal("repo/app", image.Repository); Assert.Equal(2, result.Total); Assert.Equal("prod", image.Labels["env"]); Assert.Equal("api", image.Labels["component"]); } [Fact] public async Task ResolveByPurlsAsync_FiltersImagesBySelectorConstraints() { var selector = new Selector( SelectorScope.ByNamespace, tenantId: "tenant-alpha", namespaces: new[] { "team-a" }, includeTags: new[] { "prod-*" }, labels: new[] { new LabelSelector("env", new[] { "prod" }) }); var matching = new ImpactImage( "sha256:aaa", "registry-1", "repo/app", namespaces: new[] { "team-a" }, tags: new[] { "prod-202510" }, usedByEntrypoint: true, labels: new[] { KeyValuePair.Create("env", "prod") }); var nonMatching = new ImpactImage( "sha256:bbb", "registry-1", "repo/app", namespaces: new[] { "team-b" }, tags: new[] { "dev" }, usedByEntrypoint: false, labels: new[] { KeyValuePair.Create("env", "dev") }); var indexResult = new ImpactSet( selector, new[] { matching, nonMatching }, usageOnly: true, DateTimeOffset.UtcNow, total: 2, snapshotId: null, schemaVersion: SchedulerSchemaVersions.ImpactSet); var index = new StubImpactIndex { OnResolveByPurls = (_, _, _, _) => ValueTask.FromResult(indexResult) }; var service = new ImpactTargetingService(index); var result = await service.ResolveByPurlsAsync(new[] { "pkg:npm/a" }, usageOnly: true, selector); Assert.Single(result.Images); Assert.Equal("sha256:aaa", result.Images[0].ImageDigest); } private static ImpactSet CreateEmptyImpactSet(Selector selector, bool usageOnly) => new( selector, ImmutableArray.Empty, usageOnly, DateTimeOffset.UtcNow, 0, snapshotId: null, schemaVersion: SchedulerSchemaVersions.ImpactSet); private sealed class StubImpactIndex : IImpactIndex { public Func, bool, Selector, CancellationToken, ValueTask>? OnResolveByPurls { get; set; } public Func, bool, Selector, CancellationToken, ValueTask>? OnResolveByVulnerabilities { get; set; } public Func>? OnResolveAll { get; set; } public IEnumerable? LastVulnerabilityIds { get; private set; } public ValueTask ResolveByPurlsAsync(IEnumerable purls, bool usageOnly, Selector selector, CancellationToken cancellationToken = default) => OnResolveByPurls?.Invoke(purls, usageOnly, selector, cancellationToken) ?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly)); public ValueTask ResolveByVulnerabilitiesAsync(IEnumerable vulnerabilityIds, bool usageOnly, Selector selector, CancellationToken cancellationToken = default) { LastVulnerabilityIds = vulnerabilityIds; return OnResolveByVulnerabilities?.Invoke(vulnerabilityIds, usageOnly, selector, cancellationToken) ?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly)); } public ValueTask ResolveAllAsync(Selector selector, bool usageOnly, CancellationToken cancellationToken = default) => OnResolveAll?.Invoke(selector, usageOnly, cancellationToken) ?? ValueTask.FromResult(CreateEmptyImpactSet(selector, usageOnly)); } }