save progress

This commit is contained in:
StellaOps Bot
2026-01-03 11:02:24 +02:00
parent ca578801fd
commit 83c37243e0
446 changed files with 22798 additions and 4031 deletions

View File

@@ -0,0 +1,18 @@
# BinaryIndex WebService Tests Charter
## Mission
Validate BinaryIndex WebService controller, cache wiring, and middleware behavior with deterministic tests.
## Responsibilities
- Keep tests deterministic (fixed time/IDs; use TimeProvider).
- Avoid network access; use fakes for Redis and service dependencies.
- Track task status in `TASKS.md`.
## Required Reading
- `docs/modules/binaryindex/architecture.md`
- `docs/modules/platform/architecture-overview.md`
## Working Agreement
- 1. Update task status in the sprint file and local `TASKS.md`.
- 2. Keep tests deterministic and offline-friendly.
- 3. Add coverage for controller error mapping, cache usage, and rate limiting.

View File

@@ -1,407 +1,492 @@
// -----------------------------------------------------------------------------
// ResolutionControllerIntegrationTests.cs
// Sprint: SPRINT_1227_0001_0002_BE_resolution_api
// Task: T9 — Integration tests for resolution API
// -----------------------------------------------------------------------------
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Cache;
using StellaOps.BinaryIndex.Contracts.Resolution;
using Xunit;
using StellaOps.BinaryIndex.Core.Resolution;
using StellaOps.BinaryIndex.WebService.Controllers;
using StellaOps.BinaryIndex.WebService.Middleware;
using StellaOps.BinaryIndex.WebService.Services;
namespace StellaOps.BinaryIndex.WebService.Tests;
/// <summary>
/// Integration tests for the Resolution API endpoints.
/// </summary>
[Trait("Category", "Integration")]
[Trait("Category", "BinaryIndex")]
public class ResolutionControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
public sealed class ResolutionControllerTests
{
private readonly WebApplicationFactory<Program> _factory;
private readonly HttpClient _client;
public ResolutionControllerIntegrationTests(WebApplicationFactory<Program> factory)
[Fact]
public async Task ResolveVulnerabilityAsync_UsesDefaultDsseSetting()
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Add test-specific services if needed
});
});
_client = _factory.CreateClient();
}
var fakeService = new CapturingResolutionService();
var options = Options.Create(new ResolutionServiceOptions { EnableDsseByDefault = false });
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
#region Single Resolution Tests
[Fact(DisplayName = "POST /api/v1/resolve/vuln returns 200 for valid request")]
public async Task ResolveVuln_ValidRequest_Returns200()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "abc123def456789",
DistroRelease = "debian:bookworm"
BuildId = "build-1"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await controller.ResolveVulnerabilityAsync(request, bypassCache: false, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
result.Should().NotBeNull();
result!.Package.Should().Be("pkg:deb/debian/openssl@3.0.7");
result.Status.Should().BeOneOf(ResolutionStatus.Fixed, ResolutionStatus.Vulnerable,
ResolutionStatus.NotAffected, ResolutionStatus.Unknown);
result.ResolvedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
result.Result.Should().BeOfType<OkObjectResult>();
fakeService.LastOptions.Should().NotBeNull();
fakeService.LastOptions!.IncludeDsseAttestation.Should().BeFalse();
}
[Fact(DisplayName = "POST /api/v1/resolve/vuln returns 400 for missing package")]
public async Task ResolveVuln_MissingPackage_Returns400()
[Fact]
public async Task ResolveVulnerabilityAsync_BadRequest_SetsProblemStatus()
{
// Arrange
var request = new { BuildId = "abc123" }; // Missing required Package field
var fakeService = new CapturingResolutionService();
var options = Options.Create(new ResolutionServiceOptions());
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var request = new VulnResolutionRequest
{
Package = "",
BuildId = "build-1"
};
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var result = await controller.ResolveVulnerabilityAsync(request, bypassCache: false, TestContext.Current.CancellationToken);
var badRequest = result.Result.Should().BeOfType<BadRequestObjectResult>().Subject;
var problem = badRequest.Value.Should().BeOfType<ProblemDetails>().Subject;
problem.Status.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact(DisplayName = "POST /api/v1/resolve/vuln with CVE returns targeted resolution")]
public async Task ResolveVuln_WithCveId_ReturnsTargetedResolution()
[Fact]
public async Task ResolveVulnerabilityAsync_Error_SetsProblemStatus()
{
// Arrange
var fakeService = new ThrowingResolutionService();
var options = Options.Create(new ResolutionServiceOptions());
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
CveId = "CVE-2024-0001",
BuildId = "abc123def456789"
BuildId = "build-1"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await controller.ResolveVulnerabilityAsync(request, bypassCache: false, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var error = result.Result.Should().BeOfType<ObjectResult>().Subject;
error.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
var problem = error.Value.Should().BeOfType<ProblemDetails>().Subject;
problem.Status.Should().Be(StatusCodes.Status500InternalServerError);
}
[Fact(DisplayName = "Resolution includes cache headers")]
public async Task ResolveVuln_IncludesCacheHeaders()
[Fact]
public async Task ResolveBatchAsync_UsesDefaultDsseSetting()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7"
};
var fakeService = new CapturingResolutionService();
var options = Options.Create(new ResolutionServiceOptions { EnableDsseByDefault = true });
var controller = new ResolutionController(fakeService, options, NullLogger<ResolutionController>.Instance);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.Headers.Should().ContainKey("X-RateLimit-Limit");
response.Headers.Should().ContainKey("X-RateLimit-Remaining");
}
#endregion
#region Batch Resolution Tests
[Fact(DisplayName = "POST /api/v1/resolve/vuln/batch handles multiple items")]
public async Task ResolveBatch_MultipleItems_ReturnsAllResults()
{
// Arrange
var request = new BatchVulnResolutionRequest
{
Items = new[]
{
new VulnResolutionRequest { Package = "pkg:deb/debian/openssl@3.0.7" },
new VulnResolutionRequest { Package = "pkg:deb/debian/libcurl@7.88.1" },
new VulnResolutionRequest { Package = "pkg:deb/debian/zlib@1.2.13" }
new VulnResolutionRequest { Package = "pkg:deb/debian/openssl@3.0.7", BuildId = "build-1" }
}
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
var result = await controller.ResolveBatchAsync(request, TestContext.Current.CancellationToken);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
result.Result.Should().BeOfType<OkObjectResult>();
fakeService.LastOptions.Should().NotBeNull();
fakeService.LastOptions!.IncludeDsseAttestation.Should().BeTrue();
}
}
var result = await response.Content.ReadFromJsonAsync<BatchVulnResolutionResponse>();
result.Should().NotBeNull();
result!.Results.Should().HaveCount(3);
public sealed class CachedResolutionServiceTests
{
private readonly FixedTimeProvider _timeProvider = new(new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero));
[Fact]
public async Task ResolveAsync_CachesResult_AndServesFromCache()
{
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions());
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "build-1"
};
var first = await service.ResolveAsync(request, null, TestContext.Current.CancellationToken);
var second = await service.ResolveAsync(request, null, TestContext.Current.CancellationToken);
first.FromCache.Should().BeFalse();
second.FromCache.Should().BeTrue();
fakeInner.ResolveCalls.Should().Be(1);
cache.SetCalls.Should().Be(1);
cache.GetCalls.Should().Be(2);
}
[Fact(DisplayName = "Batch resolution respects size limit")]
public async Task ResolveBatch_ExceedsSizeLimit_Returns400()
[Fact]
public async Task ResolveAsync_BypassCache_SkipsCache()
{
// Arrange - Create 501 items (assuming 500 is the limit)
var items = Enumerable.Range(0, 501)
.Select(i => new VulnResolutionRequest { Package = $"pkg:npm/package{i}@1.0.0" })
.ToArray();
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions());
var request = new BatchVulnResolutionRequest { Items = items };
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "build-2"
};
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var options = new ResolutionOptions { BypassCache = true };
var result = await service.ResolveAsync(request, options, TestContext.Current.CancellationToken);
result.FromCache.Should().BeFalse();
fakeInner.ResolveCalls.Should().Be(1);
cache.SetCalls.Should().Be(0);
cache.GetCalls.Should().Be(0);
}
[Fact(DisplayName = "Batch resolution performance under 500ms for 100 cached items")]
public async Task ResolveBatch_CachedItems_PerformanceAcceptable()
[Fact]
public async Task ResolveBatchAsync_UsesCacheForHits()
{
// Arrange
var items = Enumerable.Range(0, 100)
.Select(i => new VulnResolutionRequest
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions { MaxBatchSize = 10 });
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
var cachedRequest = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "build-3"
};
var cachedKey = cache.GenerateCacheKey(cachedRequest);
cache.Entries[cachedKey] = new CachedResolution
{
Status = ResolutionStatus.Fixed,
FixedVersion = "1.0.1",
CachedAt = _timeProvider.GetUtcNow(),
Confidence = 0.95m,
MatchType = ResolutionMatchTypes.BuildId
};
var batch = new BatchVulnResolutionRequest
{
Items = new[]
{
Package = $"pkg:deb/debian/test-package{i}@1.0.0",
BuildId = $"build-{i}"
})
.ToArray();
var request = new BatchVulnResolutionRequest { Items = items };
// Warm up cache with first request
await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
// Act
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln/batch", request);
stopwatch.Stop();
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
stopwatch.ElapsedMilliseconds.Should().BeLessThan(500,
"Cached batch resolution should complete in under 500ms");
}
#endregion
#region Cache Tests
[Fact(DisplayName = "Second request returns cached result")]
public async Task ResolveVuln_SecondRequest_ReturnsCachedResult()
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "cache-test-build-id"
cachedRequest,
new VulnResolutionRequest { Package = "pkg:deb/debian/curl@8.0.0", BuildId = "build-4" }
}
};
// Act
var response1 = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result1 = await response1.Content.ReadFromJsonAsync<VulnResolutionResponse>();
var response = await service.ResolveBatchAsync(batch, null, TestContext.Current.CancellationToken);
var response2 = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result2 = await response2.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
result1.Should().NotBeNull();
result2.Should().NotBeNull();
result2!.FromCache.Should().BeTrue();
result1!.Status.Should().Be(result2.Status);
response.CacheHits.Should().Be(1);
response.Results.Should().HaveCount(2);
response.Results[0].FromCache.Should().BeTrue();
response.Results[1].FromCache.Should().BeFalse();
}
[Fact(DisplayName = "Bypass cache option works")]
public async Task ResolveVuln_BypassCache_FreshResult()
[Fact]
public async Task ResolveBatchAsync_RespectsMaxBatchSize()
{
// Arrange
var request = new VulnResolutionRequest
var fakeInner = new FakeResolutionService(_timeProvider);
var cache = new FakeResolutionCacheService();
var cacheOptions = Options.Create(new ResolutionCacheOptions());
var serviceOptions = Options.Create(new ResolutionServiceOptions { MaxBatchSize = 1 });
var service = new CachedResolutionService(
fakeInner,
cache,
cacheOptions,
serviceOptions,
_timeProvider,
NullLogger<CachedResolutionService>.Instance);
var batch = new BatchVulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "bypass-cache-test"
Items = new[]
{
new VulnResolutionRequest { Package = "pkg:deb/debian/openssl@3.0.7", BuildId = "build-5" },
new VulnResolutionRequest { Package = "pkg:deb/debian/curl@8.0.0", BuildId = "build-6" }
}
};
// First request to populate cache
await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var response = await service.ResolveBatchAsync(batch, null, TestContext.Current.CancellationToken);
// Second request with bypass
_client.DefaultRequestHeaders.Add("X-Bypass-Cache", "true");
response.Results.Should().HaveCount(1);
}
}
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
public sealed class RateLimitingMiddlewareTests
{
[Fact]
public async Task InvokeAsync_Disabled_SkipsRateLimiting()
{
var options = Options.Create(new RateLimitingOptions
{
Enabled = false,
MaxRequests = 1
});
// Assert
result.Should().NotBeNull();
result!.FromCache.Should().BeFalse();
var called = false;
RequestDelegate next = _ =>
{
called = true;
return Task.CompletedTask;
};
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Bypass-Cache");
var middleware = new RateLimitingMiddleware(next, NullLogger<RateLimitingMiddleware>.Instance, options, null, new FixedTimeProvider());
var context = CreateContext();
await middleware.InvokeAsync(context);
called.Should().BeTrue();
context.Response.Headers.ContainsKey("X-RateLimit-Limit").Should().BeFalse();
}
#endregion
#region DSSE Attestation Tests
[Fact(DisplayName = "Response includes DSSE attestation when requested")]
public async Task ResolveVuln_WithDsseRequest_IncludesAttestation()
[Fact]
public async Task InvokeAsync_EnforcesLimit()
{
// Arrange
var request = new VulnResolutionRequest
var options = Options.Create(new RateLimitingOptions
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "dsse-test-build"
};
Enabled = true,
MaxRequests = 1,
WindowSize = TimeSpan.FromMinutes(1),
RetryAfterSeconds = 60,
CleanupEveryNRequests = 1,
EvictionAfter = TimeSpan.FromMinutes(5)
});
_client.DefaultRequestHeaders.Add("X-Include-Attestation", "true");
RequestDelegate next = _ => Task.CompletedTask;
var timeProvider = new FixedTimeProvider(new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero));
var middleware = new RateLimitingMiddleware(next, NullLogger<RateLimitingMiddleware>.Instance, options, null, timeProvider);
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
var firstContext = CreateContext();
await middleware.InvokeAsync(firstContext);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Note: Attestation may be null if signing is not configured
var secondContext = CreateContext();
await middleware.InvokeAsync(secondContext);
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Include-Attestation");
secondContext.Response.StatusCode.Should().Be(StatusCodes.Status429TooManyRequests);
secondContext.Response.Headers.ContainsKey("X-RateLimit-Remaining").Should().BeTrue();
secondContext.Response.Headers.ContainsKey("Retry-After").Should().BeTrue();
}
[Fact(DisplayName = "DSSE attestation is valid base64")]
public async Task ResolveVuln_DsseAttestation_IsValidBase64()
private static DefaultHttpContext CreateContext()
{
// Arrange
var request = new VulnResolutionRequest
var context = new DefaultHttpContext();
context.Request.Path = "/api/v1/resolve/vuln";
context.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1");
context.Response.Body = new MemoryStream();
return context;
}
}
internal sealed class FixedTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public FixedTimeProvider() : this(new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero))
{
}
public FixedTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) => _now = _now.Add(delta);
}
internal sealed class CapturingResolutionService : IResolutionService
{
public ResolutionOptions? LastOptions { get; private set; }
public Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
LastOptions = options;
var response = new VulnResolutionResponse
{
Package = "pkg:deb/debian/openssl@3.0.7",
BuildId = "dsse-validation-test"
Package = request.Package,
Status = ResolutionStatus.Unknown,
ResolvedAt = new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero),
FromCache = false,
CveId = request.CveId
};
_client.DefaultRequestHeaders.Add("X-Include-Attestation", "true");
return Task.FromResult(response);
}
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
if (!string.IsNullOrEmpty(result?.AttestationDsse))
public Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
LastOptions = options;
var results = request.Items.Select(item => new VulnResolutionResponse
{
// Should not throw
var bytes = Convert.FromBase64String(result.AttestationDsse);
bytes.Should().NotBeEmpty();
Package = item.Package,
Status = ResolutionStatus.Unknown,
ResolvedAt = new DateTimeOffset(2026, 1, 3, 12, 0, 0, TimeSpan.Zero),
FromCache = false,
CveId = item.CveId
}).ToList();
// Should be valid JSON
var json = System.Text.Encoding.UTF8.GetString(bytes);
var doc = JsonDocument.Parse(json);
doc.RootElement.TryGetProperty("payload", out _).Should().BeTrue();
doc.RootElement.TryGetProperty("payloadType", out _).Should().BeTrue();
return Task.FromResult(new BatchVulnResolutionResponse
{
Results = results,
TotalCount = results.Count,
CacheHits = 0,
ProcessingTimeMs = 0
});
}
}
internal sealed class ThrowingResolutionService : IResolutionService
{
public Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
throw new InvalidOperationException("boom");
}
public Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
throw new InvalidOperationException("boom");
}
}
internal sealed class FakeResolutionService : IResolutionService
{
private readonly FixedTimeProvider _timeProvider;
public FakeResolutionService(FixedTimeProvider timeProvider)
{
_timeProvider = timeProvider;
}
public int ResolveCalls { get; private set; }
public Task<VulnResolutionResponse> ResolveAsync(
VulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
ResolveCalls++;
var response = new VulnResolutionResponse
{
Package = request.Package,
Status = ResolutionStatus.Fixed,
FixedVersion = "1.0.1",
Evidence = new ResolutionEvidence
{
MatchType = ResolutionMatchTypes.BuildId,
Confidence = 0.95m
},
ResolvedAt = _timeProvider.GetUtcNow(),
FromCache = false,
CveId = request.CveId
};
return Task.FromResult(response);
}
public async Task<BatchVulnResolutionResponse> ResolveBatchAsync(
BatchVulnResolutionRequest request,
ResolutionOptions? options = null,
CancellationToken ct = default)
{
var results = new List<VulnResolutionResponse>();
foreach (var item in request.Items)
{
results.Add(await ResolveAsync(item, options, ct));
}
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Include-Attestation");
}
#endregion
#region Rate Limiting Tests
[Fact(DisplayName = "Rate limiting returns 429 when exceeded")]
public async Task ResolveVuln_RateLimitExceeded_Returns429()
{
// Arrange - This test depends on rate limit configuration
// Create a client with test tenant that has low rate limit
var request = new VulnResolutionRequest
return new BatchVulnResolutionResponse
{
Package = "pkg:npm/rate-limit-test@1.0.0"
Results = results,
TotalCount = results.Count,
CacheHits = 0,
ProcessingTimeMs = 0
};
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "rate-limit-test-tenant");
// Act - Send many requests quickly
var tasks = Enumerable.Range(0, 150)
.Select(_ => _client.PostAsJsonAsync("/api/v1/resolve/vuln", request));
var responses = await Task.WhenAll(tasks);
// Assert - At least some should be rate limited
var rateLimited = responses.Where(r => r.StatusCode == HttpStatusCode.TooManyRequests);
// Note: This may pass or fail depending on actual rate limit config
// Cleanup
_client.DefaultRequestHeaders.Remove("X-Tenant-Id");
}
}
[Fact(DisplayName = "Rate limit headers are present")]
public async Task ResolveVuln_RateLimitHeaders_Present()
internal sealed class FakeResolutionCacheService : IResolutionCacheService
{
public Dictionary<string, CachedResolution> Entries { get; } = new();
public int GetCalls { get; private set; }
public int SetCalls { get; private set; }
public Task<CachedResolution?> GetAsync(string cacheKey, CancellationToken ct = default)
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:npm/headers-test@1.0.0"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
// Assert
response.Headers.Contains("X-RateLimit-Limit").Should().BeTrue();
response.Headers.Contains("X-RateLimit-Remaining").Should().BeTrue();
response.Headers.Contains("X-RateLimit-Reset").Should().BeTrue();
GetCalls++;
return Task.FromResult(Entries.TryGetValue(cacheKey, out var cached) ? cached : null);
}
#endregion
#region Evidence Tests
[Fact(DisplayName = "Fixed resolution includes evidence")]
public async Task ResolveVuln_FixedStatus_IncludesEvidence()
public Task SetAsync(string cacheKey, CachedResolution result, TimeSpan ttl, CancellationToken ct = default)
{
// Arrange
var request = new VulnResolutionRequest
{
Package = "pkg:deb/debian/openssl@3.0.7-1+deb12u1",
BuildId = "fixed-binary-build-id",
DistroRelease = "debian:bookworm"
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/resolve/vuln", request);
var result = await response.Content.ReadFromJsonAsync<VulnResolutionResponse>();
// Assert
if (result?.Status == ResolutionStatus.Fixed)
{
result.Evidence.Should().NotBeNull();
result.Evidence!.MatchType.Should().NotBeNullOrEmpty();
result.Evidence.Confidence.Should().BeGreaterThan(0);
}
SetCalls++;
Entries[cacheKey] = result;
return Task.CompletedTask;
}
#endregion
}
public Task InvalidateByPatternAsync(string pattern, CancellationToken ct = default)
{
return Task.CompletedTask;
}
/// <summary>
/// Placeholder for batch request if not in Contracts.
/// </summary>
public record BatchVulnResolutionRequest
{
public VulnResolutionRequest[] Items { get; init; } = Array.Empty<VulnResolutionRequest>();
public ResolutionOptions? Options { get; init; }
}
public record BatchVulnResolutionResponse
{
public VulnResolutionResponse[] Results { get; init; } = Array.Empty<VulnResolutionResponse>();
public int TotalCount { get; init; }
public int SuccessCount { get; init; }
public int ErrorCount { get; init; }
}
public record ResolutionOptions
{
public bool BypassCache { get; init; }
public bool IncludeDsseAttestation { get; init; }
public string GenerateCacheKey(VulnResolutionRequest request)
{
return string.Join(":", new[]
{
"resolution",
request.Package,
request.CveId ?? "all",
request.BuildId ?? "",
request.Hashes?.FileSha256 ?? "",
request.Hashes?.TextSha256 ?? "",
request.Fingerprint ?? ""
});
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.BinaryIndex.WebService/StellaOps.BinaryIndex.WebService.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.BinaryIndex.Cache/StellaOps.BinaryIndex.Cache.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.BinaryIndex.Core/StellaOps.BinaryIndex.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.BinaryIndex.Contracts/StellaOps.BinaryIndex.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
# BinaryIndex WebService Tests Task Board
This board mirrors active sprint tasks for this module.
Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.md`.
| Task ID | Status | Notes |
| --- | --- | --- |
| AUDIT-0129-A | DONE | Added deterministic controller/cache/middleware tests. |