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, IAsyncLifetime, IAsyncDisposable { /// /// Default tenant identifier injected into every test client via the /// X-StellaOps-Tenant header so that RequireTenant() endpoint filters /// resolve a valid tenant context without requiring per-request configuration. /// Tests that need a specific tenant can override this header per request. /// public const string DefaultTestTenant = "default"; private readonly bool skipPostgres; private readonly Dictionary 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>? configureConfiguration; private Action? 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"; } /// /// Creates a lightweight factory that skips PostgreSQL/Testcontainers initialization. /// Use this for tests that mock all database services. /// public static ScannerApplicationFactory CreateLightweight() => new(skipPostgres: true); // Note: Made internal to satisfy xUnit fixture requirement of single public constructor internal ScannerApplicationFactory( Action>? configureConfiguration, Action? configureServices) : this() { this.configureConfiguration = configureConfiguration; this.configureServices = configureServices; } public ScannerApplicationFactory WithOverrides( Action>? configureConfiguration = null, Action? 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(); } } /// /// Creates an HTTP client with the default test tenant header pre-configured. /// All endpoints under MapGroup(...).RequireTenant() 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 /// request.Headers.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, "other-tenant"). /// public new HttpClient CreateClient() { var client = base.CreateClient(); client.DefaultRequestHeaders.TryAddWithoutValidation(StellaOpsHttpHeaderNames.Tenant, DefaultTestTenant); return client; } /// /// Creates an HTTP client with the given options and the default test tenant header pre-configured. /// This override ensures that callers using CreateClient(options) also receive the tenant /// header, which is required by RequireTenant() endpoint filters. /// 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(); services.AddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); if (skipPostgres) { // Remove all hosted services that require PostgreSQL (migrations, etc.) services.RemoveAll(); } if (useTestAuthentication) { // Replace real JWT authentication with test handler services.AddAuthentication(options => { options.DefaultAuthenticateScheme = TestAuthenticationHandler.SchemeName; options.DefaultChallengeScheme = TestAuthenticationHandler.SchemeName; }).AddScheme( TestAuthenticationHandler.SchemeName, _ => { }); } }); } private sealed class TestSurfaceValidatorRunner : ISurfaceValidatorRunner { public ValueTask 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 QueryAsync(SliceQueryRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new SliceQueryResponse { SliceDigest = "sha256:null", Verdict = "unknown", Confidence = 0.0, CacheHit = false }); public Task GetSliceAsync(string digest, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task GetSliceDsseAsync(string digest, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ReplayAsync(SliceReplayRequest request, CancellationToken cancellationToken = default) => Task.FromResult(new SliceReplayResponse { Match = true, OriginalDigest = request.SliceDigest ?? "sha256:null", RecomputedDigest = request.SliceDigest ?? "sha256:null" }); } /// /// Test authentication handler for security integration tests. /// Validates tokens based on simple rules for testing authorization behavior. /// internal sealed class TestAuthenticationHandler : AuthenticationHandler { public const string SchemeName = "TestBearer"; public TestAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } protected override Task 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 { 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)); } } }