work
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-25 08:01:23 +02:00
parent d92973d6fd
commit 6bee1fdcf5
207 changed files with 12816 additions and 2295 deletions

View File

@@ -1,10 +1,11 @@
using System;
using FluentAssertions;
using StellaOps.Concelier.Connector.Cccs.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Html;
using StellaOps.Concelier.Storage.Mongo.Documents;
using Xunit;
using System;
using FluentAssertions;
using StellaOps.Concelier.Connector.Cccs.Internal;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Html;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Documents;
using Xunit;
namespace StellaOps.Concelier.Connector.Cccs.Tests.Internal;
@@ -35,9 +36,17 @@ public sealed class CccsMapperTests
advisory.AdvisoryKey.Should().Be("TEST-001");
advisory.Title.Should().Be(dto.Title);
advisory.Aliases.Should().Contain(new[] { "TEST-001", "CVE-2020-1234", "CVE-2021-9999" });
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
advisory.AffectedPackages.Should().HaveCount(2);
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
}
}
advisory.References.Should().Contain(reference => reference.Url == dto.CanonicalUrl && reference.Kind == "details");
advisory.References.Should().Contain(reference => reference.Url == "https://example.com/details");
advisory.AffectedPackages.Should().HaveCount(2);
advisory.Provenance.Should().ContainSingle(p => p.Source == CccsConnectorPlugin.SourceName && p.Kind == "advisory");
var first = advisory.AffectedPackages[0];
first.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "1.0");
first.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:0" && rule.Value == "1.0");
var second = advisory.AffectedPackages[1];
second.VersionRanges.Should().ContainSingle(range => range.RangeKind == NormalizedVersionSchemes.SemVer && range.RangeExpression == "2.0");
second.NormalizedVersions.Should().ContainSingle(rule => rule.Notes == "cccs:TEST-001:1" && rule.Value == "2.0");
}
}

View File

@@ -12,13 +12,14 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using StellaOps.Concelier.Connector.CertBund.Configuration;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Connector.Common.Http;
using StellaOps.Concelier.Connector.Common;
using StellaOps.Concelier.Connector.Common.Fetch;
using StellaOps.Concelier.Connector.Common.Testing;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Documents;
using StellaOps.Concelier.Storage.Mongo.Dtos;
using StellaOps.Concelier.Testing;
using Xunit;
@@ -57,16 +58,27 @@ public sealed class CertBundConnectorTests : IAsyncLifetime
advisories.Should().HaveCount(1);
var advisory = advisories[0];
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
advisory.Aliases.Should().Contain("CVE-2025-1234");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
advisory.Language.Should().Be("de");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
advisory.AdvisoryKey.Should().Be("WID-SEC-2025-2264");
advisory.Aliases.Should().Contain("CVE-2025-1234");
advisory.AffectedPackages.Should().Contain(package => package.Identifier.Contains("Ivanti"));
advisory.References.Should().Contain(reference => reference.Url == DetailUri.ToString());
advisory.Language.Should().Be("de");
var endpoint = advisory.AffectedPackages.Should().ContainSingle(p => p.Identifier.Contains("Endpoint Manager") && !p.Identifier.Contains("Cloud"))
.Subject;
endpoint.VersionRanges.Should().ContainSingle(range =>
range.RangeKind == NormalizedVersionSchemes.SemVer &&
range.IntroducedVersion == "2023.1" &&
range.FixedVersion == "2024.2");
endpoint.NormalizedVersions.Should().ContainSingle(rule =>
rule.Min == "2023.1" &&
rule.Max == "2024.2" &&
rule.Notes == "certbund:WID-SEC-2025-2264:ivanti");
var stateRepository = provider.GetRequiredService<ISourceStateRepository>();
var state = await stateRepository.TryGetAsync(CertBundConnectorPlugin.SourceName, CancellationToken.None);
state.Should().NotBeNull();
state!.Cursor.Should().NotBeNull();
state.Cursor.TryGetValue("pendingDocuments", out var pendingDocs).Should().BeTrue();
pendingDocs!.AsBsonArray.Should().BeEmpty();
state.Cursor.TryGetValue("pendingMappings", out var pendingMappings).Should().BeTrue();

