Complete Entrypoint Detection Re-Engineering Program (Sprints 0410-0415) and Sprint 3500.0002.0003 (Proof Replay + API)

Entrypoint Detection Program (100% complete):
- Sprint 0411: Semantic Entrypoint Engine - all 25 tasks DONE
- Sprint 0412: Temporal & Mesh Entrypoint - all 19 tasks DONE
- Sprint 0413: Speculative Execution Engine - all 19 tasks DONE
- Sprint 0414: Binary Intelligence - all 19 tasks DONE
- Sprint 0415: Predictive Risk Scoring - all tasks DONE

Key deliverables:
- SemanticEntrypoint schema with ApplicationIntent/CapabilityClass
- TemporalEntrypointGraph and MeshEntrypointGraph
- ShellSymbolicExecutor with PathEnumerator and PathConfidenceScorer
- CodeFingerprint index with symbol recovery
- RiskScore with multi-dimensional risk assessment

Sprint 3500.0002.0003 (Proof Replay + API):
- ManifestEndpoints with DSSE content negotiation
- Proof bundle endpoints by root hash
- IdempotencyMiddleware with RFC 9530 Content-Digest
- Rate limiting (100 req/hr per tenant)
- OpenAPI documentation updates

Tests: 357 EntryTrace tests pass, WebService tests blocked by pre-existing infrastructure issue
This commit is contained in:
StellaOps Bot
2025-12-20 17:46:27 +02:00
parent ce8cdcd23d
commit 3698ebf4a8
46 changed files with 4156 additions and 46 deletions

View File

