Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.WebService.Tests/ScannerApplicationFactory.cs

429 lines
17 KiB
C#

using System.Collections.Generic;
using System.Net.Http;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Auth.Abstractions;
using StellaOps.Infrastructure.Postgres.Testing;
using StellaOps.Scanner.Reachability.Slices;
using StellaOps.Scanner.Storage;
using StellaOps.Scanner.Surface.Validation;
using StellaOps.Scanner.Triage;
using StellaOps.Determinism;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Services;
using Xunit;
namespace StellaOps.Scanner.WebService.Tests;
public sealed class ScannerApplicationFactory : WebApplicationFactory<ServiceStatus>, IAsyncLifetime, IAsyncDisposable
{
/// <summary>
/// Default tenant identifier injected into every test client via the
/// <c>X-StellaOps-Tenant</c> header so that <c>RequireTenant()</c> endpoint filters
/// resolve a valid tenant context without requiring per-request configuration.
/// Tests that need a specific tenant can override this header per request.
/// </summary>
public const string DefaultTestTenant = "default";
private readonly bool skipPostgres;
private readonly Dictionary<string, string?> configuration = new(StringComparer.OrdinalIgnoreCase)
{
["scanner:api:basePath"] = "/api/v1",
["scanner:storage:driver"] = "postgres",
["scanner:storage:dsn"] = string.Empty,
["scanner:storage:database"] = string.Empty,
["scanner:queue:driver"] = "redis",
["scanner:queue:dsn"] = "redis://localhost:6379",
["scanner:artifactStore:driver"] = "rustfs",
["scanner:artifactStore:endpoint"] = "https://rustfs.local/api/v1/",
["scanner:artifactStore:accessKey"] = "test-access",
["scanner:artifactStore:secretKey"] = "test-secret",
["scanner:artifactStore:bucket"] = "scanner-artifacts",
["scanner:artifactStore:timeoutSeconds"] = "30",
["scanner:telemetry:minimumLogLevel"] = "Information",
["scanner:telemetry:enableRequestLogging"] = "false",
["scanner:events:enabled"] = "false",
["scanner:features:enableSignedReports"] = "false",
["scanner:offlineKit:requireDsse"] = "false",
["scanner:offlineKit:rekorOfflineMode"] = "false"
};
private Action<IDictionary<string, string?>>? configureConfiguration;
private Action<IServiceCollection>? configureServices;
private bool useTestAuthentication;
private ScannerWebServicePostgresFixture? postgresFixture;
private Task? initializationTask;
private bool initialized;
private bool disposed;
public ScannerApplicationFactory() : this(skipPostgres: false)
{
}
private ScannerApplicationFactory(bool skipPostgres)
{
this.skipPostgres = skipPostgres;
initialized = skipPostgres;
if (!skipPostgres)
{
return;
}
// Lightweight mode: use stub connection string
configuration["scanner:storage:dsn"] = "Host=localhost;Database=test;";
configuration["scanner:storage:database"] = "test";
}
/// <summary>
/// Creates a lightweight factory that skips PostgreSQL/Testcontainers initialization.
/// Use this for tests that mock all database services.
/// </summary>
public static ScannerApplicationFactory CreateLightweight() => new(skipPostgres: true);
// Note: Made internal to satisfy xUnit fixture requirement of single public constructor
internal ScannerApplicationFactory(
Action<IDictionary<string, string?>>? configureConfiguration,
Action<IServiceCollection>? configureServices)
: this()
{
this.configureConfiguration = configureConfiguration;
this.configureServices = configureServices;
}
public ScannerApplicationFactory WithOverrides(
Action<IDictionary<string, string?>>? configureConfiguration = null,
Action<IServiceCollection>? configureServices = null,
bool useTestAuthentication = false)
{
this.configureConfiguration = configureConfiguration;
this.configureServices = configureServices;
this.useTestAuthentication = useTestAuthentication;
return this;
}
public ValueTask InitializeAsync()
{
initializationTask ??= InitializeCoreAsync();
return new ValueTask(initializationTask);
}
private async Task InitializeCoreAsync()
{
if (initialized)
{
return;
}
if (skipPostgres)
{
initialized = true;
return;
}
postgresFixture = new ScannerWebServicePostgresFixture();
await postgresFixture.InitializeAsync();
var connectionBuilder = new NpgsqlConnectionStringBuilder(postgresFixture.ConnectionString)
{
SearchPath = $"{postgresFixture.SchemaName},public"
};
configuration["scanner:storage:dsn"] = connectionBuilder.ToString();
configuration["scanner:storage:database"] = postgresFixture.SchemaName;
initialized = true;
}
public override async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
base.Dispose();
if (postgresFixture is not null)
{
await postgresFixture.DisposeAsync();
}
}
/// <summary>
/// Creates an HTTP client with the default test tenant header pre-configured.
/// All endpoints under <c>MapGroup(...).RequireTenant()</c> require a resolved tenant
/// context; this header satisfies that requirement for generic test scenarios.
/// Tests that need a different tenant can override the header per request using
/// <c>request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "other-tenant")</c>.
/// </summary>
public new HttpClient CreateClient()
{
var client = base.CreateClient();
client.DefaultRequestHeaders.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, DefaultTestTenant);
return client;
}
/// <summary>
/// Creates an HTTP client with the given options and the default test tenant header pre-configured.
/// This override ensures that callers using <c>CreateClient(options)</c> also receive the tenant
/// header, which is required by <c>RequireTenant()</c> endpoint filters.
/// </summary>
public new HttpClient CreateClient(WebApplicationFactoryClientOptions options)
{
var client = base.CreateClient(options);
client.DefaultRequestHeaders.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, DefaultTestTenant);
return client;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
if (!initialized)
{
throw new InvalidOperationException("ScannerApplicationFactory must be initialized via InitializeAsync before use.");
}
configureConfiguration?.Invoke(configuration);
builder.UseEnvironment("Testing");
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ENABLED", null);
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ALLOWANONYMOUSFALLBACK", null);
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ISSUER", null);
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__AUDIENCES__0", null);
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTID", null);
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTSECRET", null);
Environment.SetEnvironmentVariable("SCANNER__STORAGE__DSN", configuration["scanner:storage:dsn"]);
Environment.SetEnvironmentVariable("SCANNER__STORAGE__DATABASE", configuration["scanner:storage:database"]);
Environment.SetEnvironmentVariable("SCANNER__QUEUE__DSN", configuration["scanner:queue:dsn"]);
Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ENDPOINT", configuration["scanner:artifactStore:endpoint"]);
Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__ACCESSKEY", configuration["scanner:artifactStore:accessKey"]);
Environment.SetEnvironmentVariable("SCANNER__ARTIFACTSTORE__SECRETKEY", configuration["scanner:artifactStore:secretKey"]);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_ENDPOINT", "https://surface.local");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_FS_BUCKET", configuration["scanner:artifactStore:bucket"]);
Environment.SetEnvironmentVariable("SCANNER_SURFACE_PREFETCH_ENABLED", "false");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_PROVIDER", "file");
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_ROOT", Path.GetTempPath());
Environment.SetEnvironmentVariable("SCANNER_SURFACE_SECRETS_TENANT", "tenant-a");
if (configuration.TryGetValue("scanner:events:enabled", out var eventsEnabled))
{
Environment.SetEnvironmentVariable("SCANNER__EVENTS__ENABLED", eventsEnabled);
}
if (configuration.TryGetValue("scanner:authority:enabled", out var authorityEnabled))
{
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ENABLED", authorityEnabled);
}
if (configuration.TryGetValue("scanner:authority:allowAnonymousFallback", out var allowAnonymous))
{
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ALLOWANONYMOUSFALLBACK", allowAnonymous);
}
if (configuration.TryGetValue("scanner:authority:issuer", out var authorityIssuer))
{
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__ISSUER", authorityIssuer);
}
if (configuration.TryGetValue("scanner:authority:audiences:0", out var primaryAudience))
{
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__AUDIENCES__0", primaryAudience);
}
if (configuration.TryGetValue("scanner:authority:clientId", out var clientId))
{
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTID", clientId);
}
if (configuration.TryGetValue("scanner:authority:clientSecret", out var clientSecret))
{
Environment.SetEnvironmentVariable("SCANNER__AUTHORITY__CLIENTSECRET", clientSecret);
}
builder.ConfigureAppConfiguration((_, configBuilder) =>
{
configBuilder.AddInMemoryCollection(configuration);
});
builder.ConfigureTestServices(services =>
{
configureServices?.Invoke(services);
services.RemoveAll<ISurfaceValidatorRunner>();
services.AddSingleton<ISurfaceValidatorRunner, TestSurfaceValidatorRunner>();
services.TryAddSingleton<ISliceQueryService, NullSliceQueryService>();
services.TryAddSingleton<IGuidProvider, SystemGuidProvider>();
if (skipPostgres)
{
// Remove all hosted services that require PostgreSQL (migrations, etc.)
services.RemoveAll<IHostedService>();
}
if (useTestAuthentication)
{
// Replace real JWT authentication with test handler
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthenticationHandler.SchemeName;
options.DefaultChallengeScheme = TestAuthenticationHandler.SchemeName;
}).AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
TestAuthenticationHandler.SchemeName, _ => { });
}
});
}
private sealed class TestSurfaceValidatorRunner : ISurfaceValidatorRunner
{
public ValueTask<SurfaceValidationResult> RunAllAsync(
SurfaceValidationContext context,
CancellationToken cancellationToken = default)
=> ValueTask.FromResult(SurfaceValidationResult.Success());
public ValueTask EnsureAsync(
SurfaceValidationContext context,
CancellationToken cancellationToken = default)
=> ValueTask.CompletedTask;
}
private sealed class ScannerWebServicePostgresFixture : PostgresIntegrationFixture
{
protected override System.Reflection.Assembly? GetMigrationAssembly() => typeof(ScannerStorageOptions).Assembly;
protected override string GetModuleName() => "Scanner.Storage.WebService.Tests";
public override async ValueTask InitializeAsync()
{
await base.InitializeAsync();
var migrationsPath = Path.Combine(
ResolveRepoRoot(),
"src",
"Scanner",
"__Libraries",
"StellaOps.Scanner.Triage",
"Migrations");
if (!Directory.Exists(migrationsPath))
{
throw new DirectoryNotFoundException($"Triage migrations not found at {migrationsPath}");
}
await Fixture.RunMigrationsAsync(migrationsPath, "Scanner.Triage.WebService.Tests");
}
private static string ResolveRepoRoot()
{
var baseDirectory = AppContext.BaseDirectory;
return Path.GetFullPath(Path.Combine(
baseDirectory,
"..",
"..",
"..",
"..",
"..",
"..",
".."));
}
}
private sealed class NullSliceQueryService : ISliceQueryService
{
public Task<SliceQueryResponse> QueryAsync(SliceQueryRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new SliceQueryResponse
{
SliceDigest = "sha256:null",
Verdict = "unknown",
Confidence = 0.0,
CacheHit = false
});
public Task<ReachabilitySlice?> GetSliceAsync(string digest, CancellationToken cancellationToken = default)
=> Task.FromResult<ReachabilitySlice?>(null);
public Task<object?> GetSliceDsseAsync(string digest, CancellationToken cancellationToken = default)
=> Task.FromResult<object?>(null);
public Task<SliceReplayResponse> ReplayAsync(SliceReplayRequest request, CancellationToken cancellationToken = default)
=> Task.FromResult(new SliceReplayResponse
{
Match = true,
OriginalDigest = request.SliceDigest ?? "sha256:null",
RecomputedDigest = request.SliceDigest ?? "sha256:null"
});
}
/// <summary>
/// Test authentication handler for security integration tests.
/// Validates tokens based on simple rules for testing authorization behavior.
/// </summary>
internal sealed class TestAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "TestBearer";
public TestAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authorization) || authorization.Count == 0)
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var header = authorization[0];
if (string.IsNullOrWhiteSpace(header) || !header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(AuthenticateResult.Fail("Invalid authentication scheme."));
}
var tokenValue = header.Substring("Bearer ".Length);
// Reject malformed/expired/invalid test tokens
if (string.IsNullOrWhiteSpace(tokenValue) ||
tokenValue == "expired.token.here" ||
tokenValue == "wrong.issuer.token" ||
tokenValue == "wrong.audience.token" ||
tokenValue == "not-a-jwt" ||
tokenValue.StartsWith("Bearer ") ||
!tokenValue.Contains('.') ||
tokenValue.Split('.').Length < 3)
{
return Task.FromResult(AuthenticateResult.Fail("Invalid token."));
}
// Valid test token format: scopes separated by spaces or a valid JWT-like format
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, "test-user") };
// Extract scopes from token if it looks like "scope1 scope2"
if (!tokenValue.Contains('.'))
{
var scopes = tokenValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (scopes.Length > 0)
{
claims.Add(new Claim("scope", string.Join(' ', scopes)));
}
}
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
}