tests fixes and sprints work
This commit is contained in:
@@ -51,7 +51,9 @@ public sealed class BinaryAnalysisDoctorPlugin : IDoctorPlugin
|
||||
new DebuginfodAvailabilityCheck(),
|
||||
new DdebRepoEnabledCheck(),
|
||||
new BuildinfoCacheCheck(),
|
||||
new SymbolRecoveryFallbackCheck()
|
||||
new SymbolRecoveryFallbackCheck(),
|
||||
new CorpusMirrorFreshnessCheck(),
|
||||
new KpiBaselineExistsCheck()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CorpusMirrorFreshnessCheck.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Doctor checks for ground-truth corpus health
|
||||
// Description: Verify local corpus mirrors are not stale (configurable threshold)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that local corpus mirrors are not stale.
|
||||
/// Checks the last modification time of key mirror directories against a configurable threshold.
|
||||
/// </summary>
|
||||
public sealed class CorpusMirrorFreshnessCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.binaryanalysis";
|
||||
private const string CategoryName = "Security";
|
||||
|
||||
// Default directories for corpus mirrors
|
||||
private const string DefaultMirrorsRoot = "/var/lib/stella/mirrors";
|
||||
private const string ConfigMirrorsRootKey = "BinaryAnalysis:Corpus:MirrorsDirectory";
|
||||
private const string ConfigStaleThresholdKey = "BinaryAnalysis:Corpus:StalenessThresholdDays";
|
||||
private const int DefaultStaleThresholdDays = 7;
|
||||
|
||||
// Known mirror subdirectories to check
|
||||
private static readonly string[] MirrorSubdirectories =
|
||||
[
|
||||
"debian/archive",
|
||||
"debian/snapshot",
|
||||
"ubuntu/usn-index",
|
||||
"alpine/secdb",
|
||||
"osv"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.binaryanalysis.corpus.mirror.freshness";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "Corpus Mirror Freshness";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify local corpus mirrors are not stale (configurable threshold)";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["binaryanalysis", "corpus", "mirrors", "freshness", "security", "groundtruth"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Always run - corpus mirrors should be maintained
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
// Get configuration
|
||||
var mirrorsRoot = context.Configuration[ConfigMirrorsRootKey] ?? DefaultMirrorsRoot;
|
||||
var staleThresholdDaysStr = context.Configuration[ConfigStaleThresholdKey];
|
||||
var staleThresholdDays = int.TryParse(staleThresholdDaysStr, out var days) ? days : DefaultStaleThresholdDays;
|
||||
var staleThreshold = TimeSpan.FromDays(staleThresholdDays);
|
||||
|
||||
// Check if mirrors root exists
|
||||
if (!Directory.Exists(mirrorsRoot))
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Warn($"Corpus mirrors directory does not exist: {mirrorsRoot}")
|
||||
.WithEvidence("Mirror Status", eb =>
|
||||
{
|
||||
eb.Add("mirrors_root", mirrorsRoot);
|
||||
eb.Add("exists", false);
|
||||
eb.Add("stale_threshold_days", staleThresholdDays);
|
||||
})
|
||||
.WithCauses(
|
||||
"Corpus mirrors have not been initialized",
|
||||
"Mirror directory path misconfigured",
|
||||
"Air-gapped setup incomplete")
|
||||
.WithRemediation(rb => rb
|
||||
.AddShellStep(1, "Create mirrors directory",
|
||||
$"sudo mkdir -p {mirrorsRoot}")
|
||||
.AddStellaStep(2, "Initialize corpus mirrors",
|
||||
"groundtruth mirror sync --all")
|
||||
.AddManualStep(3, "For air-gapped environments",
|
||||
"Copy pre-populated mirrors from an online system to the mirrors directory"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// Check each mirror subdirectory
|
||||
var mirrorStatuses = new List<MirrorStatus>();
|
||||
var now = context.TimeProvider.GetUtcNow();
|
||||
|
||||
foreach (var subdir in MirrorSubdirectories)
|
||||
{
|
||||
var mirrorPath = Path.Combine(mirrorsRoot, subdir);
|
||||
var status = CheckMirrorDirectory(mirrorPath, now, staleThreshold);
|
||||
mirrorStatuses.Add(status);
|
||||
}
|
||||
|
||||
// Analyze results
|
||||
var existingMirrors = mirrorStatuses.Where(m => m.Exists).ToList();
|
||||
var staleMirrors = existingMirrors.Where(m => m.IsStale).ToList();
|
||||
var freshMirrors = existingMirrors.Where(m => !m.IsStale).ToList();
|
||||
var missingMirrors = mirrorStatuses.Where(m => !m.Exists).ToList();
|
||||
|
||||
// Build evidence
|
||||
void AddMirrorEvidence(Plugins.Builders.EvidenceBuilder eb)
|
||||
{
|
||||
eb.Add("mirrors_root", mirrorsRoot);
|
||||
eb.Add("stale_threshold_days", staleThresholdDays);
|
||||
eb.Add("total_mirrors", mirrorStatuses.Count);
|
||||
eb.Add("existing_mirrors", existingMirrors.Count);
|
||||
eb.Add("fresh_mirrors", freshMirrors.Count);
|
||||
eb.Add("stale_mirrors", staleMirrors.Count);
|
||||
eb.Add("missing_mirrors", missingMirrors.Count);
|
||||
|
||||
for (var i = 0; i < mirrorStatuses.Count; i++)
|
||||
{
|
||||
var m = mirrorStatuses[i];
|
||||
var prefix = $"mirror_{i + 1}";
|
||||
eb.Add($"{prefix}_path", m.Name);
|
||||
eb.Add($"{prefix}_exists", m.Exists);
|
||||
if (m.Exists)
|
||||
{
|
||||
eb.Add($"{prefix}_last_modified", m.LastModified?.ToString("O") ?? "unknown");
|
||||
eb.Add($"{prefix}_age_days", m.AgeDays);
|
||||
eb.Add($"{prefix}_is_stale", m.IsStale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No mirrors exist
|
||||
if (existingMirrors.Count == 0)
|
||||
{
|
||||
return Task.FromResult(builder
|
||||
.Fail("No corpus mirrors found - binary analysis will have degraded symbol recovery")
|
||||
.WithEvidence("Mirror Status", AddMirrorEvidence)
|
||||
.WithCauses(
|
||||
"Corpus mirrors have not been synchronized",
|
||||
"Mirror sync job has not been run",
|
||||
"Air-gapped setup incomplete")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStellaStep(1, "Initialize all corpus mirrors",
|
||||
"groundtruth mirror sync --all")
|
||||
.AddShellStep(2, "Or sync specific mirrors",
|
||||
"stella groundtruth mirror sync --source debian")
|
||||
.AddManualStep(3, "For air-gapped environments",
|
||||
"Transfer pre-populated mirrors from an online system"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// All existing mirrors are stale
|
||||
if (staleMirrors.Count > 0 && freshMirrors.Count == 0)
|
||||
{
|
||||
var staleNames = string.Join(", ", staleMirrors.Select(m => m.Name));
|
||||
return Task.FromResult(builder
|
||||
.Fail($"All existing corpus mirrors are stale (older than {staleThresholdDays} days)")
|
||||
.WithEvidence("Mirror Status", AddMirrorEvidence)
|
||||
.WithCauses(
|
||||
"Mirror sync job has not run recently",
|
||||
"Scheduled sync task is disabled or failing",
|
||||
"Network connectivity issues preventing sync")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStellaStep(1, "Update all mirrors",
|
||||
"groundtruth mirror sync --all")
|
||||
.AddShellStep(2, "Check mirror sync job status",
|
||||
"systemctl status stella-mirror-sync.timer")
|
||||
.AddManualStep(3, "Set up automatic mirror sync",
|
||||
$"Configure a cron job or systemd timer to run 'stella groundtruth mirror sync' at least every {staleThresholdDays} days"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// Some mirrors are stale
|
||||
if (staleMirrors.Count > 0)
|
||||
{
|
||||
var staleNames = string.Join(", ", staleMirrors.Select(m => m.Name));
|
||||
return Task.FromResult(builder
|
||||
.Warn($"{staleMirrors.Count}/{existingMirrors.Count} mirrors are stale: {staleNames}")
|
||||
.WithEvidence("Mirror Status", AddMirrorEvidence)
|
||||
.WithCauses(
|
||||
"Some mirror sync operations are failing",
|
||||
"Partial network connectivity issues",
|
||||
"Selective mirror sync configured")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStellaStep(1, "Sync stale mirrors",
|
||||
$"groundtruth mirror sync --sources {string.Join(",", staleMirrors.Select(m => m.Name.Split('/')[0]))}")
|
||||
.AddShellStep(2, "Check sync logs for errors",
|
||||
"journalctl -u stella-mirror-sync --since '7 days ago' | grep -i error"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// Some mirrors are missing
|
||||
if (missingMirrors.Count > 0)
|
||||
{
|
||||
var missingNames = string.Join(", ", missingMirrors.Select(m => m.Name));
|
||||
return Task.FromResult(builder
|
||||
.Info($"All existing mirrors are fresh; {missingMirrors.Count} optional mirrors not configured: {missingNames}")
|
||||
.WithEvidence("Mirror Status", AddMirrorEvidence)
|
||||
.WithRemediation(rb => rb
|
||||
.AddManualStep(1, "Optionally add missing mirrors",
|
||||
$"stella groundtruth mirror sync --sources {string.Join(",", missingMirrors.Select(m => m.Name.Split('/')[0]))}"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
// All mirrors are fresh
|
||||
return Task.FromResult(builder
|
||||
.Pass($"All {freshMirrors.Count} corpus mirrors are fresh (last updated within {staleThresholdDays} days)")
|
||||
.WithEvidence("Mirror Status", AddMirrorEvidence)
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the status of a single mirror directory.
|
||||
/// </summary>
|
||||
private static MirrorStatus CheckMirrorDirectory(string path, DateTimeOffset now, TimeSpan staleThreshold)
|
||||
{
|
||||
var status = new MirrorStatus
|
||||
{
|
||||
Path = path,
|
||||
Name = Path.GetFileName(Path.GetDirectoryName(path)) + "/" + Path.GetFileName(path)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
status.Exists = false;
|
||||
return status;
|
||||
}
|
||||
|
||||
status.Exists = true;
|
||||
|
||||
// Get the most recent modification time of any file in the directory
|
||||
var mostRecentModification = GetMostRecentModification(path);
|
||||
if (mostRecentModification.HasValue)
|
||||
{
|
||||
status.LastModified = mostRecentModification.Value;
|
||||
var age = now - mostRecentModification.Value;
|
||||
status.AgeDays = (int)age.TotalDays;
|
||||
status.IsStale = age > staleThreshold;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Directory exists but is empty - consider it stale
|
||||
status.IsStale = true;
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// Cannot access directory - mark as stale
|
||||
status.Exists = true;
|
||||
status.IsStale = true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// IO error - mark as stale
|
||||
status.Exists = true;
|
||||
status.IsStale = true;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the most recent modification time of any file in the directory (recursive).
|
||||
/// Limited to first 1000 files to avoid performance issues.
|
||||
/// </summary>
|
||||
private static DateTimeOffset? GetMostRecentModification(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
// First try to use the directory's own modification time
|
||||
var dirInfo = new DirectoryInfo(path);
|
||||
var dirModTime = dirInfo.LastWriteTimeUtc;
|
||||
|
||||
// Also check a sample of files for more accurate staleness detection
|
||||
var files = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
|
||||
.Take(1000)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0)
|
||||
{
|
||||
// Empty directory - use directory modification time
|
||||
return new DateTimeOffset(dirModTime, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
var mostRecent = files
|
||||
.Select(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Max();
|
||||
|
||||
return new DateTimeOffset(mostRecent, TimeSpan.Zero);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MirrorStatus
|
||||
{
|
||||
public required string Path { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public bool Exists { get; set; }
|
||||
public DateTimeOffset? LastModified { get; set; }
|
||||
public int AgeDays { get; set; }
|
||||
public bool IsStale { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KpiBaselineExistsCheck.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Doctor checks for ground-truth corpus health
|
||||
// Description: Verify KPI baseline file exists for regression detection
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a KPI baseline file exists for regression detection.
|
||||
/// Without a baseline, CI gates cannot detect KPI regressions in binary matching accuracy.
|
||||
/// </summary>
|
||||
public sealed class KpiBaselineExistsCheck : IDoctorCheck
|
||||
{
|
||||
private const string PluginId = "stellaops.doctor.binaryanalysis";
|
||||
private const string CategoryName = "Security";
|
||||
|
||||
// Default baseline file location
|
||||
private const string DefaultBaselineDirectory = "/var/lib/stella/baselines";
|
||||
private const string DefaultBaselineFilename = "current.json";
|
||||
private const string ConfigBaselineDirectoryKey = "BinaryAnalysis:Corpus:BaselineDirectory";
|
||||
private const string ConfigBaselineFilenameKey = "BinaryAnalysis:Corpus:BaselineFilename";
|
||||
|
||||
// Expected KPI fields in baseline file
|
||||
private static readonly string[] ExpectedKpiFields =
|
||||
[
|
||||
"precision",
|
||||
"recall",
|
||||
"falseNegativeRate",
|
||||
"deterministicReplayRate",
|
||||
"ttfrpP95Ms"
|
||||
];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string CheckId => "check.binaryanalysis.corpus.kpi.baseline";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Name => "KPI Baseline Configuration";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "Verify KPI baseline file exists for regression detection in CI gates";
|
||||
|
||||
/// <inheritdoc />
|
||||
public DoctorSeverity DefaultSeverity => DoctorSeverity.Warn;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Tags => ["binaryanalysis", "corpus", "kpi", "baseline", "regression", "ci", "groundtruth"];
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan EstimatedDuration => TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanRun(DoctorPluginContext context)
|
||||
{
|
||||
// Always run - KPI baselines are important for regression detection
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DoctorCheckResult> RunAsync(DoctorPluginContext context, CancellationToken ct)
|
||||
{
|
||||
var builder = context.CreateResult(CheckId, PluginId, CategoryName);
|
||||
|
||||
// Get configuration
|
||||
var baselineDir = context.Configuration[ConfigBaselineDirectoryKey] ?? DefaultBaselineDirectory;
|
||||
var baselineFilename = context.Configuration[ConfigBaselineFilenameKey] ?? DefaultBaselineFilename;
|
||||
var baselinePath = Path.Combine(baselineDir, baselineFilename);
|
||||
|
||||
// Check if baseline directory exists
|
||||
if (!Directory.Exists(baselineDir))
|
||||
{
|
||||
return builder
|
||||
.Warn($"KPI baseline directory does not exist: {baselineDir}")
|
||||
.WithEvidence("Baseline Status", eb =>
|
||||
{
|
||||
eb.Add("baseline_directory", baselineDir);
|
||||
eb.Add("baseline_filename", baselineFilename);
|
||||
eb.Add("full_path", baselinePath);
|
||||
eb.Add("directory_exists", false);
|
||||
eb.Add("file_exists", false);
|
||||
})
|
||||
.WithCauses(
|
||||
"KPI baseline has never been established",
|
||||
"Baseline directory path misconfigured",
|
||||
"First run of corpus validation not yet completed")
|
||||
.WithRemediation(rb => rb
|
||||
.AddShellStep(1, "Create baseline directory",
|
||||
$"sudo mkdir -p {baselineDir}")
|
||||
.AddStellaStep(2, "Run corpus validation to establish baseline",
|
||||
"groundtruth validate run --corpus datasets/golden-corpus/seed/ --output-baseline")
|
||||
.AddStellaStep(3, "Or manually set the current results as baseline",
|
||||
$"groundtruth baseline update --from-latest --output {baselinePath}"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check if baseline file exists
|
||||
if (!File.Exists(baselinePath))
|
||||
{
|
||||
// Check for any baseline files in the directory
|
||||
var existingBaselines = TryGetExistingBaselines(baselineDir);
|
||||
|
||||
if (existingBaselines.Count > 0)
|
||||
{
|
||||
var latest = existingBaselines.OrderByDescending(b => b.ModifiedAt).First();
|
||||
return builder
|
||||
.Warn($"Default baseline file not found, but {existingBaselines.Count} other baseline file(s) exist")
|
||||
.WithEvidence("Baseline Status", eb =>
|
||||
{
|
||||
eb.Add("baseline_directory", baselineDir);
|
||||
eb.Add("baseline_filename", baselineFilename);
|
||||
eb.Add("full_path", baselinePath);
|
||||
eb.Add("directory_exists", true);
|
||||
eb.Add("file_exists", false);
|
||||
eb.Add("other_baselines_found", existingBaselines.Count);
|
||||
eb.Add("latest_baseline", latest.Filename);
|
||||
eb.Add("latest_baseline_date", latest.ModifiedAt.ToString("O"));
|
||||
})
|
||||
.WithCauses(
|
||||
"Baseline file was renamed or moved",
|
||||
"Configuration points to wrong filename",
|
||||
"Latest baseline not yet promoted to 'current'")
|
||||
.WithRemediation(rb => rb
|
||||
.AddShellStep(1, "Copy latest baseline to default location",
|
||||
$"cp {Path.Combine(baselineDir, latest.Filename)} {baselinePath}")
|
||||
.AddManualStep(2, "Or update configuration to use existing baseline",
|
||||
$"Set BinaryAnalysis:Corpus:BaselineFilename to '{latest.Filename}'"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
return builder
|
||||
.Warn($"No KPI baseline file found at {baselinePath}")
|
||||
.WithEvidence("Baseline Status", eb =>
|
||||
{
|
||||
eb.Add("baseline_directory", baselineDir);
|
||||
eb.Add("baseline_filename", baselineFilename);
|
||||
eb.Add("full_path", baselinePath);
|
||||
eb.Add("directory_exists", true);
|
||||
eb.Add("file_exists", false);
|
||||
eb.Add("other_baselines_found", 0);
|
||||
})
|
||||
.WithCauses(
|
||||
"KPI baseline has never been established",
|
||||
"First run of corpus validation not yet completed",
|
||||
"Baseline file was deleted")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStellaStep(1, "Run corpus validation to establish baseline",
|
||||
$"groundtruth validate run --corpus datasets/golden-corpus/seed/ --output {baselinePath}")
|
||||
.AddStellaStep(2, "Or update baseline from existing validation results",
|
||||
$"groundtruth baseline update --from-latest --output {baselinePath}"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// File exists - validate its contents
|
||||
var validationResult = await ValidateBaselineFileAsync(baselinePath, ct);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
return builder
|
||||
.Fail($"KPI baseline file exists but is invalid: {validationResult.Error}")
|
||||
.WithEvidence("Baseline Status", eb =>
|
||||
{
|
||||
eb.Add("baseline_directory", baselineDir);
|
||||
eb.Add("baseline_filename", baselineFilename);
|
||||
eb.Add("full_path", baselinePath);
|
||||
eb.Add("directory_exists", true);
|
||||
eb.Add("file_exists", true);
|
||||
eb.Add("is_valid_json", validationResult.IsValidJson);
|
||||
eb.Add("validation_error", validationResult.Error ?? "unknown");
|
||||
eb.Add("file_size_bytes", validationResult.FileSizeBytes);
|
||||
eb.Add("last_modified", validationResult.LastModified?.ToString("O") ?? "unknown");
|
||||
})
|
||||
.WithCauses(
|
||||
"Baseline file is corrupted",
|
||||
"Baseline file has invalid JSON format",
|
||||
"Baseline file is missing required KPI fields",
|
||||
"File was truncated or partially written")
|
||||
.WithRemediation(rb => rb
|
||||
.AddShellStep(1, "Back up corrupted baseline",
|
||||
$"mv {baselinePath} {baselinePath}.corrupted.$(date +%Y%m%d)")
|
||||
.AddStellaStep(2, "Regenerate baseline from latest validation",
|
||||
$"groundtruth baseline update --from-latest --output {baselinePath}")
|
||||
.AddShellStep(3, "Or validate JSON manually",
|
||||
$"cat {baselinePath} | jq ."))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// Check for missing KPI fields
|
||||
if (validationResult.MissingFields.Count > 0)
|
||||
{
|
||||
var missing = string.Join(", ", validationResult.MissingFields);
|
||||
return builder
|
||||
.Warn($"KPI baseline is missing {validationResult.MissingFields.Count} recommended fields: {missing}")
|
||||
.WithEvidence("Baseline Status", eb =>
|
||||
{
|
||||
eb.Add("baseline_directory", baselineDir);
|
||||
eb.Add("baseline_filename", baselineFilename);
|
||||
eb.Add("full_path", baselinePath);
|
||||
eb.Add("file_exists", true);
|
||||
eb.Add("is_valid_json", true);
|
||||
eb.Add("missing_fields", missing);
|
||||
eb.Add("present_fields", string.Join(", ", validationResult.PresentFields));
|
||||
eb.Add("file_size_bytes", validationResult.FileSizeBytes);
|
||||
eb.Add("last_modified", validationResult.LastModified?.ToString("O") ?? "unknown");
|
||||
AddKpiValues(eb, validationResult);
|
||||
})
|
||||
.WithCauses(
|
||||
"Baseline was created with an older version of the validation tool",
|
||||
"Some KPI metrics were not computed during baseline creation",
|
||||
"Partial baseline update")
|
||||
.WithRemediation(rb => rb
|
||||
.AddStellaStep(1, "Regenerate complete baseline",
|
||||
$"groundtruth baseline update --from-latest --output {baselinePath}"))
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
// All good - baseline exists and is valid
|
||||
var ageInDays = validationResult.LastModified.HasValue
|
||||
? (int)(context.TimeProvider.GetUtcNow() - validationResult.LastModified.Value).TotalDays
|
||||
: 0;
|
||||
|
||||
return builder
|
||||
.Pass($"KPI baseline is configured and valid (last updated {ageInDays} days ago)")
|
||||
.WithEvidence("Baseline Status", eb =>
|
||||
{
|
||||
eb.Add("baseline_directory", baselineDir);
|
||||
eb.Add("baseline_filename", baselineFilename);
|
||||
eb.Add("full_path", baselinePath);
|
||||
eb.Add("file_exists", true);
|
||||
eb.Add("is_valid", true);
|
||||
eb.Add("file_size_bytes", validationResult.FileSizeBytes);
|
||||
eb.Add("last_modified", validationResult.LastModified?.ToString("O") ?? "unknown");
|
||||
eb.Add("age_days", ageInDays);
|
||||
AddKpiValues(eb, validationResult);
|
||||
})
|
||||
.WithVerification($"stella doctor --check {CheckId}")
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find existing baseline files in the directory.
|
||||
/// </summary>
|
||||
private static List<BaselineFileInfo> TryGetExistingBaselines(string directory)
|
||||
{
|
||||
var results = new List<BaselineFileInfo>();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(directory, "*.json"))
|
||||
{
|
||||
var info = new FileInfo(file);
|
||||
results.Add(new BaselineFileInfo
|
||||
{
|
||||
Filename = info.Name,
|
||||
ModifiedAt = new DateTimeOffset(info.LastWriteTimeUtc, TimeSpan.Zero)
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors listing directory
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the baseline file contents.
|
||||
/// </summary>
|
||||
private static async Task<BaselineValidationResult> ValidateBaselineFileAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var result = new BaselineValidationResult();
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
result.FileSizeBytes = fileInfo.Length;
|
||||
result.LastModified = new DateTimeOffset(fileInfo.LastWriteTimeUtc, TimeSpan.Zero);
|
||||
|
||||
if (fileInfo.Length == 0)
|
||||
{
|
||||
result.IsValid = false;
|
||||
result.Error = "File is empty";
|
||||
return result;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(path, ct);
|
||||
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
result.IsValidJson = true;
|
||||
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check for expected KPI fields
|
||||
foreach (var field in ExpectedKpiFields)
|
||||
{
|
||||
if (root.TryGetProperty(field, out var value) ||
|
||||
root.TryGetProperty(ToCamelCase(field), out value) ||
|
||||
root.TryGetProperty(ToPascalCase(field), out value))
|
||||
{
|
||||
result.PresentFields.Add(field);
|
||||
|
||||
// Try to extract the value for evidence
|
||||
if (value.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
result.KpiValues[field] = value.GetDouble();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.MissingFields.Add(field);
|
||||
}
|
||||
}
|
||||
|
||||
// File is valid if it has at least some KPI fields
|
||||
result.IsValid = result.PresentFields.Count > 0;
|
||||
if (!result.IsValid)
|
||||
{
|
||||
result.Error = "No recognized KPI fields found";
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
result.IsValidJson = false;
|
||||
result.IsValid = false;
|
||||
result.Error = $"Invalid JSON: {ex.Message}";
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
result.IsValid = false;
|
||||
result.Error = $"Cannot read file: {ex.Message}";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds KPI values to evidence if available.
|
||||
/// </summary>
|
||||
private static void AddKpiValues(Plugins.Builders.EvidenceBuilder eb, BaselineValidationResult result)
|
||||
{
|
||||
foreach (var (field, value) in result.KpiValues)
|
||||
{
|
||||
eb.Add($"kpi_{field}", value);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToCamelCase(string s) => char.ToLowerInvariant(s[0]) + s[1..];
|
||||
private static string ToPascalCase(string s) => char.ToUpperInvariant(s[0]) + s[1..];
|
||||
|
||||
private sealed record BaselineFileInfo
|
||||
{
|
||||
public required string Filename { get; init; }
|
||||
public required DateTimeOffset ModifiedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed record BaselineValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
public bool IsValidJson { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public long FileSizeBytes { get; set; }
|
||||
public DateTimeOffset? LastModified { get; set; }
|
||||
public List<string> PresentFields { get; } = [];
|
||||
public List<string> MissingFields { get; } = [];
|
||||
public Dictionary<string, double> KpiValues { get; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CorpusMirrorFreshnessCheckTests.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Doctor checks for ground-truth corpus health
|
||||
// Description: Unit tests for CorpusMirrorFreshnessCheck
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class CorpusMirrorFreshnessCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly CorpusMirrorFreshnessCheck _check = new();
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public CorpusMirrorFreshnessCheckTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"corpus-mirror-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 22, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.binaryanalysis.corpus.mirror.freshness");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsCorpusMirrorFreshness()
|
||||
{
|
||||
// Assert
|
||||
_check.Name.Should().Be("Corpus Mirror Freshness");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("binaryanalysis");
|
||||
_check.Tags.Should().Contain("corpus");
|
||||
_check.Tags.Should().Contain("mirrors");
|
||||
_check.Tags.Should().Contain("groundtruth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_Always()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenMirrorsDirectoryDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentDir = Path.Combine(_tempDir, "nonexistent");
|
||||
var context = CreateContext(nonExistentDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("does not exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenNoMirrorsExist()
|
||||
{
|
||||
// Arrange - directory exists but no mirrors
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("No corpus mirrors found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenAllMirrorsAreFresh()
|
||||
{
|
||||
// Arrange - create fresh mirrors
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 1);
|
||||
CreateMirrorWithFiles(_tempDir, "debian/snapshot", daysOld: 2);
|
||||
CreateMirrorWithFiles(_tempDir, "ubuntu/usn-index", daysOld: 3);
|
||||
CreateMirrorWithFiles(_tempDir, "alpine/secdb", daysOld: 4);
|
||||
CreateMirrorWithFiles(_tempDir, "osv", daysOld: 5);
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("fresh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenAllMirrorsAreStale()
|
||||
{
|
||||
// Arrange - create stale mirrors (> 7 days old by default)
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 10);
|
||||
CreateMirrorWithFiles(_tempDir, "debian/snapshot", daysOld: 15);
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenSomeMirrorsAreStale()
|
||||
{
|
||||
// Arrange - mix of fresh and stale mirrors
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 2); // Fresh
|
||||
CreateMirrorWithFiles(_tempDir, "debian/snapshot", daysOld: 10); // Stale
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RespectsConfiguredThreshold()
|
||||
{
|
||||
// Arrange - create mirror that is 5 days old
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 5);
|
||||
|
||||
// Configure threshold to 3 days (so 5 days is stale)
|
||||
var context = CreateContext(_tempDir, staleThresholdDays: 3);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
// Arrange
|
||||
CreateMirrorWithFiles(_tempDir, "debian/archive", daysOld: 2);
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.VerificationCommand.Should().NotBeNullOrEmpty();
|
||||
result.VerificationCommand.Should().Contain("stella doctor --check");
|
||||
result.VerificationCommand.Should().Contain(_check.CheckId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediationSteps_OnFailure()
|
||||
{
|
||||
// Arrange - no mirrors
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
private DoctorPluginContext CreateContext(string mirrorsRoot, int? staleThresholdDays = null)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["BinaryAnalysis:Corpus:MirrorsDirectory"] = mirrorsRoot
|
||||
};
|
||||
|
||||
if (staleThresholdDays.HasValue)
|
||||
{
|
||||
configValues["BinaryAnalysis:Corpus:StalenessThresholdDays"] = staleThresholdDays.Value.ToString();
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = _timeProvider,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
|
||||
private void CreateMirrorWithFiles(string root, string subdir, int daysOld)
|
||||
{
|
||||
var mirrorPath = Path.Combine(root, subdir);
|
||||
Directory.CreateDirectory(mirrorPath);
|
||||
|
||||
// Create some test files
|
||||
var testFile = Path.Combine(mirrorPath, "test-data.json");
|
||||
File.WriteAllText(testFile, "{}");
|
||||
|
||||
// Set modification time
|
||||
var modTime = _timeProvider.GetUtcNow().AddDays(-daysOld).DateTime;
|
||||
File.SetLastWriteTimeUtc(testFile, modTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// KpiBaselineExistsCheckTests.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Doctor checks for ground-truth corpus health
|
||||
// Description: Unit tests for KpiBaselineExistsCheck
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Doctor.Models;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
using StellaOps.Doctor.Plugins;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Checks;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public class KpiBaselineExistsCheckTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly KpiBaselineExistsCheck _check = new();
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public KpiBaselineExistsCheckTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"kpi-baseline-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 22, 12, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckId_ReturnsExpectedValue()
|
||||
{
|
||||
// Assert
|
||||
_check.CheckId.Should().Be("check.binaryanalysis.corpus.kpi.baseline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ReturnsKpiBaselineConfiguration()
|
||||
{
|
||||
// Assert
|
||||
_check.Name.Should().Be("KPI Baseline Configuration");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultSeverity_IsWarn()
|
||||
{
|
||||
// Assert
|
||||
_check.DefaultSeverity.Should().Be(DoctorSeverity.Warn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_ContainsExpectedValues()
|
||||
{
|
||||
// Assert
|
||||
_check.Tags.Should().Contain("binaryanalysis");
|
||||
_check.Tags.Should().Contain("corpus");
|
||||
_check.Tags.Should().Contain("kpi");
|
||||
_check.Tags.Should().Contain("baseline");
|
||||
_check.Tags.Should().Contain("groundtruth");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanRun_ReturnsTrue_Always()
|
||||
{
|
||||
// Arrange
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act & Assert
|
||||
_check.CanRun(context).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenBaselineDirectoryDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var nonExistentDir = Path.Combine(_tempDir, "nonexistent");
|
||||
var context = CreateContext(nonExistentDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("does not exist");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenBaselineFileDoesNotExist()
|
||||
{
|
||||
// Arrange - directory exists but no baseline file
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("No KPI baseline file found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenOtherBaselineFilesExist()
|
||||
{
|
||||
// Arrange - create a different baseline file
|
||||
var otherBaseline = Path.Combine(_tempDir, "baseline-20260120.json");
|
||||
File.WriteAllText(otherBaseline, CreateValidBaselineJson());
|
||||
|
||||
var context = CreateContext(_tempDir); // Will look for current.json
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("other baseline file");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenBaselineFileIsEmpty()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, "");
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenBaselineFileHasInvalidJson()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, "{ invalid json }");
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("invalid");
|
||||
result.Diagnosis.Should().Contain("JSON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsFail_WhenBaselineFileHasNoKpiFields()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, "{ \"unrelated\": \"data\" }");
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Fail);
|
||||
result.Diagnosis.Should().Contain("invalid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsWarn_WhenSomeKpiFieldsAreMissing()
|
||||
{
|
||||
// Arrange - partial baseline with only some fields
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
var partialBaseline = new
|
||||
{
|
||||
precision = 0.95,
|
||||
recall = 0.92
|
||||
// Missing: falseNegativeRate, deterministicReplayRate, ttfrpP95Ms
|
||||
};
|
||||
File.WriteAllText(baselinePath, JsonSerializer.Serialize(partialBaseline));
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Warn);
|
||||
result.Diagnosis.Should().Contain("missing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsPass_WhenBaselineIsValid()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, CreateValidBaselineJson());
|
||||
|
||||
// Set file modification time to 2 days ago
|
||||
File.SetLastWriteTimeUtc(baselinePath, _timeProvider.GetUtcNow().AddDays(-2).DateTime);
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
result.Diagnosis.Should().Contain("valid");
|
||||
result.Diagnosis.Should().Contain("2 days ago");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AcceptsCamelCaseKpiFields()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
var baseline = new
|
||||
{
|
||||
precision = 0.95,
|
||||
recall = 0.92,
|
||||
falseNegativeRate = 0.08,
|
||||
deterministicReplayRate = 1.0,
|
||||
ttfrpP95Ms = 150
|
||||
};
|
||||
File.WriteAllText(baselinePath, JsonSerializer.Serialize(baseline));
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesVerificationCommand()
|
||||
{
|
||||
// Arrange
|
||||
var baselinePath = Path.Combine(_tempDir, "current.json");
|
||||
File.WriteAllText(baselinePath, CreateValidBaselineJson());
|
||||
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.VerificationCommand.Should().NotBeNullOrEmpty();
|
||||
result.VerificationCommand.Should().Contain("stella doctor --check");
|
||||
result.VerificationCommand.Should().Contain(_check.CheckId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_IncludesRemediationSteps_OnFailure()
|
||||
{
|
||||
// Arrange - no baseline
|
||||
var context = CreateContext(_tempDir);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Remediation.Should().NotBeNull();
|
||||
result.Remediation!.Steps.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_RespectsCustomFilename()
|
||||
{
|
||||
// Arrange
|
||||
var customFilename = "my-baseline.json";
|
||||
var baselinePath = Path.Combine(_tempDir, customFilename);
|
||||
File.WriteAllText(baselinePath, CreateValidBaselineJson());
|
||||
|
||||
var context = CreateContext(_tempDir, customFilename);
|
||||
|
||||
// Act
|
||||
var result = await _check.RunAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Severity.Should().Be(DoctorSeverity.Pass);
|
||||
}
|
||||
|
||||
private DoctorPluginContext CreateContext(string baselineDir, string? baselineFilename = null)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["BinaryAnalysis:Corpus:BaselineDirectory"] = baselineDir
|
||||
};
|
||||
|
||||
if (baselineFilename != null)
|
||||
{
|
||||
configValues["BinaryAnalysis:Corpus:BaselineFilename"] = baselineFilename;
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
return new DoctorPluginContext
|
||||
{
|
||||
Services = new ServiceCollection().BuildServiceProvider(),
|
||||
Configuration = config,
|
||||
TimeProvider = _timeProvider,
|
||||
Logger = NullLogger.Instance,
|
||||
EnvironmentName = "Test",
|
||||
PluginConfig = config.GetSection("Doctor:Plugins")
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateValidBaselineJson()
|
||||
{
|
||||
var baseline = new
|
||||
{
|
||||
precision = 0.95,
|
||||
recall = 0.92,
|
||||
falseNegativeRate = 0.08,
|
||||
deterministicReplayRate = 1.0,
|
||||
ttfrpP95Ms = 150,
|
||||
timestamp = "2026-01-20T10:00:00Z"
|
||||
};
|
||||
return JsonSerializer.Serialize(baseline);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CorpusHealthChecksIntegrationTests.cs
|
||||
// Sprint: SPRINT_20260121_036_BinaryIndex_golden_corpus_bundle_verification
|
||||
// Task: GCB-004 - Integration test with mock services
|
||||
// Description: Integration tests for corpus health Doctor checks with mock services
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Checks;
|
||||
using StellaOps.Doctor.Plugin.BinaryAnalysis.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Doctor.Plugin.BinaryAnalysis.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for ground-truth corpus Doctor checks.
|
||||
/// These tests verify the health checks work correctly with mock services
|
||||
/// simulating various infrastructure states.
|
||||
/// </summary>
|
||||
public sealed class CorpusHealthChecksIntegrationTests : IDisposable
|
||||
{
|
||||
private readonly string _testOutputDir;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public CorpusHealthChecksIntegrationTests()
|
||||
{
|
||||
_testOutputDir = Path.Combine(Path.GetTempPath(), $"corpus-health-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testOutputDir);
|
||||
_serviceProvider = BuildServiceProvider();
|
||||
}
|
||||
|
||||
#region DebuginfodAvailabilityCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task DebuginfodAvailabilityCheck_AllUrlsReachable_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.OK);
|
||||
var options = CreateDebuginfodOptions(["https://debuginfod.fedora.org", "https://debuginfod.ubuntu.com"]);
|
||||
var check = new DebuginfodAvailabilityCheck(httpClient, options, NullLogger<DebuginfodAvailabilityCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("reachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebuginfodAvailabilityCheck_SomeUrlsUnreachable_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(statusByUrl: new Dictionary<string, HttpStatusCode>
|
||||
{
|
||||
["https://debuginfod.fedora.org"] = HttpStatusCode.OK,
|
||||
["https://debuginfod.ubuntu.com"] = HttpStatusCode.ServiceUnavailable
|
||||
});
|
||||
var options = CreateDebuginfodOptions(["https://debuginfod.fedora.org", "https://debuginfod.ubuntu.com"]);
|
||||
var check = new DebuginfodAvailabilityCheck(httpClient, options, NullLogger<DebuginfodAvailabilityCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
result.Message.Should().Contain("unreachable");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DebuginfodAvailabilityCheck_AllUrlsUnreachable_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.ServiceUnavailable);
|
||||
var options = CreateDebuginfodOptions(["https://debuginfod.fedora.org"]);
|
||||
var check = new DebuginfodAvailabilityCheck(httpClient, options, NullLogger<DebuginfodAvailabilityCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CorpusMirrorFreshnessCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_FreshMirrors_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
SetupFreshMirrors();
|
||||
var options = CreateMirrorOptions(_testOutputDir);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("fresh");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_StaleMirrors_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
SetupStaleMirrors(daysOld: 5); // Within warning threshold
|
||||
var options = CreateMirrorOptions(_testOutputDir, staleDays: 7, warnDays: 3);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
result.Message.Should().Contain("stale");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_VeryOldMirrors_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
SetupStaleMirrors(daysOld: 30); // Beyond stale threshold
|
||||
var options = CreateMirrorOptions(_testOutputDir, staleDays: 7);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorpusMirrorFreshnessCheck_MissingLastSync_ReturnsFail()
|
||||
{
|
||||
// Arrange - no .last-sync files
|
||||
Directory.CreateDirectory(Path.Combine(_testOutputDir, "mirrors", "debian"));
|
||||
var options = CreateMirrorOptions(_testOutputDir);
|
||||
var check = new CorpusMirrorFreshnessCheck(options, NullLogger<CorpusMirrorFreshnessCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
result.Message.Should().Contain("never synced");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region KpiBaselineExistsCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task KpiBaselineExistsCheck_BaselineExists_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
SetupValidBaseline();
|
||||
var options = CreateKpiOptions(_testOutputDir);
|
||||
var check = new KpiBaselineExistsCheck(options, NullLogger<KpiBaselineExistsCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("baseline");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KpiBaselineExistsCheck_BaselineMissing_ReturnsFail()
|
||||
{
|
||||
// Arrange - no baseline file
|
||||
var options = CreateKpiOptions(_testOutputDir);
|
||||
var check = new KpiBaselineExistsCheck(options, NullLogger<KpiBaselineExistsCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
result.Message.Should().Contain("not found");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KpiBaselineExistsCheck_OldBaseline_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
SetupOldBaseline(daysOld: 45); // Older than recommended refresh interval
|
||||
var options = CreateKpiOptions(_testOutputDir, maxBaselineAgeDays: 30);
|
||||
var check = new KpiBaselineExistsCheck(options, NullLogger<KpiBaselineExistsCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
result.Message.Should().Contain("old");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildinfoCacheAccessibleCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task BuildinfoCacheAccessibleCheck_CacheAccessible_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.OK);
|
||||
var options = CreateBuildinfoOptions("https://buildinfos.debian.net");
|
||||
var check = new BuildinfoCacheAccessibleCheck(httpClient, options, NullLogger<BuildinfoCacheAccessibleCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildinfoCacheAccessibleCheck_CacheUnreachable_ReturnsFail()
|
||||
{
|
||||
// Arrange
|
||||
var httpClient = CreateMockHttpClient(HttpStatusCode.ServiceUnavailable);
|
||||
var options = CreateBuildinfoOptions("https://buildinfos.debian.net");
|
||||
var check = new BuildinfoCacheAccessibleCheck(httpClient, options, NullLogger<BuildinfoCacheAccessibleCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Fail);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SymbolRecoveryFallbackCheck Tests
|
||||
|
||||
[Fact]
|
||||
public async Task SymbolRecoveryFallbackCheck_FallbackConfigured_ReturnsPass()
|
||||
{
|
||||
// Arrange
|
||||
SetupFallbackSymbols();
|
||||
var options = CreateFallbackOptions(_testOutputDir, fallbackEnabled: true);
|
||||
var check = new SymbolRecoveryFallbackCheck(options, NullLogger<SymbolRecoveryFallbackCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Pass);
|
||||
result.Message.Should().Contain("fallback");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SymbolRecoveryFallbackCheck_FallbackDisabled_ReturnsWarn()
|
||||
{
|
||||
// Arrange
|
||||
var options = CreateFallbackOptions(_testOutputDir, fallbackEnabled: false);
|
||||
var check = new SymbolRecoveryFallbackCheck(options, NullLogger<SymbolRecoveryFallbackCheck>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await check.RunAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Status.Should().Be(CheckStatus.Warn);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Plugin Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void BinaryAnalysisDoctorPlugin_RegistersAllCorpusChecks()
|
||||
{
|
||||
// Arrange
|
||||
var plugin = new BinaryAnalysisDoctorPlugin();
|
||||
|
||||
// Act
|
||||
var checks = plugin.GetChecks(_serviceProvider).ToList();
|
||||
|
||||
// Assert
|
||||
checks.Should().Contain(c => c.Name.Contains("debuginfod", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("mirror", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("baseline", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("buildinfo", StringComparison.OrdinalIgnoreCase));
|
||||
checks.Should().Contain(c => c.Name.Contains("fallback", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllCorpusChecks_RunWithoutException()
|
||||
{
|
||||
// Arrange
|
||||
SetupFreshMirrors();
|
||||
SetupValidBaseline();
|
||||
SetupFallbackSymbols();
|
||||
|
||||
var plugin = new BinaryAnalysisDoctorPlugin();
|
||||
var checks = plugin.GetChecks(_serviceProvider).ToList();
|
||||
|
||||
// Act & Assert
|
||||
foreach (var check in checks.Where(c => c.Category == "corpus"))
|
||||
{
|
||||
var act = async () => await check.RunAsync(CancellationToken.None);
|
||||
await act.Should().NotThrowAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private IServiceProvider BuildServiceProvider()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddSingleton(CreateMockHttpClient(HttpStatusCode.OK));
|
||||
services.Configure<DebuginfodOptions>(o =>
|
||||
{
|
||||
o.Urls = ["https://debuginfod.fedora.org"];
|
||||
});
|
||||
services.Configure<CorpusMirrorOptions>(o =>
|
||||
{
|
||||
o.MirrorsPath = Path.Combine(_testOutputDir, "mirrors");
|
||||
});
|
||||
services.Configure<KpiOptions>(o =>
|
||||
{
|
||||
o.BaselinePath = Path.Combine(_testOutputDir, "baselines", "current.json");
|
||||
});
|
||||
services.Configure<BuildinfoOptions>(o =>
|
||||
{
|
||||
o.CacheUrl = "https://buildinfos.debian.net";
|
||||
});
|
||||
services.Configure<SymbolFallbackOptions>(o =>
|
||||
{
|
||||
o.FallbackPath = Path.Combine(_testOutputDir, "fallback");
|
||||
o.Enabled = true;
|
||||
});
|
||||
|
||||
services.AddLogging();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
private static HttpClient CreateMockHttpClient(HttpStatusCode defaultStatus)
|
||||
{
|
||||
var handler = new MockHttpMessageHandler(defaultStatus);
|
||||
return new HttpClient(handler);
|
||||
}
|
||||
|
||||
private static HttpClient CreateMockHttpClient(Dictionary<string, HttpStatusCode> statusByUrl)
|
||||
{
|
||||
var handler = new MockHttpMessageHandler(statusByUrl);
|
||||
return new HttpClient(handler);
|
||||
}
|
||||
|
||||
private static IOptions<DebuginfodOptions> CreateDebuginfodOptions(string[] urls)
|
||||
{
|
||||
return Options.Create(new DebuginfodOptions { Urls = urls });
|
||||
}
|
||||
|
||||
private IOptions<CorpusMirrorOptions> CreateMirrorOptions(string basePath, int staleDays = 7, int warnDays = 3)
|
||||
{
|
||||
return Options.Create(new CorpusMirrorOptions
|
||||
{
|
||||
MirrorsPath = Path.Combine(basePath, "mirrors"),
|
||||
StaleDaysThreshold = staleDays,
|
||||
WarnDaysThreshold = warnDays
|
||||
});
|
||||
}
|
||||
|
||||
private IOptions<KpiOptions> CreateKpiOptions(string basePath, int maxBaselineAgeDays = 30)
|
||||
{
|
||||
return Options.Create(new KpiOptions
|
||||
{
|
||||
BaselinePath = Path.Combine(basePath, "baselines", "current.json"),
|
||||
MaxBaselineAgeDays = maxBaselineAgeDays
|
||||
});
|
||||
}
|
||||
|
||||
private static IOptions<BuildinfoOptions> CreateBuildinfoOptions(string url)
|
||||
{
|
||||
return Options.Create(new BuildinfoOptions { CacheUrl = url });
|
||||
}
|
||||
|
||||
private IOptions<SymbolFallbackOptions> CreateFallbackOptions(string basePath, bool fallbackEnabled)
|
||||
{
|
||||
return Options.Create(new SymbolFallbackOptions
|
||||
{
|
||||
FallbackPath = Path.Combine(basePath, "fallback"),
|
||||
Enabled = fallbackEnabled
|
||||
});
|
||||
}
|
||||
|
||||
private void SetupFreshMirrors()
|
||||
{
|
||||
var mirrorsPath = Path.Combine(_testOutputDir, "mirrors");
|
||||
foreach (var mirror in new[] { "debian", "ubuntu", "alpine", "osv" })
|
||||
{
|
||||
var mirrorDir = Path.Combine(mirrorsPath, mirror);
|
||||
Directory.CreateDirectory(mirrorDir);
|
||||
File.WriteAllText(Path.Combine(mirrorDir, ".last-sync"), DateTime.UtcNow.ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupStaleMirrors(int daysOld)
|
||||
{
|
||||
var mirrorsPath = Path.Combine(_testOutputDir, "mirrors");
|
||||
foreach (var mirror in new[] { "debian", "ubuntu" })
|
||||
{
|
||||
var mirrorDir = Path.Combine(mirrorsPath, mirror);
|
||||
Directory.CreateDirectory(mirrorDir);
|
||||
File.WriteAllText(Path.Combine(mirrorDir, ".last-sync"),
|
||||
DateTime.UtcNow.AddDays(-daysOld).ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupValidBaseline()
|
||||
{
|
||||
var baselineDir = Path.Combine(_testOutputDir, "baselines");
|
||||
Directory.CreateDirectory(baselineDir);
|
||||
var baseline = new
|
||||
{
|
||||
baselineId = Guid.NewGuid().ToString(),
|
||||
createdAt = DateTime.UtcNow.ToString("O"),
|
||||
precision = 0.95,
|
||||
recall = 0.92
|
||||
};
|
||||
File.WriteAllText(Path.Combine(baselineDir, "current.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(baseline));
|
||||
}
|
||||
|
||||
private void SetupOldBaseline(int daysOld)
|
||||
{
|
||||
var baselineDir = Path.Combine(_testOutputDir, "baselines");
|
||||
Directory.CreateDirectory(baselineDir);
|
||||
var baseline = new
|
||||
{
|
||||
baselineId = Guid.NewGuid().ToString(),
|
||||
createdAt = DateTime.UtcNow.AddDays(-daysOld).ToString("O"),
|
||||
precision = 0.95,
|
||||
recall = 0.92
|
||||
};
|
||||
File.WriteAllText(Path.Combine(baselineDir, "current.json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(baseline));
|
||||
}
|
||||
|
||||
private void SetupFallbackSymbols()
|
||||
{
|
||||
var fallbackDir = Path.Combine(_testOutputDir, "fallback");
|
||||
Directory.CreateDirectory(fallbackDir);
|
||||
File.WriteAllText(Path.Combine(fallbackDir, "fallback-config.json"), "{}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testOutputDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(_testOutputDir, recursive: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mock HTTP Handler
|
||||
|
||||
private sealed class MockHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _defaultStatus;
|
||||
private readonly Dictionary<string, HttpStatusCode>? _statusByUrl;
|
||||
|
||||
public MockHttpMessageHandler(HttpStatusCode defaultStatus)
|
||||
{
|
||||
_defaultStatus = defaultStatus;
|
||||
}
|
||||
|
||||
public MockHttpMessageHandler(Dictionary<string, HttpStatusCode> statusByUrl)
|
||||
{
|
||||
_statusByUrl = statusByUrl;
|
||||
_defaultStatus = HttpStatusCode.OK;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = request.RequestUri?.ToString() ?? "";
|
||||
var status = _statusByUrl?.GetValueOrDefault(url, _defaultStatus) ?? _defaultStatus;
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(status));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
// Enum for HTTP status codes (if not already in scope)
|
||||
file enum HttpStatusCode
|
||||
{
|
||||
OK = 200,
|
||||
ServiceUnavailable = 503
|
||||
}
|
||||
|
||||
// Check status enum (if not already defined in the Doctor plugin)
|
||||
file enum CheckStatus
|
||||
{
|
||||
Pass,
|
||||
Warn,
|
||||
Fail
|
||||
}
|
||||
@@ -10,8 +10,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user