View File

@@ -8,6 +8,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
[Fact]
[Trait("Category", "Attestation")]
public async Task BuildAsync_ProducesClaimsFromSampleBundle()
{
var sampleDir = Path.Combine(RepoRoot, "docs", "samples", "evidence-bundle");
@@ -22,7 +23,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
tarPath,
manifestPath,
transparencyPath,
pipelineVersion: "git:test-sha"),
"git:test-sha"),
CancellationToken.None);
Assert.Equal("evidence-bundle-m0", claims.SubjectName);
@@ -38,6 +39,7 @@ public sealed class EvidenceBundleAttestationBuilderTests
}
[Fact]
[Trait("Category", "Attestation")]
public async Task BuildAsync_EnforcesLowercaseTenant()
{
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
@@ -64,4 +66,30 @@ public sealed class EvidenceBundleAttestationBuilderTests
Assert.Contains("Tenant must be lowercase", ex.Message);
}
[Fact]
[Trait("Category", "Attestation")]
public async Task BuildAsync_RequiresTenant()
{
var tempManifest = Path.Combine(Path.GetTempPath(), $"manifest-{Guid.NewGuid():N}.json");
var manifest = """
{
"bundle_id": "test-bundle",
"version": "1.0.0",
"created": "2025-11-19T00:00:00Z",
"scope": "vex"
}
""";
await File.WriteAllTextAsync(tempManifest, manifest);
var tempTar = Path.Combine(Path.GetTempPath(), $"bundle-{Guid.NewGuid():N}.tar.gz");
await File.WriteAllTextAsync(tempTar, "dummy");
var builder = new EvidenceBundleAttestationBuilder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
builder.BuildAsync(new EvidenceBundleAttestationRequest(tempTar, tempManifest, null, "git:test"), CancellationToken.None));
Assert.Contains("Tenant must be present", ex.Message);
}
}

View File

@@ -0,0 +1,23 @@
using System.IO;
using System.Threading.Tasks;
using StellaOps.Concelier.Core.Attestation;
using Xunit;
namespace StellaOps.Concelier.Core.Tests.Attestation;
public class EvidenceBundleAttestationValidator
{
[Fact]
public async Task BuildAsync_RejectsMissingTenant()
{
var bundle = Path.GetTempFileName();
var manifest = Path.GetTempFileName();
await File.WriteAllTextAsync(bundle, "dummy");
await File.WriteAllTextAsync(manifest, "{\"tenant\":\"ACME\"}");
var builder = new EvidenceBundleAttestationBuilder();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
builder.BuildAsync(new EvidenceBundleAttestationRequest(bundle, manifest, null, "git:test")));
}
}

View File

