wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10

This commit is contained in:
master
2026-02-23 15:30:50 +02:00
parent bd8fee6ed8
commit e746577380
1424 changed files with 81225 additions and 25251 deletions

View File

@@ -24,7 +24,7 @@ public sealed class FindingsEvidenceControllerTests
{
using var secrets = new TestSurfaceSecretsScope();
var mockTriageService = new Mock<ITriageQueryService>();
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((TriageFinding?)null);
await using var factory = new ScannerApplicationFactory().WithOverrides(
@@ -71,6 +71,7 @@ public sealed class FindingsEvidenceControllerTests
var finding = new TriageFinding
{
Id = findingId,
TenantId = "default",
AssetId = Guid.NewGuid(),
AssetLabel = "prod/api-gateway:1.2.3",
Purl = "pkg:npm/lodash@4.17.20",
@@ -79,9 +80,9 @@ public sealed class FindingsEvidenceControllerTests
LastSeenAt = now,
UpdatedAt = now
};
var mockTriageService = new Mock<ITriageQueryService>();
mockTriageService.Setup(s => s.GetFindingAsync(findingId.ToString(), It.IsAny<CancellationToken>()))
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), findingId.ToString(), It.IsAny<CancellationToken>()))
.ReturnsAsync(finding);
var mockEvidenceService = new Mock<IEvidenceCompositionService>();
@@ -147,6 +148,7 @@ public sealed class FindingsEvidenceControllerTests
var finding = new TriageFinding
{
Id = findingId,
TenantId = "default",
AssetId = Guid.NewGuid(),
AssetLabel = "prod/api-gateway:1.2.3",
Purl = "pkg:npm/lodash@4.17.20",
@@ -155,11 +157,11 @@ public sealed class FindingsEvidenceControllerTests
LastSeenAt = now,
UpdatedAt = now
};
var mockTriageService = new Mock<ITriageQueryService>();
mockTriageService.Setup(s => s.GetFindingAsync(findingId.ToString(), It.IsAny<CancellationToken>()))
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), findingId.ToString(), It.IsAny<CancellationToken>()))
.ReturnsAsync(finding);
mockTriageService.Setup(s => s.GetFindingAsync(It.Is<string>(id => id != findingId.ToString()), It.IsAny<CancellationToken>()))
mockTriageService.Setup(s => s.GetFindingAsync(It.IsAny<string>(), It.Is<string>(id => id != findingId.ToString()), It.IsAny<CancellationToken>()))
.ReturnsAsync((TriageFinding?)null);
var mockEvidenceService = new Mock<IEvidenceCompositionService>();

View File

@@ -503,6 +503,7 @@ public sealed class GatingReasonServiceTests
var finding = new TriageFinding
{
Id = Guid.NewGuid(),
TenantId = "default",
AssetLabel = "test-asset",
Purl = "pkg:npm/test@1.0.0",
CveId = "CVE-2024-1234",

View File

@@ -3,6 +3,7 @@ using System.Collections.Immutable;
using System.Net;
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.CallGraph;
using StellaOps.Scanner.Contracts;
using StellaOps.Scanner.Reachability;
@@ -87,7 +88,48 @@ public sealed class ReachabilityDriftEndpointsTests
Assert.Single(sinksPayload.Sinks);
}
private static async Task SeedCallGraphSnapshotsAsync(IServiceProvider services, string baseScanId, string headScanId)
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DriftSinksEndpoint_IsTenantIsolated()
{
using var secrets = new TestSurfaceSecretsScope();
await using var factory = new ScannerApplicationFactory().WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var baseScanId = await CreateScanAsync(client, "base-tenant-a", "tenant-a");
var headScanId = await CreateScanAsync(client, "head-tenant-a", "tenant-a");
await SeedCallGraphSnapshotsAsync(factory.Services, baseScanId, headScanId, "tenant-a");
using var ownerDriftRequest = new HttpRequestMessage(
HttpMethod.Get,
$"/api/v1/scans/{headScanId}/drift?baseScanId={baseScanId}&language=dotnet&includeFullPath=false");
ownerDriftRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
var driftResponse = await client.SendAsync(ownerDriftRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, driftResponse.StatusCode);
var drift = await driftResponse.Content.ReadFromJsonAsync<ReachabilityDriftResult>();
Assert.NotNull(drift);
using var crossTenantSinksRequest = new HttpRequestMessage(
HttpMethod.Get,
$"/api/v1/drift/{drift!.Id}/sinks?direction=became_reachable&offset=0&limit=10");
crossTenantSinksRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
var crossTenantSinksResponse = await client.SendAsync(crossTenantSinksRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, crossTenantSinksResponse.StatusCode);
}
private static async Task SeedCallGraphSnapshotsAsync(
IServiceProvider services,
string baseScanId,
string headScanId,
string? tenantId = null)
{
using var scope = services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<ICallGraphSnapshotRepository>();
@@ -99,8 +141,8 @@ public sealed class ReachabilityDriftEndpointsTests
scanId: headScanId,
edges: ImmutableArray.Create(new CallGraphEdge("entry", "sink", CallKind.Direct, "Demo.cs:1")));
await repo.StoreAsync(baseSnapshot);
await repo.StoreAsync(headSnapshot);
await repo.StoreAsync(baseSnapshot, tenantId: tenantId);
await repo.StoreAsync(headSnapshot, tenantId: tenantId);
}
private static CallGraphSnapshot CreateSnapshot(string scanId, ImmutableArray<CallGraphEdge> edges)
@@ -142,21 +184,31 @@ public sealed class ReachabilityDriftEndpointsTests
return provisional with { GraphDigest = CallGraphDigests.ComputeGraphDigest(provisional) };
}
private static async Task<string> CreateScanAsync(HttpClient client, string? clientRequestId = null)
private static async Task<string> CreateScanAsync(HttpClient client, string? clientRequestId = null, string? tenantId = null)
{
var response = await client.PostAsJsonAsync("/api/v1/scans", new ScanSubmitRequest
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/scans")
{
Image = new ScanImageDescriptor
Content = JsonContent.Create(new ScanSubmitRequest
{
Reference = "example.com/demo:1.0",
Digest = "sha256:0123456789abcdef"
},
ClientRequestId = clientRequestId,
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["test.request"] = clientRequestId ?? string.Empty
}
});
Image = new ScanImageDescriptor
{
Reference = "example.com/demo:1.0",
Digest = "sha256:0123456789abcdef"
},
ClientRequestId = clientRequestId,
Metadata = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["test.request"] = clientRequestId ?? string.Empty
}
})
};
if (!string.IsNullOrWhiteSpace(tenantId))
{
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, tenantId);
}
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);

