up
Some checks failed
LNM Migration CI / build-runner (push) Has been cancelled
Ledger OpenAPI CI / deprecation-check (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Airgap Sealed CI Smoke / sealed-smoke (push) Has been cancelled
Ledger Packs CI / build-pack (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Ledger OpenAPI CI / validate-oas (push) Has been cancelled
Ledger OpenAPI CI / check-wellknown (push) Has been cancelled
Ledger Packs CI / verify-pack (push) Has been cancelled
LNM Migration CI / validate-metrics (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Some checks failed
LNM Migration CI / build-runner (push) Has been cancelled
Ledger OpenAPI CI / deprecation-check (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Airgap Sealed CI Smoke / sealed-smoke (push) Has been cancelled
Ledger Packs CI / build-pack (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Ledger OpenAPI CI / validate-oas (push) Has been cancelled
Ledger OpenAPI CI / check-wellknown (push) Has been cancelled
Ledger Packs CI / verify-pack (push) Has been cancelled
LNM Migration CI / validate-metrics (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
This commit is contained in:
@@ -39,6 +39,12 @@ public sealed class ZastavaRuntimeOptions
|
||||
|
||||
[Required]
|
||||
public ZastavaAuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Offline/air-gapped operation configuration.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public ZastavaOfflineOptions Offline { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ZastavaRuntimeLoggingOptions
|
||||
@@ -82,3 +88,62 @@ public sealed class ZastavaRuntimeMetricsOptions
|
||||
/// </summary>
|
||||
public IDictionary<string, string> CommonTags { get; init; } = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Offline/air-gapped operation configuration for Zastava components.
|
||||
/// Controls network access restrictions for secure, disconnected deployments.
|
||||
/// </summary>
|
||||
public sealed class ZastavaOfflineOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable strict offline mode. When true, any HTTP request to an external host
|
||||
/// (not in <see cref="AllowedHosts"/>) will throw an exception at request time.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool StrictMode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Require Surface.FS cache to be available and populated at startup.
|
||||
/// When true, the component will fail startup if the cache directory is missing
|
||||
/// or empty. Used with <see cref="StrictMode"/> for fully air-gapped deployments.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool RequireSurfaceCache { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to the Surface.FS cache directory containing pre-fetched vulnerability data.
|
||||
/// Required when <see cref="RequireSurfaceCache"/> is true.
|
||||
/// </summary>
|
||||
public string? SurfaceCachePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of cache entries required when <see cref="RequireSurfaceCache"/> is true.
|
||||
/// Ensures the cache has been properly populated before starting.
|
||||
/// Default: 1.
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int MinimumCacheEntries { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum age (in hours) of cache entries before they are considered stale.
|
||||
/// When <see cref="StrictMode"/> is true and all entries exceed this age,
|
||||
/// a warning is emitted but operation continues.
|
||||
/// Default: 168 (7 days).
|
||||
/// </summary>
|
||||
[Range(1, 8760)]
|
||||
public int MaxCacheAgeHours { get; init; } = 168;
|
||||
|
||||
/// <summary>
|
||||
/// List of hostnames explicitly allowed for network access in strict mode.
|
||||
/// Supports exact matches and wildcard prefixes (e.g., "*.internal.corp").
|
||||
/// Localhost (127.0.0.1, ::1, localhost) is always implicitly allowed.
|
||||
/// </summary>
|
||||
public IList<string> AllowedHosts { get; init; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// When true, emits detailed logs for each blocked network request.
|
||||
/// Useful for auditing network access patterns during initial deployment.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool LogBlockedRequests { get; init; }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Diagnostics;
|
||||
using StellaOps.Zastava.Core.Http;
|
||||
using StellaOps.Zastava.Core.Security;
|
||||
using StellaOps.Zastava.Core.Validation;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -45,9 +47,27 @@ public static class ZastavaServiceCollectionExtensions
|
||||
ConfigureAuthorityServices(services, configuration);
|
||||
services.TryAddSingleton<IZastavaAuthorityTokenProvider, ZastavaAuthorityTokenProvider>();
|
||||
|
||||
// Register offline strict mode handler for HttpClientFactory
|
||||
services.TryAddTransient<OfflineStrictModeHandler>();
|
||||
|
||||
// Register Surface.FS cache validator as hosted service
|
||||
// This validates cache availability at startup when RequireSurfaceCache is enabled
|
||||
services.AddHostedService<SurfaceCacheValidator>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the offline strict mode handler to an HttpClient configuration.
|
||||
/// When <see cref="ZastavaOfflineOptions.StrictMode"/> is enabled, requests to
|
||||
/// hosts not in the allowlist will be blocked.
|
||||
/// </summary>
|
||||
public static IHttpClientBuilder AddOfflineStrictModeHandler(this IHttpClientBuilder builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
return builder.AddHttpMessageHandler<OfflineStrictModeHandler>();
|
||||
}
|
||||
|
||||
private static void ConfigureAuthorityServices(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var authoritySection = configuration.GetSection($"{ZastavaRuntimeOptions.SectionName}:authority");
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Http;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP delegating handler that enforces strict offline mode.
|
||||
/// When <see cref="ZastavaOfflineOptions.StrictMode"/> is enabled, requests to
|
||||
/// hosts not in the allowlist will be rejected with an exception.
|
||||
/// </summary>
|
||||
public sealed class OfflineStrictModeHandler : DelegatingHandler
|
||||
{
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> _optionsMonitor;
|
||||
private readonly ILogger<OfflineStrictModeHandler> _logger;
|
||||
|
||||
// Implicitly allowed local hosts
|
||||
private static readonly HashSet<string> ImplicitlyAllowedHosts = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"[::1]"
|
||||
};
|
||||
|
||||
public OfflineStrictModeHandler(
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor,
|
||||
ILogger<OfflineStrictModeHandler> logger)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var options = _optionsMonitor.CurrentValue.Offline;
|
||||
|
||||
// If strict mode is not enabled, pass through
|
||||
if (!options.StrictMode)
|
||||
{
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var requestUri = request.RequestUri;
|
||||
if (requestUri is null)
|
||||
{
|
||||
throw new OfflineStrictModeException("Request URI is null - cannot validate against offline strict mode.");
|
||||
}
|
||||
|
||||
var host = requestUri.Host;
|
||||
|
||||
// Check if host is allowed
|
||||
if (!IsHostAllowed(host, options))
|
||||
{
|
||||
if (options.LogBlockedRequests)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Offline strict mode blocked request to {Host}{Path} (Method: {Method})",
|
||||
host,
|
||||
requestUri.PathAndQuery,
|
||||
request.Method);
|
||||
}
|
||||
|
||||
throw new OfflineStrictModeException(
|
||||
$"Offline strict mode is enabled. Request to external host '{host}' is not allowed. " +
|
||||
$"Add the host to zastava:runtime:offline:allowedHosts or disable strict mode.");
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static bool IsHostAllowed(string host, ZastavaOfflineOptions options)
|
||||
{
|
||||
// Implicitly allowed hosts (localhost, loopback)
|
||||
if (ImplicitlyAllowedHosts.Contains(host))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for loopback IP patterns
|
||||
if (host.StartsWith("127.", StringComparison.Ordinal) ||
|
||||
host.StartsWith("[::ffff:127.", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check explicit allowlist
|
||||
if (options.AllowedHosts.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var allowedHost in options.AllowedHosts)
|
||||
{
|
||||
if (MatchesHost(host, allowedHost))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesHost(string host, string pattern)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pattern))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard prefix match (e.g., "*.internal.corp")
|
||||
if (pattern.StartsWith("*.", StringComparison.Ordinal))
|
||||
{
|
||||
var suffix = pattern.Substring(1); // ".internal.corp"
|
||||
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(host, pattern.Substring(2), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a network request is blocked by offline strict mode.
|
||||
/// </summary>
|
||||
public sealed class OfflineStrictModeException : InvalidOperationException
|
||||
{
|
||||
public OfflineStrictModeException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public OfflineStrictModeException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Startup validator that ensures Surface.FS cache is available and populated
|
||||
/// when <see cref="ZastavaOfflineOptions.RequireSurfaceCache"/> is enabled.
|
||||
/// </summary>
|
||||
public sealed class SurfaceCacheValidator : IHostedService
|
||||
{
|
||||
private readonly IOptionsMonitor<ZastavaRuntimeOptions> _optionsMonitor;
|
||||
private readonly ILogger<SurfaceCacheValidator> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public SurfaceCacheValidator(
|
||||
IOptionsMonitor<ZastavaRuntimeOptions> optionsMonitor,
|
||||
ILogger<SurfaceCacheValidator> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue.Offline;
|
||||
|
||||
// Skip validation if RequireSurfaceCache is not enabled
|
||||
if (!options.RequireSurfaceCache)
|
||||
{
|
||||
_logger.LogDebug("Surface.FS cache validation skipped (RequireSurfaceCache=false)");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
ValidateCache(options);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private void ValidateCache(ZastavaOfflineOptions options)
|
||||
{
|
||||
var cachePath = options.SurfaceCachePath;
|
||||
|
||||
// Validate path is configured
|
||||
if (string.IsNullOrWhiteSpace(cachePath))
|
||||
{
|
||||
throw new SurfaceCacheValidationException(
|
||||
"Surface.FS cache path is required when RequireSurfaceCache is enabled. " +
|
||||
"Set zastava:runtime:offline:surfaceCachePath in configuration.");
|
||||
}
|
||||
|
||||
// Validate directory exists
|
||||
if (!Directory.Exists(cachePath))
|
||||
{
|
||||
throw new SurfaceCacheValidationException(
|
||||
$"Surface.FS cache directory does not exist: '{cachePath}'. " +
|
||||
"Ensure the cache has been populated before starting in offline mode.");
|
||||
}
|
||||
|
||||
// Count cache entries (files in the directory, excluding metadata files)
|
||||
var cacheEntries = GetCacheEntries(cachePath).ToList();
|
||||
var entryCount = cacheEntries.Count;
|
||||
|
||||
if (entryCount < options.MinimumCacheEntries)
|
||||
{
|
||||
throw new SurfaceCacheValidationException(
|
||||
$"Surface.FS cache has {entryCount} entries, but {options.MinimumCacheEntries} are required. " +
|
||||
"Populate the cache before starting in offline mode.");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Surface.FS cache validated: {EntryCount} entries found in {CachePath}",
|
||||
entryCount,
|
||||
cachePath);
|
||||
|
||||
// Check for stale cache entries
|
||||
CheckCacheStaleness(cacheEntries, options);
|
||||
}
|
||||
|
||||
private void CheckCacheStaleness(IReadOnlyList<CacheEntry> entries, ZastavaOfflineOptions options)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var maxAge = TimeSpan.FromHours(options.MaxCacheAgeHours);
|
||||
var staleThreshold = now - maxAge;
|
||||
|
||||
var staleCount = entries.Count(e => e.LastModified < staleThreshold);
|
||||
var freshCount = entries.Count - staleCount;
|
||||
|
||||
if (staleCount > 0)
|
||||
{
|
||||
var oldestEntry = entries.OrderBy(e => e.LastModified).FirstOrDefault();
|
||||
var oldestAge = oldestEntry is not null ? now - oldestEntry.LastModified : TimeSpan.Zero;
|
||||
|
||||
if (freshCount == 0)
|
||||
{
|
||||
// All entries are stale - warn but continue
|
||||
_logger.LogWarning(
|
||||
"All {StaleCount} Surface.FS cache entries are older than {MaxAge} hours. " +
|
||||
"Oldest entry is {OldestAge:N1} hours old. " +
|
||||
"Consider refreshing the cache for up-to-date vulnerability data.",
|
||||
staleCount,
|
||||
options.MaxCacheAgeHours,
|
||||
oldestAge.TotalHours);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Some entries are stale
|
||||
_logger.LogInformation(
|
||||
"Surface.FS cache status: {FreshCount} fresh, {StaleCount} stale " +
|
||||
"(threshold: {MaxAge} hours)",
|
||||
freshCount,
|
||||
staleCount,
|
||||
options.MaxCacheAgeHours);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"All {EntryCount} Surface.FS cache entries are within the {MaxAge} hour threshold",
|
||||
entries.Count,
|
||||
options.MaxCacheAgeHours);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<CacheEntry> GetCacheEntries(string cachePath)
|
||||
{
|
||||
// Cache entries are typically .json, .json.gz, or .ndjson files
|
||||
// Exclude metadata files like .manifest, .index, .lock
|
||||
var metadataExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".manifest",
|
||||
".index",
|
||||
".lock",
|
||||
".tmp",
|
||||
".partial"
|
||||
};
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(cachePath, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var extension = Path.GetExtension(file);
|
||||
|
||||
// Skip metadata files
|
||||
if (metadataExtensions.Contains(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip hidden files
|
||||
var fileName = Path.GetFileName(file);
|
||||
if (fileName.StartsWith('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(file);
|
||||
if (info.Length > 0) // Skip empty files
|
||||
{
|
||||
yield return new CacheEntry(file, info.LastWriteTimeUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CacheEntry(string Path, DateTimeOffset LastModified);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when Surface.FS cache validation fails at startup.
|
||||
/// </summary>
|
||||
public sealed class SurfaceCacheValidationException : InvalidOperationException
|
||||
{
|
||||
public SurfaceCacheValidationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SurfaceCacheValidationException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Zastava.Core.Configuration;
|
||||
using StellaOps.Zastava.Core.Http;
|
||||
using StellaOps.Zastava.Core.Validation;
|
||||
|
||||
namespace StellaOps.Zastava.Core.Tests.Validation;
|
||||
|
||||
public sealed class OfflineStrictModeTests : IDisposable
|
||||
{
|
||||
private readonly string _tempCachePath;
|
||||
|
||||
public OfflineStrictModeTests()
|
||||
{
|
||||
_tempCachePath = Path.Combine(Path.GetTempPath(), "zastava-test-cache-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempCachePath))
|
||||
{
|
||||
Directory.Delete(_tempCachePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
#region OfflineStrictModeHandler Tests
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineStrictModeHandler_WhenDisabled_AllowsAnyRequest()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(strictMode: false);
|
||||
var handler = CreateHandler(options);
|
||||
handler.InnerHandler = new TestHttpMessageHandler();
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("https://external.example.com/api/data");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineStrictModeHandler_WhenEnabled_BlocksExternalHost()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(strictMode: true);
|
||||
var handler = CreateHandler(options);
|
||||
handler.InnerHandler = new TestHttpMessageHandler();
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<OfflineStrictModeException>(
|
||||
() => client.GetAsync("https://external.example.com/api/data"));
|
||||
|
||||
Assert.Contains("external.example.com", exception.Message);
|
||||
Assert.Contains("offline strict mode", exception.Message.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineStrictModeHandler_WhenEnabled_AllowsLocalhost()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(strictMode: true);
|
||||
var handler = CreateHandler(options);
|
||||
handler.InnerHandler = new TestHttpMessageHandler();
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
// Act - localhost should be implicitly allowed
|
||||
var response = await client.GetAsync("http://localhost:8080/api/health");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineStrictModeHandler_WhenEnabled_AllowsLoopbackIp()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(strictMode: true);
|
||||
var handler = CreateHandler(options);
|
||||
handler.InnerHandler = new TestHttpMessageHandler();
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
// Act - 127.0.0.1 should be implicitly allowed
|
||||
var response = await client.GetAsync("http://127.0.0.1:8080/api/health");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineStrictModeHandler_WhenEnabled_AllowsExplicitlyAllowedHost()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(
|
||||
strictMode: true,
|
||||
allowedHosts: ["scanner.internal", "backend.corp"]);
|
||||
var handler = CreateHandler(options);
|
||||
handler.InnerHandler = new TestHttpMessageHandler();
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("https://scanner.internal/api/events");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineStrictModeHandler_WhenEnabled_SupportsWildcardHost()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(
|
||||
strictMode: true,
|
||||
allowedHosts: ["*.internal.corp"]);
|
||||
var handler = CreateHandler(options);
|
||||
handler.InnerHandler = new TestHttpMessageHandler();
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
// Act - subdomain matching
|
||||
var response = await client.GetAsync("https://scanner.internal.corp/api/events");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OfflineStrictModeHandler_WhenEnabled_BlocksNonMatchingWildcard()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(
|
||||
strictMode: true,
|
||||
allowedHosts: ["*.internal.corp"]);
|
||||
var handler = CreateHandler(options);
|
||||
handler.InnerHandler = new TestHttpMessageHandler();
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
// Act & Assert - different domain should be blocked
|
||||
var exception = await Assert.ThrowsAsync<OfflineStrictModeException>(
|
||||
() => client.GetAsync("https://scanner.external.com/api/events"));
|
||||
|
||||
Assert.Contains("scanner.external.com", exception.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SurfaceCacheValidator Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_WhenRequireCacheDisabled_SkipsValidation()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(requireSurfaceCache: false);
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert - should complete without exception
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_WhenPathNotConfigured_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(requireSurfaceCache: true, surfaceCachePath: null);
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("path is required", exception.Message.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_WhenDirectoryMissing_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateOptions(
|
||||
requireSurfaceCache: true,
|
||||
surfaceCachePath: "/nonexistent/path/to/cache");
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("does not exist", exception.Message.ToLowerInvariant());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_WhenCacheEmpty_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
Directory.CreateDirectory(_tempCachePath);
|
||||
var options = CreateOptions(
|
||||
requireSurfaceCache: true,
|
||||
surfaceCachePath: _tempCachePath,
|
||||
minimumCacheEntries: 1);
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("0 entries", exception.Message);
|
||||
Assert.Contains("1 are required", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_WhenBelowMinimumEntries_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
Directory.CreateDirectory(_tempCachePath);
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "entry2.json"), "{}");
|
||||
|
||||
var options = CreateOptions(
|
||||
requireSurfaceCache: true,
|
||||
surfaceCachePath: _tempCachePath,
|
||||
minimumCacheEntries: 5);
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("2 entries", exception.Message);
|
||||
Assert.Contains("5 are required", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_WhenSufficientEntries_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
Directory.CreateDirectory(_tempCachePath);
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "entry2.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "entry3.json"), "{}");
|
||||
|
||||
var options = CreateOptions(
|
||||
requireSurfaceCache: true,
|
||||
surfaceCachePath: _tempCachePath,
|
||||
minimumCacheEntries: 3);
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert - should complete without exception
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_IgnoresMetadataFiles()
|
||||
{
|
||||
// Arrange
|
||||
Directory.CreateDirectory(_tempCachePath);
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, ".manifest"), "metadata");
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "data.index"), "index");
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, ".lock"), "lock");
|
||||
|
||||
var options = CreateOptions(
|
||||
requireSurfaceCache: true,
|
||||
surfaceCachePath: _tempCachePath,
|
||||
minimumCacheEntries: 1);
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert - should succeed with only 1 valid entry
|
||||
await validator.StartAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SurfaceCacheValidator_IgnoresEmptyFiles()
|
||||
{
|
||||
// Arrange
|
||||
Directory.CreateDirectory(_tempCachePath);
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "entry1.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "empty.json"), ""); // Empty file
|
||||
|
||||
var options = CreateOptions(
|
||||
requireSurfaceCache: true,
|
||||
surfaceCachePath: _tempCachePath,
|
||||
minimumCacheEntries: 2);
|
||||
var validator = CreateValidator(options);
|
||||
|
||||
// Act & Assert - should fail as only 1 non-empty file
|
||||
var exception = await Assert.ThrowsAsync<SurfaceCacheValidationException>(
|
||||
() => validator.StartAsync(CancellationToken.None));
|
||||
|
||||
Assert.Contains("1 entries", exception.Message);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void FullOfflineConfiguration_ValidatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
Directory.CreateDirectory(_tempCachePath);
|
||||
File.WriteAllText(Path.Combine(_tempCachePath, "vuln-data.json"), "{\"version\":1}");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["zastava:runtime:tenant"] = "offline-tenant",
|
||||
["zastava:runtime:environment"] = "airgap",
|
||||
["zastava:runtime:offline:strictMode"] = "true",
|
||||
["zastava:runtime:offline:requireSurfaceCache"] = "true",
|
||||
["zastava:runtime:offline:surfaceCachePath"] = _tempCachePath,
|
||||
["zastava:runtime:offline:minimumCacheEntries"] = "1",
|
||||
["zastava:runtime:offline:maxCacheAgeHours"] = "168",
|
||||
["zastava:runtime:offline:allowedHosts:0"] = "localhost",
|
||||
["zastava:runtime:offline:allowedHosts:1"] = "*.internal.corp",
|
||||
["zastava:runtime:offline:logBlockedRequests"] = "true"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddZastavaRuntimeCore(configuration, componentName: "observer");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
|
||||
// Act
|
||||
var options = provider.GetRequiredService<IOptions<ZastavaRuntimeOptions>>().Value;
|
||||
|
||||
// Assert
|
||||
Assert.True(options.Offline.StrictMode);
|
||||
Assert.True(options.Offline.RequireSurfaceCache);
|
||||
Assert.Equal(_tempCachePath, options.Offline.SurfaceCachePath);
|
||||
Assert.Equal(1, options.Offline.MinimumCacheEntries);
|
||||
Assert.Equal(168, options.Offline.MaxCacheAgeHours);
|
||||
Assert.True(options.Offline.LogBlockedRequests);
|
||||
Assert.Equal(2, options.Offline.AllowedHosts.Count);
|
||||
Assert.Contains("localhost", options.Offline.AllowedHosts);
|
||||
Assert.Contains("*.internal.corp", options.Offline.AllowedHosts);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static IOptionsMonitor<ZastavaRuntimeOptions> CreateOptions(
|
||||
bool strictMode = false,
|
||||
bool requireSurfaceCache = false,
|
||||
string? surfaceCachePath = null,
|
||||
int minimumCacheEntries = 1,
|
||||
int maxCacheAgeHours = 168,
|
||||
bool logBlockedRequests = false,
|
||||
string[]? allowedHosts = null)
|
||||
{
|
||||
var options = new ZastavaRuntimeOptions
|
||||
{
|
||||
Tenant = "test-tenant",
|
||||
Environment = "test",
|
||||
Offline = new ZastavaOfflineOptions
|
||||
{
|
||||
StrictMode = strictMode,
|
||||
RequireSurfaceCache = requireSurfaceCache,
|
||||
SurfaceCachePath = surfaceCachePath,
|
||||
MinimumCacheEntries = minimumCacheEntries,
|
||||
MaxCacheAgeHours = maxCacheAgeHours,
|
||||
LogBlockedRequests = logBlockedRequests,
|
||||
AllowedHosts = allowedHosts?.ToList() ?? new List<string>()
|
||||
}
|
||||
};
|
||||
|
||||
return new TestOptionsMonitor<ZastavaRuntimeOptions>(options);
|
||||
}
|
||||
|
||||
private static OfflineStrictModeHandler CreateHandler(IOptionsMonitor<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
return new OfflineStrictModeHandler(
|
||||
options,
|
||||
NullLogger<OfflineStrictModeHandler>.Instance);
|
||||
}
|
||||
|
||||
private static SurfaceCacheValidator CreateValidator(IOptionsMonitor<ZastavaRuntimeOptions> options)
|
||||
{
|
||||
return new SurfaceCacheValidator(
|
||||
options,
|
||||
NullLogger<SurfaceCacheValidator>.Instance);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
public TestOptionsMonitor(T currentValue)
|
||||
{
|
||||
CurrentValue = currentValue;
|
||||
}
|
||||
|
||||
public T CurrentValue { get; }
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class TestHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"status\":\"ok\"}")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using StellaOps.Zastava.Observer.ContainerRuntime.Windows;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Zastava.Observer.Tests.ContainerRuntime.Windows;
|
||||
|
||||
public sealed class WindowsContainerRuntimeTests
|
||||
{
|
||||
[Fact]
|
||||
public void WindowsContainerInfo_RequiredProperties_AreSet()
|
||||
{
|
||||
var container = new WindowsContainerInfo
|
||||
{
|
||||
Id = "abc123",
|
||||
Name = "test-container",
|
||||
ImageRef = "mcr.microsoft.com/windows/servercore:ltsc2022"
|
||||
};
|
||||
|
||||
Assert.Equal("abc123", container.Id);
|
||||
Assert.Equal("test-container", container.Name);
|
||||
Assert.Equal("mcr.microsoft.com/windows/servercore:ltsc2022", container.ImageRef);
|
||||
Assert.Equal(WindowsContainerState.Unknown, container.State);
|
||||
Assert.Equal("windows", container.RuntimeType);
|
||||
Assert.Empty(container.Command);
|
||||
Assert.Empty(container.Labels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsContainerInfo_WithKubernetesOwner_HasOwnerSet()
|
||||
{
|
||||
var container = new WindowsContainerInfo
|
||||
{
|
||||
Id = "def456",
|
||||
Name = "k8s_container_pod",
|
||||
Owner = new WindowsContainerOwner
|
||||
{
|
||||
Kind = "Pod",
|
||||
Name = "my-pod",
|
||||
Namespace = "default"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.NotNull(container.Owner);
|
||||
Assert.Equal("Pod", container.Owner.Kind);
|
||||
Assert.Equal("my-pod", container.Owner.Name);
|
||||
Assert.Equal("default", container.Owner.Namespace);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsContainerInfo_HyperVContainer_HasIsolationFlag()
|
||||
{
|
||||
var container = new WindowsContainerInfo
|
||||
{
|
||||
Id = "hyperv123",
|
||||
Name = "hyperv-container",
|
||||
HyperVIsolated = true,
|
||||
RuntimeType = "hyperv"
|
||||
};
|
||||
|
||||
Assert.True(container.HyperVIsolated);
|
||||
Assert.Equal("hyperv", container.RuntimeType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsContainerEvent_RequiredProperties_AreSet()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var evt = new WindowsContainerEvent
|
||||
{
|
||||
Type = WindowsContainerEventType.ContainerStarted,
|
||||
ContainerId = "xyz789",
|
||||
ContainerName = "started-container",
|
||||
ImageRef = "myimage:latest",
|
||||
Timestamp = timestamp,
|
||||
Data = new Dictionary<string, string>
|
||||
{
|
||||
["exitCode"] = "0"
|
||||
}
|
||||
};
|
||||
|
||||
Assert.Equal(WindowsContainerEventType.ContainerStarted, evt.Type);
|
||||
Assert.Equal("xyz789", evt.ContainerId);
|
||||
Assert.Equal("started-container", evt.ContainerName);
|
||||
Assert.Equal("myimage:latest", evt.ImageRef);
|
||||
Assert.Equal(timestamp, evt.Timestamp);
|
||||
Assert.NotNull(evt.Data);
|
||||
Assert.Equal("0", evt.Data["exitCode"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WindowsContainerEventType.ContainerCreated)]
|
||||
[InlineData(WindowsContainerEventType.ContainerStarted)]
|
||||
[InlineData(WindowsContainerEventType.ContainerStopped)]
|
||||
[InlineData(WindowsContainerEventType.ContainerDeleted)]
|
||||
[InlineData(WindowsContainerEventType.ProcessStarted)]
|
||||
[InlineData(WindowsContainerEventType.ProcessExited)]
|
||||
public void WindowsContainerEventType_AllValues_AreDefined(WindowsContainerEventType eventType)
|
||||
{
|
||||
var evt = new WindowsContainerEvent
|
||||
{
|
||||
Type = eventType,
|
||||
ContainerId = "test",
|
||||
Timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
Assert.Equal(eventType, evt.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsRuntimeIdentity_RequiredProperties_AreSet()
|
||||
{
|
||||
var identity = new WindowsRuntimeIdentity
|
||||
{
|
||||
RuntimeName = "docker",
|
||||
RuntimeVersion = "20.10.21",
|
||||
OsVersion = "10.0.20348",
|
||||
OsBuild = 20348,
|
||||
HyperVAvailable = true
|
||||
};
|
||||
|
||||
Assert.Equal("docker", identity.RuntimeName);
|
||||
Assert.Equal("20.10.21", identity.RuntimeVersion);
|
||||
Assert.Equal("10.0.20348", identity.OsVersion);
|
||||
Assert.Equal(20348, identity.OsBuild);
|
||||
Assert.True(identity.HyperVAvailable);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WindowsContainerState.Unknown)]
|
||||
[InlineData(WindowsContainerState.Created)]
|
||||
[InlineData(WindowsContainerState.Running)]
|
||||
[InlineData(WindowsContainerState.Paused)]
|
||||
[InlineData(WindowsContainerState.Stopped)]
|
||||
public void WindowsContainerState_AllValues_AreDefined(WindowsContainerState state)
|
||||
{
|
||||
var container = new WindowsContainerInfo
|
||||
{
|
||||
Id = "test",
|
||||
Name = "test",
|
||||
State = state
|
||||
};
|
||||
|
||||
Assert.Equal(state, container.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsContainerInfo_WithTimestamps_TracksLifecycle()
|
||||
{
|
||||
var createdAt = DateTimeOffset.UtcNow.AddMinutes(-10);
|
||||
var startedAt = DateTimeOffset.UtcNow.AddMinutes(-9);
|
||||
var finishedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var container = new WindowsContainerInfo
|
||||
{
|
||||
Id = "lifecycle-test",
|
||||
Name = "lifecycle-container",
|
||||
State = WindowsContainerState.Stopped,
|
||||
CreatedAt = createdAt,
|
||||
StartedAt = startedAt,
|
||||
FinishedAt = finishedAt,
|
||||
ExitCode = 0
|
||||
};
|
||||
|
||||
Assert.Equal(createdAt, container.CreatedAt);
|
||||
Assert.Equal(startedAt, container.StartedAt);
|
||||
Assert.Equal(finishedAt, container.FinishedAt);
|
||||
Assert.Equal(0, container.ExitCode);
|
||||
Assert.True(container.StartedAt > container.CreatedAt);
|
||||
Assert.True(container.FinishedAt > container.StartedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsContainerInfo_WithLabels_CanBeEnumerated()
|
||||
{
|
||||
var labels = new Dictionary<string, string>
|
||||
{
|
||||
["io.kubernetes.pod.name"] = "my-pod",
|
||||
["io.kubernetes.pod.namespace"] = "default",
|
||||
["app"] = "test-app"
|
||||
};
|
||||
|
||||
var container = new WindowsContainerInfo
|
||||
{
|
||||
Id = "labeled",
|
||||
Name = "labeled-container",
|
||||
Labels = labels
|
||||
};
|
||||
|
||||
Assert.Equal(3, container.Labels.Count);
|
||||
Assert.Equal("my-pod", container.Labels["io.kubernetes.pod.name"]);
|
||||
Assert.Equal("default", container.Labels["io.kubernetes.pod.namespace"]);
|
||||
Assert.Equal("test-app", container.Labels["app"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WindowsContainerInfo_WithCommand_HasEntrypoint()
|
||||
{
|
||||
var command = new[] { "powershell.exe", "-Command", "Get-Process" };
|
||||
|
||||
var container = new WindowsContainerInfo
|
||||
{
|
||||
Id = "cmd",
|
||||
Name = "cmd-container",
|
||||
Command = command
|
||||
};
|
||||
|
||||
Assert.Equal(3, container.Command.Count);
|
||||
Assert.Equal("powershell.exe", container.Command[0]);
|
||||
Assert.Contains("-Command", container.Command);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that require Windows and Docker Windows containers.
|
||||
/// These tests are skipped on non-Windows platforms.
|
||||
/// </summary>
|
||||
[Collection("WindowsIntegration")]
|
||||
public sealed class WindowsContainerRuntimeIntegrationTests
|
||||
{
|
||||
private static bool IsWindowsWithDocker =>
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) &&
|
||||
Environment.GetEnvironmentVariable("ZASTAVA_WINDOWS_INTEGRATION_TESTS") == "true";
|
||||
|
||||
[SkippableFact]
|
||||
public async Task WindowsLibraryHashCollector_CollectCurrentProcess_ReturnsModules()
|
||||
{
|
||||
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
|
||||
|
||||
var collector = new WindowsLibraryHashCollector(NullLogger<WindowsLibraryHashCollector>.Instance);
|
||||
var processId = Environment.ProcessId;
|
||||
|
||||
var libraries = await collector.CollectAsync(processId, CancellationToken.None);
|
||||
|
||||
// Current process should have at least some loaded modules
|
||||
Assert.NotEmpty(libraries);
|
||||
|
||||
// Should include the main process executable
|
||||
var hasExe = libraries.Any(lib => lib.Path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(hasExe, "Should include at least one .exe module");
|
||||
|
||||
// All libraries should have paths
|
||||
Assert.All(libraries, lib => Assert.False(string.IsNullOrWhiteSpace(lib.Path)));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task WindowsLibraryHashCollector_WithMaxLimit_RespectsLimit()
|
||||
{
|
||||
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
|
||||
|
||||
var collector = new WindowsLibraryHashCollector(
|
||||
NullLogger<WindowsLibraryHashCollector>.Instance,
|
||||
maxLibraries: 5);
|
||||
|
||||
var processId = Environment.ProcessId;
|
||||
|
||||
var libraries = await collector.CollectAsync(processId, CancellationToken.None);
|
||||
|
||||
Assert.True(libraries.Count <= 5, "Should respect maxLibraries limit");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task WindowsLibraryHashCollector_InvalidProcessId_ReturnsEmptyList()
|
||||
{
|
||||
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
|
||||
|
||||
var collector = new WindowsLibraryHashCollector(NullLogger<WindowsLibraryHashCollector>.Instance);
|
||||
|
||||
// Use an invalid process ID
|
||||
var libraries = await collector.CollectAsync(int.MaxValue, CancellationToken.None);
|
||||
|
||||
Assert.Empty(libraries);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task WindowsLibraryHashCollector_ComputesHashes_WhenFilesAccessible()
|
||||
{
|
||||
Skip.IfNot(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows-only test");
|
||||
|
||||
var collector = new WindowsLibraryHashCollector(
|
||||
NullLogger<WindowsLibraryHashCollector>.Instance,
|
||||
maxLibraries: 10,
|
||||
maxFileBytes: 100_000_000);
|
||||
|
||||
var processId = Environment.ProcessId;
|
||||
|
||||
var libraries = await collector.CollectAsync(processId, CancellationToken.None);
|
||||
|
||||
// At least some libraries should have hashes (system DLLs should be accessible)
|
||||
var librariesWithHashes = libraries.Where(lib => !string.IsNullOrEmpty(lib.Sha256)).ToList();
|
||||
|
||||
Assert.NotEmpty(librariesWithHashes);
|
||||
Assert.All(librariesWithHashes, lib =>
|
||||
{
|
||||
Assert.StartsWith("sha256:", lib.Sha256);
|
||||
Assert.Equal(71, lib.Sha256!.Length); // "sha256:" + 64 hex chars
|
||||
});
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DockerWindowsRuntimeClient_IsAvailable_WhenDockerRunning()
|
||||
{
|
||||
Skip.IfNot(IsWindowsWithDocker, "Requires Windows with Docker in Windows containers mode");
|
||||
|
||||
await using var client = new DockerWindowsRuntimeClient(NullLogger<DockerWindowsRuntimeClient>.Instance);
|
||||
|
||||
var available = await client.IsAvailableAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(available, "Docker Windows should be available");
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DockerWindowsRuntimeClient_GetIdentity_ReturnsDockerInfo()
|
||||
{
|
||||
Skip.IfNot(IsWindowsWithDocker, "Requires Windows with Docker in Windows containers mode");
|
||||
|
||||
await using var client = new DockerWindowsRuntimeClient(NullLogger<DockerWindowsRuntimeClient>.Instance);
|
||||
|
||||
var identity = await client.GetIdentityAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(identity);
|
||||
Assert.Equal("docker", identity.RuntimeName);
|
||||
Assert.False(string.IsNullOrEmpty(identity.RuntimeVersion));
|
||||
Assert.False(string.IsNullOrEmpty(identity.OsVersion));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DockerWindowsRuntimeClient_ListContainers_ReturnsWindowsContainers()
|
||||
{
|
||||
Skip.IfNot(IsWindowsWithDocker, "Requires Windows with Docker in Windows containers mode");
|
||||
|
||||
await using var client = new DockerWindowsRuntimeClient(NullLogger<DockerWindowsRuntimeClient>.Instance);
|
||||
|
||||
var containers = await client.ListContainersAsync(
|
||||
WindowsContainerState.Running,
|
||||
CancellationToken.None);
|
||||
|
||||
// May be empty if no containers running, but should not throw
|
||||
Assert.NotNull(containers);
|
||||
Assert.All(containers, c =>
|
||||
{
|
||||
Assert.False(string.IsNullOrEmpty(c.Id));
|
||||
Assert.False(string.IsNullOrEmpty(c.Name));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skippable fact attribute for conditional tests.
|
||||
/// </summary>
|
||||
public sealed class SkippableFactAttribute : FactAttribute
|
||||
{
|
||||
public SkippableFactAttribute()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skip helper for conditional tests.
|
||||
/// </summary>
|
||||
public static class Skip
|
||||
{
|
||||
public static void IfNot(bool condition, string reason)
|
||||
{
|
||||
if (!condition)
|
||||
{
|
||||
throw new SkipException(reason);
|
||||
}
|
||||
}
|
||||
|
||||
public static void If(bool condition, string reason)
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
throw new SkipException(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown to skip a test.
|
||||
/// </summary>
|
||||
public sealed class SkipException : Exception
|
||||
{
|
||||
public SkipException(string reason) : base(reason)
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user