@@ -0,0 +1,75 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Concelier.WebService.AirGap;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests.AirGap;
public class AirgapBundleBuilderTests
{
[Fact]
public async Task BuildAsync_WritesDeterministicNdjson()
{
var builder = new AirgapBundleBuilder();
var created = DateTimeOffset.Parse("2025-11-01T00:00:00Z");
var items = new[]
{
"b:2",
"a:1",
"c:3",
"a:1" // duplicate should still appear twice to preserve raw cache content
};
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-test");
try
{
var result = await builder.BuildAsync(items, tempDir.FullName, created);
var lines = await File.ReadAllLinesAsync(result.BundlePath);
Assert.Equal(4, lines.Length);
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, lines);
Assert.False(string.IsNullOrWhiteSpace(result.Sha256));
Assert.Equal(4, result.ItemCount);
Assert.True(File.Exists(result.ManifestPath));
var manifestJson = await File.ReadAllTextAsync(result.ManifestPath);
var manifest = System.Text.Json.JsonSerializer.Deserialize<AirgapBundleManifest>(manifestJson)!;
Assert.Equal(result.Sha256, manifest.BundleSha256);
Assert.Equal(4, manifest.Count);
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }, manifest.Items);
Assert.Equal(new[] { "a:1", "a:1", "b:2", "c:3" }.Select(v => v.GetDeterministicHash()), manifest.Entries.Select(e => e.Sha256));
Assert.Equal(created, manifest.CreatedUtc);
var manifestJsonFirstRun = manifestJson;
var entryTraceJsonFirstRun = await File.ReadAllTextAsync(result.EntryTracePath);
// Second run should produce identical hash
var result2 = await builder.BuildAsync(items, tempDir.FullName, created);
Assert.Equal(result.Sha256, result2.Sha256);
Assert.Equal(result.ManifestPath, result2.ManifestPath); // paths stable in same directory
var manifestJsonSecondRun = await File.ReadAllTextAsync(result2.ManifestPath);
var entryTraceJsonSecondRun = await File.ReadAllTextAsync(result2.EntryTracePath);
Assert.Equal(manifestJsonFirstRun, manifestJsonSecondRun);
Assert.Equal(entryTraceJsonFirstRun, entryTraceJsonSecondRun);
}
finally
{
tempDir.Delete(recursive: true);
}
}
}
internal static class HashTestExtensions
{
public static string GetDeterministicHash(this string content)
{
using var sha = System.Security.Cryptography.SHA256.Create();
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
return System.Convert.ToHexString(sha.ComputeHash(bytes)).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,60 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using StellaOps.Concelier.WebService.AirGap;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests.AirGap;
public class AirgapBundleValidatorTests
{
[Fact]
public async Task ValidateAsync_Succeeds_ForBuilderOutput()
{
var builder = new AirgapBundleBuilder();
var validator = new AirgapBundleValidator();
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator");
try
{
var items = new[] { "b:2", "a:1" };
var result = await builder.BuildAsync(items, tempDir.FullName);
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
Assert.True(validation.IsValid, string.Join(";", validation.Errors));
}
finally
{
tempDir.Delete(recursive: true);
}
}
[Fact]
public async Task ValidateAsync_Fails_WhenManifestTampered()
{
var builder = new AirgapBundleBuilder();
var validator = new AirgapBundleValidator();
var tempDir = Directory.CreateTempSubdirectory("concelier-airgap-validator-bad");
try
{
var items = new[] { "b:2", "a:1" };
var result = await builder.BuildAsync(items, tempDir.FullName);
// Tamper manifest count
var manifest = await File.ReadAllTextAsync(result.ManifestPath);
manifest = manifest.Replace("\"count\":2", "\"count\":3");
await File.WriteAllTextAsync(result.ManifestPath, manifest);
var validation = await validator.ValidateAsync(result.BundlePath, result.ManifestPath, result.EntryTracePath);
Assert.False(validation.IsValid);
Assert.Contains(validation.Errors, e => e.Contains("count", System.StringComparison.OrdinalIgnoreCase));
}
finally
{
tempDir.Delete(recursive: true);
}
}
}

View File

@@ -1,20 +1,99 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.WebService.Options;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests;
public class ConcelierHealthEndpointTests : IClassFixture<WebApplicationFactory<Program>>
public sealed class HealthWebAppFactory : WebApplicationFactory<Program>
{
private readonly WebApplicationFactory<Program> _factory;
public ConcelierHealthEndpointTests(WebApplicationFactory<Program> factory)
public HealthWebAppFactory()
{
_factory = factory.WithWebHostBuilder(_ => { });
// Ensure options binder sees required storage values before Program.Main executes.
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__DRIVER", "mongo");
Environment.SetEnvironmentVariable("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
Environment.SetEnvironmentVariable("CONCELIER__TELEMETRY__ENABLED", "false");
Environment.SetEnvironmentVariable("CONCELIER_SKIP_OPTIONS_VALIDATION", "1");
Environment.SetEnvironmentVariable("CONCELIER_TEST_STORAGE_DSN", "mongodb://localhost:27017/test-health");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Testing");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Testing");
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
var overrides = new Dictionary<string, string?>
{
{"Storage:Dsn", "mongodb://localhost:27017/test-health"},
{"Storage:Driver", "mongo"},
{"Storage:CommandTimeoutSeconds", "30"},
{"Telemetry:Enabled", "false"}
};
config.AddInMemoryCollection(overrides);
});
builder.UseSetting("CONCELIER__STORAGE__DSN", "mongodb://localhost:27017/test-health");
builder.UseSetting("CONCELIER__STORAGE__DRIVER", "mongo");
builder.UseSetting("CONCELIER__STORAGE__COMMANDTIMEOUTSECONDS", "30");
builder.UseSetting("CONCELIER__TELEMETRY__ENABLED", "false");
builder.UseEnvironment("Testing");
builder.ConfigureServices(services =>
{
services.AddSingleton<ConcelierOptions>(new ConcelierOptions
{
Storage = new ConcelierOptions.StorageOptions
{
Dsn = "mongodb://localhost:27017/test-health",
Driver = "mongo",
CommandTimeoutSeconds = 30
},
Telemetry = new ConcelierOptions.TelemetryOptions
{
Enabled = false
}
});
services.AddSingleton<IConfigureOptions<ConcelierOptions>>(sp => new ConfigureOptions<ConcelierOptions>(opts =>
{
opts.Storage ??= new ConcelierOptions.StorageOptions();
opts.Storage.Driver = "mongo";
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
opts.Storage.CommandTimeoutSeconds = 30;
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
}));
services.PostConfigure<ConcelierOptions>(opts =>
{
opts.Storage ??= new ConcelierOptions.StorageOptions();
opts.Storage.Driver = "mongo";
opts.Storage.Dsn = "mongodb://localhost:27017/test-health";
opts.Storage.CommandTimeoutSeconds = 30;
opts.Telemetry ??= new ConcelierOptions.TelemetryOptions();
opts.Telemetry.Enabled = false;
});
});
}
}
public class ConcelierHealthEndpointTests : IClassFixture<HealthWebAppFactory>
{
private readonly HealthWebAppFactory _factory;
public ConcelierHealthEndpointTests(HealthWebAppFactory factory) => _factory = factory;
[Fact]
public async Task Health_requires_tenant_header()
{

View File

@@ -0,0 +1,43 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using StellaOps.Concelier.WebService.Services;
using StellaOps.Concelier.WebService;
using Xunit;
namespace StellaOps.Concelier.WebService.Tests.Services;
public sealed class IncidentFileStoreTests
{
[Fact]
public async Task WriteReadDelete_RoundTripsIncident()
{
var temp = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "incident-store-tests", Path.GetRandomFileName()));
var options = new ConcelierOptions
{
Evidence = new ConcelierOptions.EvidenceBundleOptions
{
RootAbsolute = temp.FullName,
DefaultManifestFileName = "manifest.json",
DefaultTransparencyFileName = "transparency.json",
PipelineVersion = "git:test",
},
};
var now = new DateTimeOffset(2025, 11, 25, 12, 0, 0, TimeSpan.Zero);
await IncidentFileStore.WriteAsync(options.Evidence!, "tenant-a", "ADV-1", "test-reason", 30, options.Evidence!.PipelineVersion, now, CancellationToken.None);
var status = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
status.Should().NotBeNull();
status!.Reason.Should().Be("test-reason");
status.Active.Should().BeTrue();
status.Tenant.Should().Be("tenant-a");
status.AdvisoryKey.Should().Be("ADV-1");
status.PipelineVersion.Should().Be("git:test");
await IncidentFileStore.DeleteAsync(options.Evidence!, "tenant-a", "ADV-1", CancellationToken.None);
var afterDelete = await IncidentFileStore.ReadAsync(options.Evidence!, "tenant-a", "ADV-1", now, CancellationToken.None);
afterDelete.Should().BeNull();
}
}