View File

@@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Http;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.WebService.Tenancy;
using StellaOps.TestKit;
using System.Net;
using System.Security.Claims;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ScannerRequestContextResolverTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolveTenantReturnsMissingWhenNoClaimOrHeader()
{
var context = new DefaultHttpContext();
var resolved = ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var error,
allowDefaultTenant: false);
Assert.False(resolved);
Assert.Equal(string.Empty, tenantId);
Assert.Equal("tenant_missing", error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolveTenantReturnsConflictWhenClaimAndHeaderDiffer()
{
var context = new DefaultHttpContext();
context.User = new ClaimsPrincipal(
new ClaimsIdentity(new[]
{
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a")
}));
context.Request.Headers["X-Stella-Tenant"] = "tenant-b";
var resolved = ScannerRequestContextResolver.TryResolveTenant(
context,
out _,
out var error,
allowDefaultTenant: false);
Assert.False(resolved);
Assert.Equal("tenant_conflict", error);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ResolveTenantPartitionKeyFallsBackToIpWhenTenantMissing()
{
var context = new DefaultHttpContext();
context.Connection.RemoteIpAddress = IPAddress.Parse("10.11.12.13");
var partitionKey = ScannerRequestContextResolver.ResolveTenantPartitionKey(context);
Assert.Equal("ip:10.11.12.13", partitionKey);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void TryResolveTenantSupportsLegacyHeaderAlias()
{
var context = new DefaultHttpContext();
context.Request.Headers["X-Tenant-Id"] = "Tenant-Alpha";
var resolved = ScannerRequestContextResolver.TryResolveTenant(
context,
out var tenantId,
out var error,
allowDefaultTenant: false);
Assert.True(resolved);
Assert.Equal("tenant-alpha", tenantId);
Assert.Null(error);
}
}

View File

@@ -0,0 +1,225 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ScannerTenantIsolationAndEndpointRegistrationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SourcesAndWebhookEndpoints_HaveExplicitAuthPosture()
{
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
var endpoints = factory.Services
.GetServices<EndpointDataSource>()
.SelectMany(source => source.Endpoints)
.OfType<RouteEndpoint>()
.ToList();
var sourcesList = FindNamedEndpoint(endpoints, "scanner.sources.list");
AssertPolicy(sourcesList, ScannerPolicies.SourcesRead);
AssertAnonymous(sourcesList, expected: false);
var sourcesCreate = FindNamedEndpoint(endpoints, "scanner.sources.create");
AssertPolicy(sourcesCreate, ScannerPolicies.SourcesWrite);
AssertAnonymous(sourcesCreate, expected: false);
var sourcesDelete = FindNamedEndpoint(endpoints, "scanner.sources.delete");
AssertPolicy(sourcesDelete, ScannerPolicies.SourcesAdmin);
AssertAnonymous(sourcesDelete, expected: false);
var webhookGeneric = FindNamedEndpoint(endpoints, "scanner.webhooks.receive");
AssertAnonymous(webhookGeneric, expected: true);
var webhookGitHub = FindNamedEndpoint(endpoints, "scanner.webhooks.github");
AssertAnonymous(webhookGitHub, expected: true);
var unknownsList = FindNamedEndpoint(endpoints, "scanner.unknowns.list");
AssertPolicy(unknownsList, ScannerPolicies.ScansRead);
AssertAnonymous(unknownsList, expected: false);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ScanStatus_RejectsCrossTenantAccess()
{
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(
configureConfiguration: configuration =>
{
configuration["scanner:authority:enabled"] = "false";
},
configureServices: services =>
{
services.RemoveAll<ISurfacePointerService>();
services.AddSingleton<ISurfacePointerService, NoopSurfacePointerService>();
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, tenantId: "tenant-a", TestContext.Current.CancellationToken);
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scans/{scanId}");
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
using var crossTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/scans/{scanId}");
crossTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
var crossTenantResponse = await client.SendAsync(crossTenantRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, crossTenantResponse.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CallGraphSubmission_RejectsCrossTenantScanOwnership()
{
var ingestion = new RecordingCallGraphIngestionService();
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(
configureConfiguration: configuration =>
{
configuration["scanner:authority:enabled"] = "false";
},
configureServices: services =>
{
services.RemoveAll<ICallGraphIngestionService>();
services.AddSingleton<ICallGraphIngestionService>(ingestion);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
var scanId = await SubmitScanAsync(client, tenantId: "tenant-a", TestContext.Current.CancellationToken);
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/scans/{scanId}/callgraphs")
{
Content = JsonContent.Create(CreateMinimalCallGraph(scanId))
};
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
request.Headers.TryAddWithoutValidation("Content-Digest", "sha256:tenant-bound-callgraph");
var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal(0, ingestion.FindByDigestCalls);
Assert.Equal(0, ingestion.IngestCalls);
}
private static async Task<string> SubmitScanAsync(HttpClient client, string tenantId, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/scans")
{
Content = JsonContent.Create(new ScanSubmitRequest
{
Image = new ScanImageDescriptor
{
Reference = "example.com/scanner/tenant-test:1.0",
Digest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd"
}
})
};
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, tenantId);
var response = await client.SendAsync(request, cancellationToken);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ScanSubmitResponse>(cancellationToken: cancellationToken);
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload!.ScanId));
return payload.ScanId;
}
private static CallGraphV1Dto CreateMinimalCallGraph(string scanId)
{
return new CallGraphV1Dto(
Schema: "stella.callgraph.v1",
ScanKey: scanId,
Language: "dotnet",
Nodes: new[]
{
new CallGraphNodeDto("n1", "Demo.Entry", null, "public", true),
new CallGraphNodeDto("n2", "Demo.Vuln", null, "public", false)
},
Edges: new[]
{
new CallGraphEdgeDto("n1", "n2", "static", "direct", 1.0)
});
}
private static RouteEndpoint FindNamedEndpoint(IEnumerable<RouteEndpoint> endpoints, string endpointName)
{
return endpoints.Single(endpoint =>
{
var nameMetadata = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>();
return string.Equals(nameMetadata?.EndpointName, endpointName, StringComparison.Ordinal);
});
}
private static void AssertPolicy(RouteEndpoint endpoint, string policyName)
{
var authorizeData = endpoint.Metadata.GetOrderedMetadata<IAuthorizeData>();
Assert.Contains(authorizeData, data => string.Equals(data.Policy, policyName, StringComparison.Ordinal));
}
private static void AssertAnonymous(RouteEndpoint endpoint, bool expected)
{
var hasAnonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
Assert.Equal(expected, hasAnonymous);
}
private sealed class RecordingCallGraphIngestionService : ICallGraphIngestionService
{
public int FindByDigestCalls { get; private set; }
public int IngestCalls { get; private set; }
public Task<ExistingCallGraphDto?> FindByDigestAsync(
ScanId scanId,
string tenantId,
string contentDigest,
CancellationToken cancellationToken = default)
{
FindByDigestCalls++;
return Task.FromResult<ExistingCallGraphDto?>(null);
}
public Task<CallGraphIngestionResult> IngestAsync(
ScanId scanId,
string tenantId,
CallGraphV1Dto callGraph,
string contentDigest,
CancellationToken cancellationToken = default)
{
IngestCalls++;
return Task.FromResult(new CallGraphIngestionResult(
CallgraphId: "cg-test",
NodeCount: callGraph.Nodes.Count,
EdgeCount: callGraph.Edges.Count,
Digest: contentDigest));
}
public CallGraphValidationResult Validate(CallGraphV1Dto callGraph)
=> CallGraphValidationResult.Success();
}
private sealed class NoopSurfacePointerService : ISurfacePointerService
{
public Task<SurfacePointersDto?> TryBuildAsync(string imageDigest, CancellationToken cancellationToken)
=> Task.FromResult<SurfacePointersDto?>(null);
}
}

View File

@@ -0,0 +1,123 @@
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using Moq;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class SecretExceptionPatternServiceTenantIsolationTests
{
private static readonly FakeTimeProvider TimeProvider = new(
new DateTimeOffset(2026, 1, 2, 1, 0, 0, TimeSpan.Zero));
[Fact]
[Trait("Category", TestCategories.Unit)]
public async Task GetPatternAsync_UsesTenantScopedRepositoryLookup()
{
var tenantId = Guid.NewGuid();
var patternId = Guid.NewGuid();
var repository = new Mock<ISecretExceptionPatternRepository>(MockBehavior.Strict);
repository
.Setup(r => r.GetByIdAsync(tenantId, patternId, It.IsAny<CancellationToken>()))
.ReturnsAsync(CreatePatternRow(tenantId, patternId));
var service = new SecretExceptionPatternService(repository.Object, TimeProvider);
var result = await service.GetPatternAsync(tenantId, patternId);
result.Should().NotBeNull();
result!.TenantId.Should().Be(tenantId);
repository.Verify(
r => r.GetByIdAsync(tenantId, patternId, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
public async Task UpdatePatternAsync_PassesTenantToReadAndWriteCalls()
{
var tenantId = Guid.NewGuid();
var patternId = Guid.NewGuid();
var existing = CreatePatternRow(tenantId, patternId);
var repository = new Mock<ISecretExceptionPatternRepository>(MockBehavior.Strict);
repository
.SetupSequence(r => r.GetByIdAsync(tenantId, patternId, It.IsAny<CancellationToken>()))
.ReturnsAsync(existing)
.ReturnsAsync(CreatePatternRow(tenantId, patternId, name: "updated-name"));
repository
.Setup(r => r.UpdateAsync(tenantId, It.IsAny<SecretExceptionPatternRow>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var service = new SecretExceptionPatternService(repository.Object, TimeProvider);
var request = new SecretExceptionPatternDto
{
Name = "updated-name",
Description = "updated description",
ValuePattern = "AKIA[0-9A-Z]{16}",
ApplicableRuleIds = ["aws-access-key"],
Justification = "Updated pattern to match current key format.",
IsActive = true
};
var (success, pattern, errors) = await service.UpdatePatternAsync(
tenantId,
patternId,
request,
"updater");
success.Should().BeTrue();
errors.Should().BeEmpty();
pattern.Should().NotBeNull();
pattern!.Pattern.Name.Should().Be("updated-name");
repository.Verify(
r => r.UpdateAsync(tenantId, It.IsAny<SecretExceptionPatternRow>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
[Trait("Category", TestCategories.Unit)]
public async Task DeletePatternAsync_UsesTenantScopedRepositoryDelete()
{
var tenantId = Guid.NewGuid();
var patternId = Guid.NewGuid();
var repository = new Mock<ISecretExceptionPatternRepository>(MockBehavior.Strict);
repository
.Setup(r => r.DeleteAsync(tenantId, patternId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var service = new SecretExceptionPatternService(repository.Object, TimeProvider);
var deleted = await service.DeletePatternAsync(tenantId, patternId);
deleted.Should().BeTrue();
repository.Verify(
r => r.DeleteAsync(tenantId, patternId, It.IsAny<CancellationToken>()),
Times.Once);
}
private static SecretExceptionPatternRow CreatePatternRow(
Guid tenantId,
Guid patternId,
string name = "aws-key-exception")
{
return new SecretExceptionPatternRow
{
ExceptionId = patternId,
TenantId = tenantId,
Name = name,
Description = "Known test fixture key pattern.",
ValuePattern = "AKIA[0-9A-Z]{16}",
ApplicableRuleIds = ["aws-access-key"],
Justification = "This key format is used in non-production fixtures only.",
IsActive = true,
CreatedAt = new DateTimeOffset(2026, 1, 2, 1, 0, 0, TimeSpan.Zero),
CreatedBy = "tester"
};
}
}

View File

@@ -4,6 +4,7 @@ using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.SmartDiff.Detection;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
@@ -103,6 +104,31 @@ public sealed class SmartDiffEndpointsTests
Assert.Contains("pkg:npm/test-component@1.0.0", body, StringComparison.Ordinal);
}
[Fact]
public async Task CandidateLookup_UsesResolvedTenantContext()
{
var imageDigest = "sha256:tenant-bound";
var candidateStore = new InMemoryVexCandidateStore();
await candidateStore.StoreCandidatesAsync(
[CreateCandidate("candidate-tenant", imageDigest, requiresReview: true)],
TestContext.Current.CancellationToken,
tenantId: "tenant-a");
await using var factory = CreateFactory(candidateStore, new StubScanMetadataRepository());
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"{BasePath}/candidates/candidate-tenant");
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
using var crossTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"{BasePath}/candidates/candidate-tenant");
crossTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
var crossTenantResponse = await client.SendAsync(crossTenantRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, crossTenantResponse.StatusCode);
}
private static ScannerApplicationFactory CreateFactory(
IVexCandidateStore candidateStore,
IScanMetadataRepository metadataRepository)
@@ -156,17 +182,17 @@ public sealed class SmartDiffEndpointsTests
private sealed class StubMaterialRiskChangeRepository : IMaterialRiskChangeRepository
{
public Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default) => Task.CompletedTask;
public Task StoreChangeAsync(MaterialRiskChangeResult change, string scanId, CancellationToken ct = default, string? tenantId = null) => Task.CompletedTask;
public Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default) => Task.CompletedTask;
public Task StoreChangesAsync(IReadOnlyList<MaterialRiskChangeResult> changes, string scanId, CancellationToken ct = default, string? tenantId = null) => Task.CompletedTask;
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default) =>
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForScanAsync(string scanId, CancellationToken ct = default, string? tenantId = null) =>
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(FindingKey findingKey, int limit = 10, CancellationToken ct = default) =>
public Task<IReadOnlyList<MaterialRiskChangeResult>> GetChangesForFindingAsync(FindingKey findingKey, int limit = 10, CancellationToken ct = default, string? tenantId = null) =>
Task.FromResult<IReadOnlyList<MaterialRiskChangeResult>>(Array.Empty<MaterialRiskChangeResult>());
public Task<MaterialRiskChangeQueryResult> QueryChangesAsync(MaterialRiskChangeQuery query, CancellationToken ct = default) =>
public Task<MaterialRiskChangeQueryResult> QueryChangesAsync(MaterialRiskChangeQuery query, CancellationToken ct = default, string? tenantId = null) =>
Task.FromResult(new MaterialRiskChangeQueryResult(ImmutableArray<MaterialRiskChangeResult>.Empty, 0, query.Offset, query.Limit));
}
}

View File

@@ -11,3 +11,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| SPRINT-20260208-063-TRIAGE-001 | DONE | Add endpoint tests for triage cluster inbox stats and batch triage actions (2026-02-08). |
| HOT-004 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: added endpoint tests for payload/component/pending triage hot-lookup APIs. |
| HOT-006 | DONE | `SPRINT_20260210_001_DOCS_sbom_attestation_hot_lookup_contract.md`: deterministic query ordering/latency coverage added; local execution is Docker-gated in this environment. |
| SPRINT-20260222-057-SCAN-TEN-09 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added focused cross-tenant triage/evidence endpoint isolation tests (`TriageTenantIsolationEndpointsTests`) and reran targeted tenant suites (2026-02-22). |
| SPRINT-20260222-057-SCAN-TEN-10 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added focused Unknowns endpoint tenant-isolation coverage (`UnknownsTenantIsolationEndpointsTests`) for cross-tenant not-found and tenant-conflict rejection (2026-02-22). |
| SPRINT-20260222-057-SCAN-TEN-11 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added SmartDiff and Reachability tenant-propagation regression checks (`SmartDiffEndpointsTests`, `ReachabilityDriftEndpointsTests`) and validated focused suites (2026-02-23). |
| SPRINT-20260222-057-SCAN-TEN-13 | DONE | `SPRINT_20260222_057_Scanner_tenant_isolation_for_scans_triage_webhooks.md`: added `SecretExceptionPatternServiceTenantIsolationTests` validating tenant-scoped repository lookups for exception get/update/delete (`3` tests, 2026-02-23). |

View File

@@ -145,7 +145,7 @@ public sealed class TriageClusterEndpointsTests
_findings = findings;
}
public Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string artifactDigest, CancellationToken ct)
public Task<IReadOnlyList<Finding>> GetFindingsForArtifactAsync(string tenantId, string artifactDigest, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<Finding>>(
_findings.Where(f => string.Equals(f.ArtifactDigest, artifactDigest, StringComparison.Ordinal)).ToArray());
}
@@ -154,10 +154,11 @@ public sealed class TriageClusterEndpointsTests
{
public List<string> UpdatedFindingIds { get; } = [];
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string findingId, CancellationToken ct = default)
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string tenantId, string findingId, CancellationToken ct = default)
=> Task.FromResult<FindingTriageStatusDto?>(null);
public Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
string tenantId,
string findingId,
UpdateTriageStatusRequestDto request,
string actor,
@@ -177,6 +178,7 @@ public sealed class TriageClusterEndpointsTests
}
public Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
string tenantId,
string findingId,
SubmitVexStatementRequestDto request,
string actor,
@@ -184,6 +186,7 @@ public sealed class TriageClusterEndpointsTests
=> Task.FromResult<SubmitVexStatementResponseDto?>(null);
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
string tenantId,
BulkTriageQueryRequestDto request,
int limit,
CancellationToken ct = default)
@@ -201,7 +204,7 @@ public sealed class TriageClusterEndpointsTests
}
});
public Task<TriageSummaryDto> GetSummaryAsync(string artifactDigest, CancellationToken ct = default)
public Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default)
=> Task.FromResult(new TriageSummaryDto
{
ByLane = new Dictionary<string, int>(),

View File

@@ -0,0 +1,224 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.Triage.Entities;
using StellaOps.Scanner.Triage.Services;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Endpoints.Triage;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class TriageTenantIsolationEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task TriageFindingStatus_RejectsCrossTenantAccess()
{
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(
configuration =>
{
configuration["scanner:authority:enabled"] = "false";
},
configureServices: services =>
{
services.RemoveAll<ITriageStatusService>();
services.AddSingleton<ITriageStatusService>(
new TenantAwareStatusService("finding-tenant-a"));
});
await factory.InitializeAsync();
var findingId = "finding-tenant-a";
using var client = factory.CreateClient();
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/triage/findings/{findingId}");
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
using var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
using var crossTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/triage/findings/{findingId}");
crossTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
using var crossTenantResponse = await client.SendAsync(crossTenantRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, crossTenantResponse.StatusCode);
var statusService = factory.Services.GetRequiredService<ITriageStatusService>() as TenantAwareStatusService;
Assert.NotNull(statusService);
Assert.Equal(new[] { "tenant-a", "tenant-b" }, statusService!.RequestedTenants);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FindingsEvidence_RejectsCrossTenantAccess()
{
var triageQueryService = new TenantAwareTriageQueryService("finding-tenant-a");
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(
configuration =>
{
configuration["scanner:authority:enabled"] = "false";
},
configureServices: services =>
{
services.RemoveAll<ITriageQueryService>();
services.AddSingleton<ITriageQueryService>(triageQueryService);
services.RemoveAll<IEvidenceCompositionService>();
services.AddSingleton<IEvidenceCompositionService>(new StaticEvidenceCompositionService());
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var tenantARequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/findings/finding-tenant-a/evidence");
tenantARequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
using var tenantAResponse = await client.SendAsync(tenantARequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, tenantAResponse.StatusCode);
using var tenantBRequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/findings/finding-tenant-a/evidence");
tenantBRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
using var tenantBResponse = await client.SendAsync(tenantBRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, tenantBResponse.StatusCode);
Assert.Equal(new[] { "tenant-a", "tenant-b" }, triageQueryService.RequestedTenants);
}
private sealed class TenantAwareStatusService : ITriageStatusService
{
private readonly string _ownedFindingId;
public TenantAwareStatusService(string ownedFindingId)
{
_ownedFindingId = ownedFindingId;
}
public List<string> RequestedTenants { get; } = [];
public Task<FindingTriageStatusDto?> GetFindingStatusAsync(string tenantId, string findingId, CancellationToken ct = default)
{
RequestedTenants.Add(tenantId);
if (!string.Equals(tenantId, "tenant-a", StringComparison.Ordinal) ||
!string.Equals(findingId, _ownedFindingId, StringComparison.Ordinal))
{
return Task.FromResult<FindingTriageStatusDto?>(null);
}
return Task.FromResult<FindingTriageStatusDto?>(new FindingTriageStatusDto
{
FindingId = findingId,
Lane = "Active",
Verdict = "Block",
ComputedAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero)
});
}
public Task<UpdateTriageStatusResponseDto?> UpdateStatusAsync(
string tenantId,
string findingId,
UpdateTriageStatusRequestDto request,
string actor,
CancellationToken ct = default)
=> Task.FromResult<UpdateTriageStatusResponseDto?>(null);
public Task<SubmitVexStatementResponseDto?> SubmitVexStatementAsync(
string tenantId,
string findingId,
SubmitVexStatementRequestDto request,
string actor,
CancellationToken ct = default)
=> Task.FromResult<SubmitVexStatementResponseDto?>(null);
public Task<BulkTriageQueryResponseDto> QueryFindingsAsync(
string tenantId,
BulkTriageQueryRequestDto request,
int limit,
CancellationToken ct = default)
=> Task.FromResult(new BulkTriageQueryResponseDto
{
Findings = [],
TotalCount = 0,
Summary = new TriageSummaryDto
{
ByLane = new Dictionary<string, int>(),
ByVerdict = new Dictionary<string, int>(),
CanShipCount = 0,
BlockingCount = 0
}
});
public Task<TriageSummaryDto> GetSummaryAsync(string tenantId, string artifactDigest, CancellationToken ct = default)
=> Task.FromResult(new TriageSummaryDto
{
ByLane = new Dictionary<string, int>(),
ByVerdict = new Dictionary<string, int>(),
CanShipCount = 0,
BlockingCount = 0
});
}
private sealed class TenantAwareTriageQueryService : ITriageQueryService
{
private readonly string _ownedFindingId;
public TenantAwareTriageQueryService(string ownedFindingId)
{
_ownedFindingId = ownedFindingId;
}
public List<string> RequestedTenants { get; } = [];
public Task<TriageFinding?> GetFindingAsync(string tenantId, string findingId, CancellationToken cancellationToken = default)
{
RequestedTenants.Add(tenantId);
if (string.Equals(tenantId, "tenant-a", StringComparison.Ordinal) &&
string.Equals(findingId, _ownedFindingId, StringComparison.Ordinal))
{
return Task.FromResult<TriageFinding?>(new TriageFinding
{
Id = Guid.NewGuid(),
TenantId = "tenant-a",
AssetId = Guid.NewGuid(),
AssetLabel = "prod/tenant-a/api:1.0.0",
Purl = "pkg:npm/acme/demo@1.0.0",
CveId = "CVE-2026-9999",
ArtifactDigest = "sha256:tenant-a-artifact",
FirstSeenAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero),
LastSeenAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero),
UpdatedAt = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero)
});
}
return Task.FromResult<TriageFinding?>(null);
}
}
private sealed class StaticEvidenceCompositionService : IEvidenceCompositionService
{
public Task<FindingEvidenceResponse?> GetEvidenceAsync(
ScanId scanId,
string findingId,
CancellationToken cancellationToken = default)
=> Task.FromResult<FindingEvidenceResponse?>(null);
public Task<FindingEvidenceResponse> ComposeAsync(
TriageFinding finding,
bool includeRaw,
CancellationToken cancellationToken = default)
{
return Task.FromResult(new FindingEvidenceResponse
{
FindingId = finding.Id.ToString(),
Cve = finding.CveId ?? "CVE-unknown",
Component = new ComponentInfo
{
Name = "demo",
Version = "1.0.0",
Purl = finding.Purl
},
LastSeen = finding.LastSeenAt
});
}
}
}

