429 lines
17 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|