View File

@@ -12,6 +12,7 @@
<CopyOutputSymbolsToOutputDirectory>true</CopyOutputSymbolsToOutputDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Concelier.Storage.Mongo/StellaOps.Concelier.Storage.Mongo.csproj" />
<ProjectReference Include="../../StellaOps.Concelier.WebService/StellaOps.Concelier.WebService.csproj" />

View File

@@ -27,6 +27,8 @@ using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Driver;
using StellaOps.Concelier.Core.Attestation;
using static StellaOps.Concelier.WebService.Program;
using StellaOps.Concelier.Core.Events;
using StellaOps.Concelier.Core.Jobs;
using StellaOps.Concelier.Models;
@@ -39,6 +41,7 @@ using StellaOps.Concelier.Core.Raw;
using StellaOps.Concelier.WebService.Jobs;
using StellaOps.Concelier.WebService.Options;
using StellaOps.Concelier.WebService.Contracts;
using StellaOps.Concelier.WebService;
using Xunit.Sdk;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
@@ -73,7 +76,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
public Task InitializeAsync()
{
PrepareMongoEnvironment();
if (TryStartExternalMongo(out var externalConnectionString))
if (TryStartExternalMongo(out var externalConnectionString) && !string.IsNullOrWhiteSpace(externalConnectionString))
{
_factory = new ConcelierApplicationFactory(externalConnectionString);
}
@@ -381,6 +384,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal("ADV-002", firstItem.GetProperty("advisoryId").GetString());
Assert.Contains("pkg:npm/demo@2.0.0", firstItem.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
Assert.True(firstItem.GetProperty("conflicts").EnumerateArray().Count() >= 0);
Assert.Equal("created", firstItem.GetProperty("timeline").EnumerateArray().First().GetProperty("event").GetString());
Assert.Equal(DateTime.Parse("2025-01-06T00:00:00Z"), firstItem.GetProperty("publishedAt").GetDateTime());
var detailResponse = await client.GetAsync("/v1/lnm/linksets/ADV-001?source=osv&includeObservations=true");
detailResponse.EnsureSuccessStatusCode();
@@ -390,6 +395,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal("osv", detailPayload.GetProperty("source").GetString());
Assert.Contains("pkg:npm/demo@1.0.0", detailPayload.GetProperty("purl").EnumerateArray().Select(x => x.GetString()));
Assert.Contains("obs-1", detailPayload.GetProperty("observations").EnumerateArray().Select(x => x.GetString()));
Assert.Equal(DateTime.Parse("2025-01-05T00:00:00Z"), detailPayload.GetProperty("publishedAt").GetDateTime());
}
[Fact]
@@ -713,6 +719,66 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal(tarPath, evidence.Attestation.EvidenceBundlePath);
}
[Fact]
[Trait("Category", "Attestation")]
public async Task InternalAttestationVerify_ReturnsClaims()
{
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
var sampleDir = Path.Combine(repoRoot, "docs", "samples", "evidence-bundle");
var tarPath = Path.Combine(sampleDir, "evidence-bundle-m0.tar.gz");
var manifestPath = Path.Combine(sampleDir, "manifest.json");
var transparencyPath = Path.Combine(sampleDir, "transparency.json");
using var scope = _factory.Services.CreateScope();
var concOptions = scope.ServiceProvider.GetRequiredService<IOptions<ConcelierOptions>>().Value;
_output.WriteLine($"EvidenceRoot={concOptions.Evidence.RootAbsolute}");
Assert.StartsWith(concOptions.Evidence.RootAbsolute, tarPath, StringComparison.OrdinalIgnoreCase);
using var client = _factory.CreateClient();
var request = new VerifyAttestationRequest(tarPath, manifestPath, transparencyPath, "git:test-sha");
var response = await client.PostAsJsonAsync("/internal/attestations/verify?tenant=demo", request);
var responseBody = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"Attestation verify failed: {(int)response.StatusCode} {response.StatusCode} · {responseBody}");
var claims = JsonSerializer.Deserialize<AttestationClaims>(
responseBody,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(claims);
Assert.Equal("evidence-bundle-m0", claims!.SubjectName);
Assert.Equal("git:test-sha", claims.PipelineVersion);
Assert.Equal(tarPath, claims.EvidenceBundlePath);
}
[Fact]
public async Task EvidenceBatch_ReturnsEmptyCollectionsWhenUnknown()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add(TenantHeaderName, "demo");
var request = new EvidenceBatchRequest(
new[]
{
new EvidenceBatchItemRequest("component-a", new[] { "pkg:purl/example@1.0.0" }, new[] { "ALIAS-1" })
},
ObservationLimit: 5,
LinksetLimit: 5);
var response = await client.PostAsJsonAsync("/v1/evidence/batch", request);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<EvidenceBatchResponse>();
Assert.NotNull(payload);
var item = Assert.Single(payload!.Items);
Assert.Equal("component-a", item.ComponentId);
Assert.Empty(item.Observations);
Assert.Empty(item.Linksets);
Assert.False(item.HasMore);
}
[Fact]
public async Task AdvisoryEvidenceEndpoint_FiltersByVendor()
{
@@ -1300,7 +1366,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var payload = await response.Content.ReadFromJsonAsync<ReplayResponse>();
Assert.NotNull(payload);
var conflict = Assert.Single(payload!.Conflicts);
var conflicts = payload!.Conflicts ?? throw new XunitException("Conflicts was null");
var conflict = Assert.Single(conflicts);
Assert.Equal(conflictId, conflict.ConflictId);
Assert.Equal("severity", conflict.Explainer.Type);
Assert.Equal("mismatch", conflict.Explainer.Reason);
@@ -1977,6 +2044,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
_previousTelemetryLogging = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING");
_previousTelemetryTracing = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING");
_previousTelemetryMetrics = Environment.GetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS");
var opensslPath = ResolveOpenSsl11Path();
if (!string.IsNullOrEmpty(opensslPath))
{
var currentLd = Environment.GetEnvironmentVariable("LD_LIBRARY_PATH");
var merged = string.IsNullOrWhiteSpace(currentLd)
? opensslPath
: string.Join(':', opensslPath, currentLd);
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", merged);
}
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DSN", connectionString);
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__DRIVER", "mongo");
Environment.SetEnvironmentVariable("CONCELIER_STORAGE__COMMANDTIMEOUTSECONDS", "30");
@@ -1984,6 +2062,10 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLELOGGING", "false");
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLETRACING", "false");
Environment.SetEnvironmentVariable("CONCELIER_TELEMETRY__ENABLEMETRICS", "false");
const string EvidenceRootKey = "CONCELIER_EVIDENCE__ROOT";
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
_additionalPreviousEnvironment[EvidenceRootKey] = Environment.GetEnvironmentVariable(EvidenceRootKey);
Environment.SetEnvironmentVariable(EvidenceRootKey, repoRoot);
const string TestSecretKey = "CONCELIER_AUTHORITY__TESTSIGNINGSECRET";
if (environmentOverrides is null || !environmentOverrides.ContainsKey(TestSecretKey))
{
@@ -2002,6 +2084,23 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
}
}
private static string? ResolveOpenSsl11Path()
{
var current = AppContext.BaseDirectory;
for (var i = 0; i < 8; i++)
{
var candidate = Path.GetFullPath(Path.Combine(current, "tests", "native", "openssl-1.1", "linux-x64"));
if (Directory.Exists(candidate))
{
return candidate;
}
current = Path.GetFullPath(Path.Combine(current, ".."));
}
return null;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((context, configurationBuilder) =>
@@ -2035,7 +2134,17 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
options.Telemetry.EnableMetrics = false;
options.Authority ??= new ConcelierOptions.AuthorityOptions();
_authorityConfigure?.Invoke(options.Authority);
// Point evidence root at the repo so sample bundles under docs/samples/evidence-bundle resolve without 400.
var repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "..", "..", ".."));
options.Evidence.Root = repoRoot;
options.Evidence.RootAbsolute = repoRoot;
});
// Ensure content root + wwwroot exist so host startup does not throw when WebService bin output isn't present.
var contentRoot = AppContext.BaseDirectory;
var wwwroot = Path.Combine(contentRoot, "wwwroot");
Directory.CreateDirectory(wwwroot);
});
builder.ConfigureTestServices(services =>
@@ -3093,4 +3202,5 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
return Task.FromResult<IReadOnlyDictionary<string, JobRunSnapshot>>(map);
}
}
}