@@ -0,0 +1,162 @@
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using StellaOps.Scheduler.Models;
using StellaOps.Scheduler.Queue;
using StellaOps.Signals.Services;
namespace StellaOps.Signals.Scheduler;
/// <summary>
/// Implementation of <see cref="ISchedulerJobClient"/> that enqueues jobs
/// to the Scheduler planner queue.
/// </summary>
public sealed class SchedulerQueueJobClient : ISchedulerJobClient
{
private readonly ISchedulerPlannerQueue _plannerQueue;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SchedulerQueueJobClient> _logger;
public SchedulerQueueJobClient(
ISchedulerPlannerQueue plannerQueue,
TimeProvider timeProvider,
ILogger<SchedulerQueueJobClient> logger)
{
_plannerQueue = plannerQueue ?? throw new ArgumentNullException(nameof(plannerQueue));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<SchedulerJobResult> CreateRescanJobAsync(
RescanJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
try
{
var (run, impactSet) = CreateRunAndImpactSet(request);
var message = new PlannerQueueMessage(
run,
impactSet,
correlationId: request.CorrelationId ?? $"unknowns-rescan:{request.UnknownId}");
_logger.LogDebug(
"Enqueueing rescan job for unknown {UnknownId}, runId={RunId}",
request.UnknownId,
run.Id);
var result = await _plannerQueue.EnqueueAsync(message, cancellationToken)
.ConfigureAwait(false);
// EnqueueAsync throws on failure; if we get here, it succeeded
_logger.LogInformation(
"Rescan job enqueued: runId={RunId}, messageId={MessageId}, deduplicated={Deduplicated}",
run.Id,
result.MessageId,
result.Deduplicated);
return SchedulerJobResult.Succeeded(result.MessageId, run.Id);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Exception creating rescan job for unknown {UnknownId}", request.UnknownId);
return SchedulerJobResult.Failed(ex.Message);
}
}
public async Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
IReadOnlyList<RescanJobRequest> requests,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requests);
var results = new List<SchedulerJobResult>(requests.Count);
var successCount = 0;
var failureCount = 0;
foreach (var request in requests)
{
var result = await CreateRescanJobAsync(request, cancellationToken).ConfigureAwait(false);
results.Add(result);
if (result.Success)
{
successCount++;
}
else
{
failureCount++;
}
}
return new BatchSchedulerJobResult(
requests.Count,
successCount,
failureCount,
results);
}
private (Run Run, ImpactSet ImpactSet) CreateRunAndImpactSet(RescanJobRequest request)
{
var now = _timeProvider.GetUtcNow();
var runId = $"rescan-{request.UnknownId}-{now:yyyyMMddHHmmss}";
var run = new Run(
id: runId,
tenantId: request.TenantId,
trigger: RunTrigger.Manual, // Triggered by unknowns escalation
state: RunState.Planning,
stats: RunStats.Empty,
createdAt: now,
reason: new RunReason(manualReason: $"Unknowns rescan for {request.PackageUrl}"));
// Create a selector targeting the specific package by purl
// We use ByRepository scope with the purl as the repository identifier
var selector = new Selector(
scope: SelectorScope.ByRepository,
tenantId: request.TenantId,
repositories: new[] { ExtractRepositoryFromPurl(request.PackageUrl) });
var impactSet = new ImpactSet(
selector: selector,
images: ImmutableArray<ImpactImage>.Empty, // Will be resolved by planner
usageOnly: false,
generatedAt: now,
total: 0);
return (run, impactSet);
}
private static string ExtractRepositoryFromPurl(string purl)
{
// Parse purl to extract repository name
// Format: pkg:type/namespace/name@version
// We want: namespace/name
if (string.IsNullOrEmpty(purl))
{
return "unknown";
}
// Remove pkg: prefix
var purlBody = purl.StartsWith("pkg:", StringComparison.OrdinalIgnoreCase)
? purl[4..]
: purl;
// Remove version suffix
var atIndex = purlBody.IndexOf('@');
if (atIndex > 0)
{
purlBody = purlBody[..atIndex];
}
// Skip type prefix (e.g., "npm/", "maven/", "nuget/")
var slashIndex = purlBody.IndexOf('/');
if (slashIndex > 0)
{
return purlBody[(slashIndex + 1)..];
}
return purlBody;
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Signals.Services;
namespace StellaOps.Signals.Scheduler;
/// <summary>
/// Extension methods for registering Scheduler integration services.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the Scheduler-integrated rescan orchestrator.
/// Requires <see cref="StellaOps.Scheduler.Queue.ISchedulerPlannerQueue"/> to be registered.
/// </summary>
public static IServiceCollection AddSchedulerRescanOrchestrator(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
// Register the Scheduler queue job client
services.TryAddSingleton<ISchedulerJobClient, SchedulerQueueJobClient>();
// Register the orchestrator that uses the job client
services.TryAddSingleton<IRescanOrchestrator, SchedulerRescanOrchestrator>();
return services;
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Signals\StellaOps.Signals.csproj" />
<ProjectReference Include="..\..\Scheduler\__Libraries\StellaOps.Scheduler.Queue\StellaOps.Scheduler.Queue.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,84 @@
namespace StellaOps.Signals.Services;
/// <summary>
/// Abstraction for creating rescan jobs in the scheduler.
/// Allows Signals to integrate with the Scheduler module without tight coupling.
/// </summary>
public interface ISchedulerJobClient
{
/// <summary>
/// Creates a targeted rescan job for a specific package.
/// </summary>
/// <param name="request">The rescan job request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Result indicating success or failure with job ID.</returns>
Task<SchedulerJobResult> CreateRescanJobAsync(
RescanJobRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates multiple rescan jobs in a batch.
/// </summary>
Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
IReadOnlyList<RescanJobRequest> requests,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Request for creating a rescan job.
/// </summary>
/// <param name="TenantId">Tenant identifier.</param>
/// <param name="UnknownId">ID of the unknown being rescanned.</param>
/// <param name="PackageUrl">Package URL (purl) to rescan.</param>
/// <param name="PackageVersion">Version to rescan (optional).</param>
/// <param name="Priority">Job priority level.</param>
/// <param name="CorrelationId">Correlation ID for tracing.</param>
public sealed record RescanJobRequest(
string TenantId,
string UnknownId,
string PackageUrl,
string? PackageVersion,
RescanJobPriority Priority,
string? CorrelationId = null);
/// <summary>
/// Priority level for rescan jobs.
/// </summary>
public enum RescanJobPriority
{
/// <summary>Immediate processing (HOT band).</summary>
High,
/// <summary>Normal processing (WARM band).</summary>
Normal,
/// <summary>Low priority batch processing (COLD band).</summary>
Low
}
/// <summary>
/// Result from creating a scheduler job.
/// </summary>
/// <param name="Success">Whether the job was created.</param>
/// <param name="JobId">Scheduler job ID if successful.</param>
/// <param name="RunId">Run ID in the scheduler.</param>
/// <param name="ErrorMessage">Error message if failed.</param>
public sealed record SchedulerJobResult(
bool Success,
string? JobId = null,
string? RunId = null,
string? ErrorMessage = null)
{
public static SchedulerJobResult Succeeded(string jobId, string runId)
=> new(true, jobId, runId);
public static SchedulerJobResult Failed(string error)
=> new(false, ErrorMessage: error);
}
/// <summary>
/// Result from batch job creation.
/// </summary>
public sealed record BatchSchedulerJobResult(
int TotalRequested,
int SuccessCount,
int FailureCount,
IReadOnlyList<SchedulerJobResult> Results);

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Signals.Services;
/// <summary>
/// Null implementation of <see cref="ISchedulerJobClient"/> that logs requests
/// but does not actually create jobs. Used when Scheduler integration is not configured.
/// </summary>
public sealed class NullSchedulerJobClient : ISchedulerJobClient
{
private readonly TimeProvider _timeProvider;
private readonly ILogger<NullSchedulerJobClient> _logger;
public NullSchedulerJobClient(
TimeProvider timeProvider,
ILogger<NullSchedulerJobClient> logger)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task<SchedulerJobResult> CreateRescanJobAsync(
RescanJobRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
_logger.LogDebug(
"Null scheduler client: Would create rescan job for unknown {UnknownId} (purl={Purl})",
request.UnknownId,
request.PackageUrl);
// Generate a fake job ID for testing/development
var jobId = $"null-job-{Guid.NewGuid():N}";
var runId = $"null-run-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}";
return Task.FromResult(SchedulerJobResult.Succeeded(jobId, runId));
}
public Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
IReadOnlyList<RescanJobRequest> requests,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requests);
_logger.LogDebug(
"Null scheduler client: Would create {Count} rescan jobs",
requests.Count);
var results = requests
.Select(r =>
{
var jobId = $"null-job-{Guid.NewGuid():N}";
var runId = $"null-run-{_timeProvider.GetUtcNow():yyyyMMddHHmmss}";
return SchedulerJobResult.Succeeded(jobId, runId);
})
.ToList();
return Task.FromResult(new BatchSchedulerJobResult(
requests.Count,
requests.Count,
0,
results));
}
}

View File

@@ -0,0 +1,189 @@
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
namespace StellaOps.Signals.Services;
/// <summary>
/// Implementation of <see cref="IRescanOrchestrator"/> that integrates with
/// the Scheduler module via <see cref="ISchedulerJobClient"/>.
/// </summary>
public sealed class SchedulerRescanOrchestrator : IRescanOrchestrator
{
private readonly ISchedulerJobClient _schedulerClient;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SchedulerRescanOrchestrator> _logger;
public SchedulerRescanOrchestrator(
ISchedulerJobClient schedulerClient,
TimeProvider timeProvider,
ILogger<SchedulerRescanOrchestrator> logger)
{
_schedulerClient = schedulerClient ?? throw new ArgumentNullException(nameof(schedulerClient));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RescanResult> TriggerRescanAsync(
UnknownSymbolDocument unknown,
RescanPriority priority,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(unknown);
var request = CreateJobRequest(unknown, priority);
_logger.LogInformation(
"Creating rescan job for unknown {UnknownId} (purl={Purl}, priority={Priority})",
unknown.Id,
unknown.Purl,
priority);
try
{
var result = await _schedulerClient.CreateRescanJobAsync(request, cancellationToken)
.ConfigureAwait(false);
if (result.Success)
{
_logger.LogDebug(
"Rescan job {JobId} created for unknown {UnknownId}",
result.JobId,
unknown.Id);
return new RescanResult(
unknown.Id,
Success: true,
NextScheduledRescan: ComputeNextRescan(priority));
}
_logger.LogWarning(
"Failed to create rescan job for unknown {UnknownId}: {Error}",
unknown.Id,
result.ErrorMessage);
return new RescanResult(
unknown.Id,
Success: false,
ErrorMessage: result.ErrorMessage);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Exception creating rescan job for unknown {UnknownId}", unknown.Id);
return new RescanResult(
unknown.Id,
Success: false,
ErrorMessage: ex.Message);
}
}
public async Task<BatchRescanResult> TriggerBatchRescanAsync(
IReadOnlyList<UnknownSymbolDocument> unknowns,
RescanPriority priority,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(unknowns);
if (unknowns.Count == 0)
{
return new BatchRescanResult(0, 0, 0, []);
}
var requests = unknowns
.Select(u => CreateJobRequest(u, priority))
.ToList();
_logger.LogInformation(
"Creating {Count} rescan jobs with priority {Priority}",
requests.Count,
priority);
try
{
var batchResult = await _schedulerClient.CreateRescanJobsAsync(requests, cancellationToken)
.ConfigureAwait(false);
var rescanResults = batchResult.Results
.Zip(unknowns, (jobResult, unknown) => new RescanResult(
unknown.Id,
jobResult.Success,
jobResult.ErrorMessage,
jobResult.Success ? ComputeNextRescan(priority) : null))
.ToList();
_logger.LogInformation(
"Batch rescan complete: {Success}/{Total} succeeded",
batchResult.SuccessCount,
batchResult.TotalRequested);
return new BatchRescanResult(
batchResult.TotalRequested,
batchResult.SuccessCount,
batchResult.FailureCount,
rescanResults);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Exception in batch rescan for {Count} unknowns", unknowns.Count);
var failedResults = unknowns
.Select(u => new RescanResult(u.Id, Success: false, ErrorMessage: ex.Message))
.ToList();
return new BatchRescanResult(
unknowns.Count,
0,
unknowns.Count,
failedResults);
}
}
private RescanJobRequest CreateJobRequest(UnknownSymbolDocument unknown, RescanPriority priority)
{
var jobPriority = priority switch
{
RescanPriority.Immediate => RescanJobPriority.High,
RescanPriority.Scheduled => RescanJobPriority.Normal,
_ => RescanJobPriority.Low
};
// Extract tenant from the unknown context
// For now, use a default tenant if not available
var tenantId = ExtractTenantId(unknown);
return new RescanJobRequest(
TenantId: tenantId,
UnknownId: unknown.Id,
PackageUrl: unknown.Purl ?? unknown.SubjectKey,
PackageVersion: unknown.PurlVersion,
Priority: jobPriority,
CorrelationId: unknown.CallgraphId);
}
private static string ExtractTenantId(UnknownSymbolDocument unknown)
{
// The CallgraphId often follows pattern: {tenant}:{graph-id}
// If not available, use a default
if (string.IsNullOrEmpty(unknown.CallgraphId))
{
return "default";
}
var colonIndex = unknown.CallgraphId.IndexOf(':', StringComparison.Ordinal);
return colonIndex > 0
? unknown.CallgraphId[..colonIndex]
: "default";
}
private DateTimeOffset ComputeNextRescan(RescanPriority priority)
{
var now = _timeProvider.GetUtcNow();
return priority switch
{
RescanPriority.Immediate => now.AddMinutes(15), // Re-evaluate after 15 min
RescanPriority.Scheduled => now.AddHours(24), // Next day for WARM
_ => now.AddDays(7) // Weekly for COLD
};
}
}

View File

@@ -0,0 +1,274 @@
using FluentAssertions;
using Microsoft.Extensions.Logging;
using StellaOps.Signals.Models;
using StellaOps.Signals.Services;
using Xunit;
namespace StellaOps.Signals.Tests;
public class SchedulerRescanOrchestratorTests
{
private readonly MockSchedulerJobClient _mockClient = new();
private readonly FakeTimeProvider _timeProvider = new();
private readonly ILogger<SchedulerRescanOrchestrator> _logger;
private readonly SchedulerRescanOrchestrator _sut;
public SchedulerRescanOrchestratorTests()
{
_logger = LoggerFactory.Create(b => b.AddDebug()).CreateLogger<SchedulerRescanOrchestrator>();
_sut = new SchedulerRescanOrchestrator(_mockClient, _timeProvider, _logger);
}
[Fact]
public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Immediate()
{
// Arrange
var unknown = CreateUnknown("pkg:npm/lodash@4.17.21", UnknownsBand.Hot);
// Act
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
// Assert
result.Success.Should().BeTrue();
_mockClient.LastRequest.Should().NotBeNull();
_mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.High);
_mockClient.LastRequest.PackageUrl.Should().Be("pkg:npm/lodash@4.17.21");
}
[Fact]
public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Scheduled()
{
// Arrange
var unknown = CreateUnknown("pkg:maven/com.example/lib@1.0.0", UnknownsBand.Warm);
// Act
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Scheduled);
// Assert
result.Success.Should().BeTrue();
_mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.Normal);
}
[Fact]
public async Task TriggerRescanAsync_CreatesJobWithCorrectPriority_Batch()
{
// Arrange
var unknown = CreateUnknown("pkg:nuget/newtonsoft.json@13.0.1", UnknownsBand.Cold);
// Act
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Batch);
// Assert
result.Success.Should().BeTrue();
_mockClient.LastRequest!.Priority.Should().Be(RescanJobPriority.Low);
}
[Fact]
public async Task TriggerRescanAsync_PropagatesCorrelationId()
{
// Arrange
var unknown = CreateUnknown("pkg:pypi/requests@2.28.0", UnknownsBand.Hot);
unknown.CallgraphId = "tenant123:graph456";
// Act
await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
// Assert
_mockClient.LastRequest!.CorrelationId.Should().Be("tenant123:graph456");
_mockClient.LastRequest.TenantId.Should().Be("tenant123");
}
[Fact]
public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForImmediate()
{
// Arrange
var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(now);
var unknown = CreateUnknown("pkg:npm/axios@1.0.0", UnknownsBand.Hot);
// Act
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
// Assert
result.NextScheduledRescan.Should().Be(now.AddMinutes(15));
}
[Fact]
public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForScheduled()
{
// Arrange
var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(now);
var unknown = CreateUnknown("pkg:npm/express@4.18.0", UnknownsBand.Warm);
// Act
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Scheduled);
// Assert
result.NextScheduledRescan.Should().Be(now.AddHours(24));
}
[Fact]
public async Task TriggerRescanAsync_ReturnsNextScheduledRescan_ForBatch()
{
// Arrange
var now = new DateTimeOffset(2025, 1, 20, 10, 0, 0, TimeSpan.Zero);
_timeProvider.SetUtcNow(now);
var unknown = CreateUnknown("pkg:npm/mocha@10.0.0", UnknownsBand.Cold);
// Act
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Batch);
// Assert
result.NextScheduledRescan.Should().Be(now.AddDays(7));
}
[Fact]
public async Task TriggerRescanAsync_ReturnsFailure_WhenClientFails()
{
// Arrange
_mockClient.ShouldFail = true;
_mockClient.FailureMessage = "Queue unavailable";
var unknown = CreateUnknown("pkg:npm/fail@1.0.0", UnknownsBand.Hot);
// Act
var result = await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
// Assert
result.Success.Should().BeFalse();
result.ErrorMessage.Should().Be("Queue unavailable");
}
[Fact]
public async Task TriggerBatchRescanAsync_ProcessesAllItems()
{
// Arrange
var unknowns = new[]
{
CreateUnknown("pkg:npm/a@1.0.0", UnknownsBand.Hot),
CreateUnknown("pkg:npm/b@1.0.0", UnknownsBand.Hot),
CreateUnknown("pkg:npm/c@1.0.0", UnknownsBand.Hot)
};
// Act
var result = await _sut.TriggerBatchRescanAsync(unknowns, RescanPriority.Immediate);
// Assert
result.TotalRequested.Should().Be(3);
result.SuccessCount.Should().Be(3);
result.FailureCount.Should().Be(0);
result.Results.Should().HaveCount(3);
}
[Fact]
public async Task TriggerBatchRescanAsync_EmptyList_ReturnsEmpty()
{
// Arrange
var unknowns = Array.Empty<UnknownSymbolDocument>();
// Act
var result = await _sut.TriggerBatchRescanAsync(unknowns, RescanPriority.Immediate);
// Assert
result.TotalRequested.Should().Be(0);
result.SuccessCount.Should().Be(0);
result.FailureCount.Should().Be(0);
}
[Fact]
public async Task TriggerRescanAsync_ExtractsTenantFromCallgraphId()
{
// Arrange
var unknown = CreateUnknown("pkg:npm/test@1.0.0", UnknownsBand.Hot);
unknown.CallgraphId = "acme-corp:cg-12345";
// Act
await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
// Assert
_mockClient.LastRequest!.TenantId.Should().Be("acme-corp");
}
[Fact]
public async Task TriggerRescanAsync_UsesDefaultTenant_WhenNoCallgraphId()
{
// Arrange
var unknown = CreateUnknown("pkg:npm/orphan@1.0.0", UnknownsBand.Hot);
unknown.CallgraphId = null;
// Act
await _sut.TriggerRescanAsync(unknown, RescanPriority.Immediate);
// Assert
_mockClient.LastRequest!.TenantId.Should().Be("default");
}
private static UnknownSymbolDocument CreateUnknown(string purl, UnknownsBand band)
{
return new UnknownSymbolDocument
{
Id = Guid.NewGuid().ToString("N"),
SubjectKey = purl,
Purl = purl,
Band = band,
Score = band switch
{
UnknownsBand.Hot => 0.85,
UnknownsBand.Warm => 0.55,
UnknownsBand.Cold => 0.35,
_ => 0.15
}
};
}
private sealed class MockSchedulerJobClient : ISchedulerJobClient
{
public RescanJobRequest? LastRequest { get; private set; }
public bool ShouldFail { get; set; }
public string FailureMessage { get; set; } = "Mock failure";
public Task<SchedulerJobResult> CreateRescanJobAsync(
RescanJobRequest request,
CancellationToken cancellationToken = default)
{
LastRequest = request;
if (ShouldFail)
{
return Task.FromResult(SchedulerJobResult.Failed(FailureMessage));
}
var jobId = $"mock-job-{Guid.NewGuid():N}";
var runId = $"mock-run-{DateTime.UtcNow:yyyyMMddHHmmss}";
return Task.FromResult(SchedulerJobResult.Succeeded(jobId, runId));
}
public async Task<BatchSchedulerJobResult> CreateRescanJobsAsync(
IReadOnlyList<RescanJobRequest> requests,
CancellationToken cancellationToken = default)
{
var results = new List<SchedulerJobResult>();
foreach (var request in requests)
{
var result = await CreateRescanJobAsync(request, cancellationToken);
results.Add(result);
}
return new BatchSchedulerJobResult(
requests.Count,
results.Count(r => r.Success),
results.Count(r => !r.Success),
results);
}
}
private sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _utcNow = DateTimeOffset.UtcNow;
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
public override DateTimeOffset GetUtcNow() => _utcNow;
}
}