View File

@@ -0,0 +1,137 @@
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Auth.Abstractions;
using StellaOps.Scanner.WebService.Services;
using StellaOps.TestKit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class UnknownsTenantIsolationEndpointsTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UnknownById_RejectsCrossTenantAccess()
{
var queryService = new TenantAwareUnknownsQueryService();
var unknownId = queryService.OwnedUnknownId;
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(
configureConfiguration: configuration =>
{
configuration["scanner:authority:enabled"] = "false";
},
configureServices: services =>
{
services.RemoveAll<IUnknownsQueryService>();
services.AddSingleton<IUnknownsQueryService>(queryService);
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var ownerRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/unknowns/unk-{unknownId:N}");
ownerRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
using var ownerResponse = await client.SendAsync(ownerRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, ownerResponse.StatusCode);
using var otherTenantRequest = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/unknowns/unk-{unknownId:N}");
otherTenantRequest.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-b");
using var otherTenantResponse = await client.SendAsync(otherTenantRequest, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, otherTenantResponse.StatusCode);
Assert.Equal(new[] { "tenant-a", "tenant-b" }, queryService.RequestedTenants);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UnknownsList_RejectsConflictingTenantHeaders()
{
await using var factory = ScannerApplicationFactory.CreateLightweight()
.WithOverrides(
configureConfiguration: configuration =>
{
configuration["scanner:authority:enabled"] = "false";
});
await factory.InitializeAsync();
using var client = factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/unknowns?limit=10");
request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "tenant-a");
request.Headers.TryAddWithoutValidation("X-Tenant-Id", "tenant-b");
using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private sealed class TenantAwareUnknownsQueryService : IUnknownsQueryService
{
public Guid OwnedUnknownId { get; } = Guid.Parse("11111111-1111-1111-1111-111111111111");
public List<string> RequestedTenants { get; } = [];
public Task<UnknownsListResult> ListAsync(
string tenantId,
UnknownsListQuery query,
CancellationToken cancellationToken = default)
{
RequestedTenants.Add(tenantId);
return Task.FromResult(new UnknownsListResult
{
Items = [],
TotalCount = 0
});
}
public Task<UnknownsDetail?> GetByIdAsync(
string tenantId,
Guid unknownId,
CancellationToken cancellationToken = default)
{
RequestedTenants.Add(tenantId);
if (!string.Equals(tenantId, "tenant-a", StringComparison.Ordinal) || unknownId != OwnedUnknownId)
{
return Task.FromResult<UnknownsDetail?>(null);
}
return Task.FromResult<UnknownsDetail?>(new UnknownsDetail
{
UnknownId = unknownId,
ArtifactDigest = "sha256:tenant-a-artifact",
VulnerabilityId = "CVE-2026-1111",
PackagePurl = "pkg:npm/acme/example@1.0.0",
Score = 0.92,
ProofRef = "proof://tenant-a/unknown",
CreatedAtUtc = new DateTimeOffset(2026, 2, 22, 0, 0, 0, TimeSpan.Zero),
UpdatedAtUtc = new DateTimeOffset(2026, 2, 22, 0, 5, 0, TimeSpan.Zero)
});
}
public Task<UnknownsStats> GetStatsAsync(string tenantId, CancellationToken cancellationToken = default)
{
RequestedTenants.Add(tenantId);
return Task.FromResult(new UnknownsStats
{
Total = 0,
Hot = 0,
Warm = 0,
Cold = 0
});
}
public Task<IReadOnlyDictionary<string, long>> GetBandDistributionAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
RequestedTenants.Add(tenantId);
return Task.FromResult<IReadOnlyDictionary<string, long>>(new Dictionary<string, long>(StringComparer.Ordinal)
{
["HOT"] = 0,
["WARM"] = 0,
["COLD"] = 0
});
}
}
}

View File

@@ -0,0 +1,138 @@
using StellaOps.Determinism;
using StellaOps.Scanner.Sources.Contracts;
using StellaOps.Scanner.Sources.Domain;
using StellaOps.Scanner.Sources.Persistence;
using StellaOps.Scanner.WebService.Endpoints;
using StellaOps.TestKit;
using System.Text.Json;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class WebhookEndpointsTenantLookupTests
{
private static readonly JsonDocument MinimalGitConfig = JsonDocument.Parse(
"""
{
"provider": "GitHub",
"repositoryUrl": "https://github.com/stella-ops/shared.git",
"branches": { "include": ["main"] },
"triggers": { "onPush": true, "onPullRequest": false, "onTag": false },
"scanOptions": { "analyzers": ["syft"] }
}
""");
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FindSourceByNameIsTenantScoped()
{
var repository = new InMemorySourceRepository();
repository.Add(CreateSource("tenant-a", "shared", SbomSourceType.Git));
var resolvedForOwner = await WebhookEndpoints.FindSourceByNameAsync(
repository,
"tenant-a",
"shared",
SbomSourceType.Git,
TestContext.Current.CancellationToken);
var resolvedForOtherTenant = await WebhookEndpoints.FindSourceByNameAsync(
repository,
"tenant-b",
"shared",
SbomSourceType.Git,
TestContext.Current.CancellationToken);
Assert.NotNull(resolvedForOwner);
Assert.Equal("tenant-a", resolvedForOwner!.TenantId);
Assert.Null(resolvedForOtherTenant);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FindSourceByNameRequiresExpectedSourceType()
{
var repository = new InMemorySourceRepository();
repository.Add(CreateSource("tenant-a", "shared", SbomSourceType.Zastava));
var resolved = await WebhookEndpoints.FindSourceByNameAsync(
repository,
"tenant-a",
"shared",
SbomSourceType.Git,
TestContext.Current.CancellationToken);
Assert.Null(resolved);
}
private static SbomSource CreateSource(string tenantId, string name, SbomSourceType sourceType)
{
return SbomSource.Create(
tenantId: tenantId,
name: name,
sourceType: sourceType,
configuration: MinimalGitConfig,
createdBy: "test",
timeProvider: TimeProvider.System,
guidProvider: new DeterministicGuidProvider());
}
private sealed class InMemorySourceRepository : ISbomSourceRepository
{
private readonly Dictionary<(string TenantId, string Name), SbomSource> _byTenantAndName =
new();
public void Add(SbomSource source)
{
_byTenantAndName[(source.TenantId, source.Name)] = source;
}
public Task<SbomSource?> GetByIdAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
=> Task.FromResult<SbomSource?>(null);
public Task<SbomSource?> GetByIdAnyTenantAsync(Guid sourceId, CancellationToken ct = default)
=> Task.FromResult<SbomSource?>(null);
public Task<SbomSource?> GetByNameAsync(string tenantId, string name, CancellationToken ct = default)
{
_byTenantAndName.TryGetValue((tenantId, name), out var source);
return Task.FromResult(source);
}
public Task<PagedResponse<SbomSource>> ListAsync(string tenantId, ListSourcesRequest request, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<SbomSource>> GetDueScheduledSourcesAsync(DateTimeOffset asOf, int limit = 100, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task CreateAsync(SbomSource source, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task UpdateAsync(SbomSource source, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task DeleteAsync(string tenantId, Guid sourceId, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<bool> NameExistsAsync(string tenantId, string name, Guid? excludeSourceId = null, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<SbomSource>> SearchByNameAsync(string name, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<SbomSource>> GetDueForScheduledRunAsync(CancellationToken ct = default)
=> throw new NotSupportedException();
}
private sealed class DeterministicGuidProvider : IGuidProvider
{
private int _counter;
public Guid NewGuid()
{
Span<byte> bytes = stackalloc byte[16];
BitConverter.TryWriteBytes(bytes, ++_counter);
return new Guid(bytes);
}
}
}