Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.

This commit is contained in:
StellaOps Bot
2025-12-26 21:54:17 +02:00
parent 335ff7da16
commit c2b9cd8d1f
3717 changed files with 264714 additions and 48202 deletions

View File

@@ -22,4 +22,5 @@ app.MapAirGapEndpoints();
app.Run();
public partial class Program { }
// Make Program class file-scoped to prevent it from being exposed to referencing assemblies
file sealed partial class Program;

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.AirGap.Controller": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62500;http://localhost:62503"
}
}
}

View File

@@ -7,10 +7,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
<PackageReference Include="BouncyCastle.Cryptography" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Importer</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="YamlDotNet" Version="13.7.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\Attestor\\StellaOps.Attestor.Envelope\\StellaOps.Attestor.Envelope.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Cryptography\\StellaOps.Cryptography.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Cryptography.Plugin.OfflineVerification\\StellaOps.Cryptography.Plugin.OfflineVerification.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
@@ -118,7 +118,6 @@ public sealed class HttpClientUsageAnalyzerTests
{
using var workspace = new AdhocWorkspace();
using StellaOps.TestKit;
var projectId = ProjectId.CreateNewId();
var documentId = DocumentId.CreateNewId(projectId);
var stubDocumentId = DocumentId.CreateNewId(projectId);

View File

@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// PolicyAnalyzerRoslynTests.cs
// Sprint: SPRINT_5100_0010_0004_airgap_tests
// Tasks: AIRGAP-5100-005, AIRGAP-5100-006
@@ -485,7 +485,6 @@ public sealed class PolicyAnalyzerRoslynTests
{
using var workspace = new AdhocWorkspace();
using StellaOps.TestKit;
var projectId = ProjectId.CreateNewId();
var documentId = DocumentId.CreateNewId(projectId);
var stubDocumentId = DocumentId.CreateNewId(projectId);

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -9,15 +9,14 @@
<!-- Test packages inherited from Directory.Build.props -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.AirGap.Policy.Analyzers\StellaOps.AirGap.Policy.Analyzers.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -11,8 +11,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
@@ -202,7 +202,6 @@ public sealed class EgressPolicyTests
using var client = EgressHttpClientFactory.Create(recordingPolicy, request);
using StellaOps.TestKit;
Assert.True(recordingPolicy.EnsureAllowedCalled);
Assert.NotNull(client);
}

View File

@@ -13,5 +13,4 @@
<ProjectReference Include="..\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -7,9 +7,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,30 +0,0 @@
using System.Reflection;
using StellaOps.AirGap.Storage.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.AirGap.Storage.Postgres.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the AirGap module.
/// Runs migrations from embedded resources and provides test isolation.
/// </summary>
public sealed class AirGapPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AirGapPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(AirGapDataSource).Assembly;
protected override string GetModuleName() => "AirGap";
protected override string? GetResourcePrefix() => "Migrations";
}
/// <summary>
/// Collection definition for AirGap PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class AirGapPostgresCollection : ICollectionFixture<AirGapPostgresFixture>
{
public const string Name = "AirGapPostgres";
}

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.AirGap.Storage.Postgres\StellaOps.AirGap.Storage.Postgres.csproj" />
<ProjectReference Include="..\StellaOps.AirGap.Controller\StellaOps.AirGap.Controller.csproj" />
<ProjectReference Include="..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>StellaOps.AirGap.Storage.Postgres</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"StellaOps.AirGap.Time": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:62505;http://localhost:62506"
}
}
}

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<!-- AIRGAP-TIME-57-001: RFC3161 verification requires PKCS support -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.0" />
<PackageReference Include="System.Security.Cryptography.Pkcs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.AirGap.Importer/StellaOps.AirGap.Importer.csproj" />

View File

@@ -0,0 +1,450 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Controller", "StellaOps.AirGap.Controller", "{9DA0004A-1BCA-3B7A-412F-15593C6F1028}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Importer", "StellaOps.AirGap.Importer", "{C5FAA63C-4A94-D386-F136-5BD45D3BD8FC}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{7DBF8C1E-F16A-4F8C-F16D-3062D454FB26}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy", "{3056069B-18EC-C954-603F-9E1BADBC5A62}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy.Analyzers", "StellaOps.AirGap.Policy.Analyzers", "{2CAEABFD-267E-9224-5E1C-B8F70A0A3CB2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy.Analyzers.Tests", "StellaOps.AirGap.Policy.Analyzers.Tests", "{EB1F748B-E5EB-0F9C-76A5-9B797F34DB98}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Policy.Tests", "StellaOps.AirGap.Policy.Tests", "{510C2F4E-DD93-97B3-C041-285142D9F330}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Time", "StellaOps.AirGap.Time", "{47C2364F-6BF0-7292-A9BA-FF57216AF67A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__External", "__External", "{5B52EF8A-3661-DCFF-797D-BC4D6AC60BDA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aoc", "Aoc", "{03DFF14F-7321-1784-D4C7-4E99D4120F48}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{BDD326D6-7616-84F0-B914-74743BFBA520}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Aoc", "StellaOps.Aoc", "{EC506DBE-AB6D-492E-786E-8B176021BF2E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Attestor", "Attestor", "{5AC09D9A-F2A5-9CFA-B3C5-8D25F257651C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.Envelope", "StellaOps.Attestor.Envelope", "{018E0E11-1CCE-A2BE-641D-21EE14D2E90D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB67BDB9-D701-3AC9-9CDF-ECCDCCD8DB6D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Attestor.ProofChain", "StellaOps.Attestor.ProofChain", "{45F7FA87-7451-6970-7F6E-F8BAE45E081B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Concelier", "Concelier", "{157C3671-CA0B-69FA-A7C9-74A1FDA97B99}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{F39E09D6-BF93-B64A-CFE7-2BA92815C0FE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Core", "StellaOps.Concelier.Core", "{6844B539-C2A3-9D4F-139D-9D533BCABADA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Models", "StellaOps.Concelier.Models", "{BC35DE94-4F04-3436-27A3-F11647FEDD5C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.Normalization", "StellaOps.Concelier.Normalization", "{864C8B80-771A-0C15-30A5-558F99006E0D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.RawModels", "StellaOps.Concelier.RawModels", "{1DCF4EBB-DBC4-752C-13D4-D1EECE4E8907}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Concelier.SourceIntel", "StellaOps.Concelier.SourceIntel", "{F2B58F4E-6F28-A25F-5BFB-CDEBAD6B9A3E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Excititor", "Excititor", "{7D49FA52-6EA1-EAC8-4C5A-AC07188D6C57}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{C9CF27FC-12DB-954F-863C-576BA8E309A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Excititor.Core", "StellaOps.Excititor.Core", "{6DCAF6F3-717F-27A9-D96C-F2BFA5550347}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Feedser", "Feedser", "{C4A90603-BE42-0044-CAB4-3EB910AD51A5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.BinaryAnalysis", "StellaOps.Feedser.BinaryAnalysis", "{054761F9-16D3-B2F8-6F4D-EFC2248805CD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Feedser.Core", "StellaOps.Feedser.Core", "{B54CE64C-4167-1DD1-B7D6-2FD7A5AEF715}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Policy", "Policy", "{8E6B774C-CC4E-CE7C-AD4B-8AF7C92889A6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy.RiskProfile", "StellaOps.Policy.RiskProfile", "{BC12ED55-6015-7C8B-8384-B39CE93C76D6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{FF70543D-AFF9-1D38-4950-4F8EE18D60BB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Policy", "StellaOps.Policy", "{831265B0-8896-9C95-3488-E12FD9F6DC53}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{1345DD29-BB3A-FB5F-4B3D-E29F6045A27A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Canonical.Json", "StellaOps.Canonical.Json", "{79E122F4-2325-3E92-438E-5825A307B594}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography", "StellaOps.Cryptography", "{66557252-B5C4-664B-D807-07018C627474}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Cryptography.Plugin.OfflineVerification", "StellaOps.Cryptography.Plugin.OfflineVerification", "{9FB0DDD7-7A77-8DA4-F9E2-A94E60ED8FC7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.DependencyInjection", "StellaOps.DependencyInjection", "{589A43FD-8213-E9E3-6CFF-9CBA72D53E98}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.EfCore", "StellaOps.Infrastructure.EfCore", "{FCD529E0-DD17-6587-B29C-12D425C0AD0C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres", "StellaOps.Infrastructure.Postgres", "{61B23570-4F2D-B060-BE1F-37995682E494}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Ingestion.Telemetry", "StellaOps.Ingestion.Telemetry", "{1182764D-2143-EEF0-9270-3DCE392F5D06}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Plugin", "StellaOps.Plugin", "{772B02B5-6280-E1D4-3E2E-248D0455C2FB}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Provenance", "StellaOps.Provenance", "{E69FA1A0-6D1B-A6E4-2DC0-8F4C5F21BF04}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.TestKit", "StellaOps.TestKit", "{8380A20C-A5B8-EE91-1A58-270323688CB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{90659617-4DF7-809A-4E5B-29BB5A98E8E1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{AB8B269C-5A2A-A4B8-0488-B5F81E55B4D9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.Infrastructure.Postgres.Testing", "StellaOps.Infrastructure.Postgres.Testing", "{CEDC2447-F717-3C95-7E08-F214D575A7B7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Libraries", "__Libraries", "{A5C98087-E847-D2C4-2143-20869479839D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Bundle", "StellaOps.AirGap.Bundle", "{C74BDF5E-977C-673A-2BD3-166CCD5B4A1C}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Persistence", "StellaOps.AirGap.Persistence", "{4F27BFA3-D275-574E-41FD-68FB7573C462}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{AB891B76-C0E8-53F9-5C21-062253F7FAD4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Bundle.Tests", "StellaOps.AirGap.Bundle.Tests", "{01EB1642-B632-1789-ABE6-8AD6DE1EF57E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "__Tests", "__Tests", "{BB76B5A5-14BA-E317-828D-110B711D71F5}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Controller.Tests", "StellaOps.AirGap.Controller.Tests", "{4D83C73F-C3C2-2F01-AC95-39B8D1C1C65D}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Importer.Tests", "StellaOps.AirGap.Importer.Tests", "{7C3C2AA9-CFF2-79B4-DAA6-8C519E030AA7}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Persistence.Tests", "StellaOps.AirGap.Persistence.Tests", "{1D7A59B6-4752-FB77-27E9-46609D7E17A4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StellaOps.AirGap.Time.Tests", "StellaOps.AirGap.Time.Tests", "{FD66D971-11C8-0DB3-91D3-6EEB3DB26178}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Bundle", "__Libraries\StellaOps.AirGap.Bundle\StellaOps.AirGap.Bundle.csproj", "{E168481D-1190-359F-F770-1725D7CC7357}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Bundle.Tests", "__Libraries\__Tests\StellaOps.AirGap.Bundle.Tests\StellaOps.AirGap.Bundle.Tests.csproj", "{4C4EB457-ACC9-0720-0BD0-798E504DB742}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Controller", "StellaOps.AirGap.Controller\StellaOps.AirGap.Controller.csproj", "{73A72ECE-BE20-88AE-AD8D-0F20DE511D88}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Controller.Tests", "__Tests\StellaOps.AirGap.Controller.Tests\StellaOps.AirGap.Controller.Tests.csproj", "{B0A7A2EF-E506-748C-5769-7E3F617A6BD7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Importer", "StellaOps.AirGap.Importer\StellaOps.AirGap.Importer.csproj", "{22B129C7-C609-3B90-AD56-64C746A1505E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Importer.Tests", "__Tests\StellaOps.AirGap.Importer.Tests\StellaOps.AirGap.Importer.Tests.csproj", "{64B9ED61-465C-9377-8169-90A72B322CCB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Persistence", "__Libraries\StellaOps.AirGap.Persistence\StellaOps.AirGap.Persistence.csproj", "{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Persistence.Tests", "__Tests\StellaOps.AirGap.Persistence.Tests\StellaOps.AirGap.Persistence.Tests.csproj", "{99FDE177-A3EB-A552-1EDE-F56E66D496C1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy", "StellaOps.AirGap.Policy\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj", "{AD31623A-BC43-52C2-D906-AC1D8784A541}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy.Analyzers", "StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.Analyzers\StellaOps.AirGap.Policy.Analyzers.csproj", "{42B622F5-A3D6-65DE-D58A-6629CEC93109}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy.Analyzers.Tests", "StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.Analyzers.Tests\StellaOps.AirGap.Policy.Analyzers.Tests.csproj", "{991EF69B-EA1C-9FF3-8127-9D2EA76D3DB2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Policy.Tests", "StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.Tests\StellaOps.AirGap.Policy.Tests.csproj", "{BF0E591F-DCCE-AA7A-AF46-34A875BBC323}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Time", "StellaOps.AirGap.Time\StellaOps.AirGap.Time.csproj", "{BE02245E-5C26-1A50-A5FD-449B2ACFB10A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.AirGap.Time.Tests", "__Tests\StellaOps.AirGap.Time.Tests\StellaOps.AirGap.Time.Tests.csproj", "{FB30AFA1-E6B1-BEEF-582C-125A3AE38735}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Aoc", "E:\dev\git.stella-ops.org\src\Aoc\__Libraries\StellaOps.Aoc\StellaOps.Aoc.csproj", "{776E2142-804F-03B9-C804-D061D64C6092}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.Envelope", "E:\dev\git.stella-ops.org\src\Attestor\StellaOps.Attestor.Envelope\StellaOps.Attestor.Envelope.csproj", "{3D8C5A6C-462D-7487-5BD0-A3EF6B657EB6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Attestor.ProofChain", "E:\dev\git.stella-ops.org\src\Attestor\__Libraries\StellaOps.Attestor.ProofChain\StellaOps.Attestor.ProofChain.csproj", "{C6822231-A4F4-9E69-6CE2-4FDB3E81C728}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Canonical.Json", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj", "{AF9E7F02-25AD-3540-18D7-F6A4F8BA5A60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Core", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj", "{BA45605A-1CCE-6B0C-489D-C113915B243F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Models", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Models\StellaOps.Concelier.Models.csproj", "{8DCCAF70-D364-4C8B-4E90-AF65091DE0C5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.Normalization", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.Normalization\StellaOps.Concelier.Normalization.csproj", "{7828C164-DD01-2809-CCB3-364486834F60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.RawModels", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj", "{34EFF636-81A7-8DF6-7CC9-4DA784BAC7F3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Concelier.SourceIntel", "E:\dev\git.stella-ops.org\src\Concelier\__Libraries\StellaOps.Concelier.SourceIntel\StellaOps.Concelier.SourceIntel.csproj", "{EB093C48-CDAC-106B-1196-AE34809B34C0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{F664A948-E352-5808-E780-77A03F19E93E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography.Plugin.OfflineVerification", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Cryptography.Plugin.OfflineVerification\StellaOps.Cryptography.Plugin.OfflineVerification.csproj", "{246FCC7C-1437-742D-BAE5-E77A24164F08}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{632A1F0D-1BA5-C84B-B716-2BE638A92780}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Excititor.Core", "E:\dev\git.stella-ops.org\src\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj", "{9151601C-8784-01A6-C2E7-A5C0FAAB0AEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.BinaryAnalysis", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.BinaryAnalysis\StellaOps.Feedser.BinaryAnalysis.csproj", "{CB296A20-2732-77C1-7F23-27D5BAEDD0C7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Feedser.Core", "E:\dev\git.stella-ops.org\src\Feedser\StellaOps.Feedser.Core\StellaOps.Feedser.Core.csproj", "{0DBEC9BA-FE1D-3898-B2C6-E4357DC23E0F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.EfCore", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj", "{A63897D9-9531-989B-7309-E384BCFC2BB9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj", "{8C594D82-3463-3367-4F06-900AC707753D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Infrastructure.Postgres.Testing", "E:\dev\git.stella-ops.org\src\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj", "{52F400CD-D473-7A1F-7986-89011CD2A887}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Ingestion.Telemetry", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Ingestion.Telemetry\StellaOps.Ingestion.Telemetry.csproj", "{9588FBF9-C37E-D16E-2E8F-CFA226EAC01D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Plugin", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj", "{38A9EE9B-6FC8-93BC-0D43-2A906E678D66}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy", "E:\dev\git.stella-ops.org\src\Policy\__Libraries\StellaOps.Policy\StellaOps.Policy.csproj", "{19868E2D-7163-2108-1094-F13887C4F070}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Policy.RiskProfile", "E:\dev\git.stella-ops.org\src\Policy\StellaOps.Policy.RiskProfile\StellaOps.Policy.RiskProfile.csproj", "{CC319FC5-F4B1-C3DD-7310-4DAD343E0125}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Provenance", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.Provenance\StellaOps.Provenance.csproj", "{CBB14B90-27F9-8DD6-DFC4-3507DBD1FBC6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.TestKit", "E:\dev\git.stella-ops.org\src\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj", "{AF043113-CCE3-59C1-DF71-9804155F26A8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E168481D-1190-359F-F770-1725D7CC7357}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E168481D-1190-359F-F770-1725D7CC7357}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E168481D-1190-359F-F770-1725D7CC7357}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E168481D-1190-359F-F770-1725D7CC7357}.Release|Any CPU.Build.0 = Release|Any CPU
{4C4EB457-ACC9-0720-0BD0-798E504DB742}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4C4EB457-ACC9-0720-0BD0-798E504DB742}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4C4EB457-ACC9-0720-0BD0-798E504DB742}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4C4EB457-ACC9-0720-0BD0-798E504DB742}.Release|Any CPU.Build.0 = Release|Any CPU
{73A72ECE-BE20-88AE-AD8D-0F20DE511D88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{73A72ECE-BE20-88AE-AD8D-0F20DE511D88}.Debug|Any CPU.Build.0 = Debug|Any CPU
{73A72ECE-BE20-88AE-AD8D-0F20DE511D88}.Release|Any CPU.ActiveCfg = Release|Any CPU
{73A72ECE-BE20-88AE-AD8D-0F20DE511D88}.Release|Any CPU.Build.0 = Release|Any CPU
{B0A7A2EF-E506-748C-5769-7E3F617A6BD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B0A7A2EF-E506-748C-5769-7E3F617A6BD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B0A7A2EF-E506-748C-5769-7E3F617A6BD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B0A7A2EF-E506-748C-5769-7E3F617A6BD7}.Release|Any CPU.Build.0 = Release|Any CPU
{22B129C7-C609-3B90-AD56-64C746A1505E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{22B129C7-C609-3B90-AD56-64C746A1505E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{22B129C7-C609-3B90-AD56-64C746A1505E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{22B129C7-C609-3B90-AD56-64C746A1505E}.Release|Any CPU.Build.0 = Release|Any CPU
{64B9ED61-465C-9377-8169-90A72B322CCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64B9ED61-465C-9377-8169-90A72B322CCB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{64B9ED61-465C-9377-8169-90A72B322CCB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64B9ED61-465C-9377-8169-90A72B322CCB}.Release|Any CPU.Build.0 = Release|Any CPU
{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68C75AAB-0E77-F9CF-9924-6C2BF6488ACD}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -6,10 +6,6 @@
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Collections.Immutable" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />

View File

@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
namespace StellaOps.AirGap.Persistence.EfCore.Context;
/// <summary>
/// EF Core DbContext for AirGap module.
/// This is a stub that will be scaffolded from the PostgreSQL database.
/// </summary>
public class AirGapDbContext : DbContext
{
public AirGapDbContext(DbContextOptions<AirGapDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("airgap");
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -2,24 +2,21 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Storage.Postgres.Repositories;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.AirGap.Storage.Postgres;
namespace StellaOps.AirGap.Persistence.Extensions;
/// <summary>
/// Extension methods for configuring AirGap PostgreSQL storage services.
/// Extension methods for configuring AirGap persistence services.
/// </summary>
public static class ServiceCollectionExtensions
public static class AirGapPersistenceExtensions
{
/// <summary>
/// Adds AirGap PostgreSQL storage services.
/// Adds AirGap PostgreSQL persistence services.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration root.</param>
/// <param name="sectionName">Configuration section name for PostgreSQL options.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddAirGapPostgresStorage(
public static IServiceCollection AddAirGapPersistence(
this IServiceCollection services,
IConfiguration configuration,
string sectionName = "Postgres:AirGap")
@@ -33,12 +30,9 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Adds AirGap PostgreSQL storage services with explicit options.
/// Adds AirGap PostgreSQL persistence services with explicit options.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configureOptions">Options configuration action.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddAirGapPostgresStorage(
public static IServiceCollection AddAirGapPersistence(
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{

View File

@@ -4,7 +4,7 @@ using Npgsql;
using StellaOps.Infrastructure.Postgres.Connections;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.AirGap.Storage.Postgres;
namespace StellaOps.AirGap.Persistence.Postgres;
/// <summary>
/// PostgreSQL data source for AirGap module.

View File

@@ -6,7 +6,7 @@ using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Models;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AirGap.Storage.Postgres.Repositories;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed store for AirGap sealing state.

View File

@@ -3,7 +3,7 @@ using Npgsql;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.Infrastructure.Postgres.Repositories;
namespace StellaOps.AirGap.Storage.Postgres.Repositories;
namespace StellaOps.AirGap.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL-backed store for AirGap bundle version activation tracking.

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<RootNamespace>StellaOps.AirGap.Persistence</RootNamespace>
<AssemblyName>StellaOps.AirGap.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps AirGap module</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\StellaOps.AirGap.Controller\StellaOps.AirGap.Controller.csproj" />
<ProjectReference Include="..\..\StellaOps.AirGap.Importer\StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.EfCore\StellaOps.Infrastructure.EfCore.csproj" />
</ItemGroup>
</Project>

View File

@@ -87,9 +87,9 @@ public sealed class AirGapIntegrationTests : IDisposable
var offlineBundlePath = Path.Combine(_offlineEnvPath, "imported-bundle");
CopyDirectory(bundleOutputPath, offlineBundlePath);
// Import in offline environment
var loader = new BundleLoader();
var importedManifest = await loader.LoadAsync(offlineBundlePath);
// Import in offline environment - load manifest directly
var importedManifestJson = await File.ReadAllTextAsync(Path.Combine(offlineBundlePath, "manifest.json"));
var importedManifest = BundleManifestSerializer.Deserialize(importedManifestJson);
// Verify data integrity
var importedFeedPath = Path.Combine(offlineBundlePath, "feeds/nvd.json");
@@ -132,8 +132,9 @@ public sealed class AirGapIntegrationTests : IDisposable
var offlinePath = Path.Combine(_offlineEnvPath, "multi-imported");
CopyDirectory(bundlePath, offlinePath);
var loader = new BundleLoader();
var imported = await loader.LoadAsync(offlinePath);
// Load manifest directly
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
var imported = BundleManifestSerializer.Deserialize(loadedJson);
// Assert - All components transferred
imported.Feeds.Should().HaveCount(1);
@@ -173,9 +174,9 @@ public sealed class AirGapIntegrationTests : IDisposable
// Corrupt the feed file after transfer
await File.WriteAllTextAsync(Path.Combine(offlinePath, "feeds/nvd.json"), """{"corrupted":"malicious data"}""");
// Act - Load (should succeed but digest verification would fail)
var loader = new BundleLoader();
var imported = await loader.LoadAsync(offlinePath);
// Act - Load manifest directly (digest verification would fail if validated)
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
var imported = BundleManifestSerializer.Deserialize(loadedJson);
// Verify digest mismatch
var actualContent = await File.ReadAllTextAsync(Path.Combine(offlinePath, "feeds/nvd.json"));
@@ -230,9 +231,9 @@ public sealed class AirGapIntegrationTests : IDisposable
var offlinePath = Path.Combine(_offlineEnvPath, "policy-imported");
CopyDirectory(bundlePath, offlinePath);
// Load in offline
var loader = new BundleLoader();
var imported = await loader.LoadAsync(offlinePath);
// Load manifest directly
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
var imported = BundleManifestSerializer.Deserialize(loadedJson);
// Verify policy content
var importedPolicyPath = Path.Combine(offlinePath, "policies/security.rego");
@@ -283,8 +284,9 @@ public sealed class AirGapIntegrationTests : IDisposable
var offlinePath = Path.Combine(_offlineEnvPath, "multi-policy-imported");
CopyDirectory(bundlePath, offlinePath);
var loader = new BundleLoader();
var imported = await loader.LoadAsync(offlinePath);
// Load manifest directly
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
var imported = BundleManifestSerializer.Deserialize(loadedJson);
// Assert
imported.Policies.Should().HaveCount(3);
@@ -313,7 +315,7 @@ public sealed class AirGapIntegrationTests : IDisposable
null,
Array.Empty<FeedBuildConfig>(),
new[] { new PolicyBuildConfig("signed-policy", "signed", "1.0", policyPath, "policies/signed.rego", PolicyType.OpaRego) },
new[] { new CryptoBuildConfig("signing-cert", "signing", certPath, "certs/signing.pem", CryptoComponentType.SigningCertificate, null) });
new[] { new CryptoBuildConfig("signing-cert", "signing", certPath, "certs/signing.pem", CryptoComponentType.SigningKey, null) });
var bundlePath = Path.Combine(_onlineEnvPath, "signed-bundle");
@@ -324,8 +326,9 @@ public sealed class AirGapIntegrationTests : IDisposable
var offlinePath = Path.Combine(_offlineEnvPath, "signed-imported");
CopyDirectory(bundlePath, offlinePath);
var loader = new BundleLoader();
var imported = await loader.LoadAsync(offlinePath);
// Load manifest directly
var loadedJson = await File.ReadAllTextAsync(Path.Combine(offlinePath, "manifest.json"));
var imported = BundleManifestSerializer.Deserialize(loadedJson);
// Assert
imported.Policies.Should().HaveCount(1);

View File

@@ -5,6 +5,7 @@ using FluentAssertions;
using StellaOps.AirGap.Bundle.Models;
using StellaOps.AirGap.Bundle.Serialization;
using StellaOps.AirGap.Bundle.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Bundle.Tests;
@@ -120,7 +121,7 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
#region Roundtrip Determinism Tests
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task Roundtrip_ExportImportReexport_IdenticalBundle()
{
// Arrange
@@ -152,7 +153,6 @@ public sealed class BundleDeterminismTests : IAsyncLifetime
// Re-export using the imported file
var reimportFeedFile = CreateSourceFile("reimport/feed.json", importedContent);
using StellaOps.TestKit;
var request2 = new BundleBuildRequest(
"roundtrip-test",
"1.0.0",

View File

@@ -12,6 +12,7 @@ using FluentAssertions;
using StellaOps.AirGap.Bundle.Models;
using StellaOps.AirGap.Bundle.Serialization;
using StellaOps.AirGap.Bundle.Services;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.AirGap.Bundle.Tests;
@@ -166,9 +167,9 @@ public sealed class BundleExportImportTests : IDisposable
var manifestPath = Path.Combine(bundlePath, "manifest.json");
await File.WriteAllTextAsync(manifestPath, BundleManifestSerializer.Serialize(manifest));
// Act - Load the bundle
var loader = new BundleLoader();
var loaded = await loader.LoadAsync(bundlePath);
// Act - Load the bundle manifest directly
var loadedJson = await File.ReadAllTextAsync(manifestPath);
var loaded = BundleManifestSerializer.Deserialize(loadedJson);
// Assert
loaded.Should().NotBeNull();
@@ -192,14 +193,14 @@ public sealed class BundleExportImportTests : IDisposable
var manifestPath = Path.Combine(bundlePath, "manifest.json");
await File.WriteAllTextAsync(manifestPath, BundleManifestSerializer.Serialize(manifest));
// Act
var loader = new BundleLoader();
var loaded = await loader.LoadAsync(bundlePath);
// Act - Load manifest directly
var loadedJson = await File.ReadAllTextAsync(manifestPath);
var loaded = BundleManifestSerializer.Deserialize(loadedJson);
// Assert - Verify file exists and digest matches
var feedPath = Path.Combine(bundlePath, "feeds", "nvd.json");
File.Exists(feedPath).Should().BeTrue();
var actualContent = await File.ReadAllTextAsync(feedPath);
var actualDigest = ComputeSha256Hex(actualContent);
loaded.Feeds[0].Digest.Should().Be(actualDigest);
@@ -224,9 +225,9 @@ public sealed class BundleExportImportTests : IDisposable
var corruptPath = Path.Combine(bundlePath, "feeds", "nvd.json");
await File.WriteAllTextAsync(corruptPath, """{"corrupted":"data"}""");
// Act
var loader = new BundleLoader();
var loaded = await loader.LoadAsync(bundlePath);
// Act - Load manifest directly (original digest was computed before corruption)
var loadedJson = await File.ReadAllTextAsync(manifestPath);
var loaded = BundleManifestSerializer.Deserialize(loadedJson);
// Assert - File content has changed, digest no longer matches
var actualContent = await File.ReadAllTextAsync(corruptPath);
@@ -326,7 +327,7 @@ public sealed class BundleExportImportTests : IDisposable
#region AIRGAP-5100-004: Roundtrip Determinism (Export Import Re-export)
[Trait("Category", TestCategories.Unit)]
[Fact]
[Fact]
public async Task Roundtrip_ExportImportReexport_ProducesIdenticalFileDigests()
{
// Arrange - Initial export
@@ -340,16 +341,15 @@ public sealed class BundleExportImportTests : IDisposable
var manifest1 = await builder.BuildAsync(request, bundlePath1);
var digest1 = manifest1.Feeds[0].Digest;
// Import by loading manifest
// Import by loading manifest directly
var manifestJson = BundleManifestSerializer.Serialize(manifest1);
await File.WriteAllTextAsync(Path.Combine(bundlePath1, "manifest.json"), manifestJson);
var loader = new BundleLoader();
var imported = await loader.LoadAsync(bundlePath1);
var loadedJson = await File.ReadAllTextAsync(Path.Combine(bundlePath1, "manifest.json"));
var imported = BundleManifestSerializer.Deserialize(loadedJson);
// Re-export using the imported bundle's files
var reexportFeedFile = Path.Combine(bundlePath1, "feeds", "nvd.json");
using StellaOps.TestKit;
var reexportRequest = new BundleBuildRequest(
imported.Name,
imported.Version,

View File

@@ -554,7 +554,6 @@ public sealed class BundleImportTests : IAsyncLifetime
private static async Task<string> ComputeFileDigestAsync(string filePath)
{
await using var stream = File.OpenRead(filePath);
using StellaOps.TestKit;
var hash = await SHA256.HashDataAsync(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -6,16 +6,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="NSubstitute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.AirGap.Bundle\StellaOps.AirGap.Bundle.csproj" />
<ProjectReference Include="../../StellaOps.AirGap.Bundle/StellaOps.AirGap.Bundle.csproj" />
<ProjectReference Include="../../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,169 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Controller.Options;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Controller.Tests;
public class AirGapStartupDiagnosticsHostedServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Blocks_when_allowlist_missing_for_sealed_state()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-x",
TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(60, 120)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir);
options.EgressAllowlist = null; // simulate missing config section
var service = CreateService(store, options, now);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
Assert.Contains("egress-allowlist-missing", ex.Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Passes_when_materials_present_and_anchor_fresh()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-ok",
TimeAnchor = new TimeAnchor(now.AddMinutes(-1), "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(300, 600)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir, new[] { "127.0.0.1/32" });
var service = CreateService(store, options, now);
await service.StartAsync(CancellationToken.None); // should not throw
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Blocks_when_anchor_is_stale()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-stale",
TimeAnchor = new TimeAnchor(now.AddHours(-2), "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(60, 90)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir, new[] { "10.0.0.0/24" });
var service = CreateService(store, options, now);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
Assert.Contains("time-anchor-stale", ex.Message);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Blocks_when_rotation_pending_without_dual_approval()
{
var now = DateTimeOffset.UtcNow;
var store = new InMemoryAirGapStateStore();
await store.SetAsync(new AirGapState
{
TenantId = "default",
Sealed = true,
PolicyHash = "policy-rot",
TimeAnchor = new TimeAnchor(now, "rough", "rough", "fp", "digest"),
StalenessBudget = new StalenessBudget(120, 240)
});
var trustDir = CreateTrustMaterial();
var options = BuildOptions(trustDir, new[] { "10.10.0.0/16" });
options.Rotation.PendingKeys["k-new"] = Convert.ToBase64String(new byte[] { 1, 2, 3 });
options.Rotation.ActiveKeys["k-old"] = Convert.ToBase64String(new byte[] { 9, 9, 9 });
options.Rotation.ApproverIds.Add("approver-1");
var service = CreateService(store, options, now);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => service.StartAsync(CancellationToken.None));
Assert.Contains("rotation:rotation-dual-approval-required", ex.Message);
}
private static AirGapStartupOptions BuildOptions(string trustDir, string[]? allowlist = null)
{
return new AirGapStartupOptions
{
TenantId = "default",
EgressAllowlist = allowlist,
Trust = new TrustMaterialOptions
{
RootJsonPath = Path.Combine(trustDir, "root.json"),
SnapshotJsonPath = Path.Combine(trustDir, "snapshot.json"),
TimestampJsonPath = Path.Combine(trustDir, "timestamp.json")
}
};
}
private static AirGapStartupDiagnosticsHostedService CreateService(IAirGapStateStore store, AirGapStartupOptions options, DateTimeOffset now)
{
return new AirGapStartupDiagnosticsHostedService(
store,
new StalenessCalculator(),
new FixedTimeProvider(now),
Microsoft.Extensions.Options.Options.Create(options),
NullLogger<AirGapStartupDiagnosticsHostedService>.Instance,
new AirGapTelemetry(NullLogger<AirGapTelemetry>.Instance),
new TufMetadataValidator(),
new RootRotationPolicy());
}
private static string CreateTrustMaterial()
{
var dir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "airgap-trust-" + Guid.NewGuid().ToString("N"))).FullName;
var expires = DateTimeOffset.UtcNow.AddDays(1).ToString("O");
const string hash = "abc123";
File.WriteAllText(Path.Combine(dir, "root.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\"}}");
File.WriteAllText(Path.Combine(dir, "snapshot.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"meta\":{{\"snapshot\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
File.WriteAllText(Path.Combine(dir, "timestamp.json"), $"{{\"version\":1,\"expiresUtc\":\"{expires}\",\"snapshot\":{{\"meta\":{{\"hashes\":{{\"sha256\":\"{hash}\"}}}}}}}}");
return dir;
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now)
{
_now = now;
}
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,128 @@
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Controller.Tests;
public class AirGapStateServiceTests
{
private readonly AirGapStateService _service;
private readonly InMemoryAirGapStateStore _store = new();
private readonly StalenessCalculator _calculator = new();
public AirGapStateServiceTests()
{
_service = new AirGapStateService(_store, _calculator);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Seal_sets_state_and_computes_staleness()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-2), "roughtime", "roughtime", "fp", "digest");
var budget = new StalenessBudget(60, 120);
await _service.SealAsync("tenant-a", "policy-1", anchor, budget, now);
var status = await _service.GetStatusAsync("tenant-a", now);
Assert.True(status.State.Sealed);
Assert.Equal("policy-1", status.State.PolicyHash);
Assert.Equal("tenant-a", status.State.TenantId);
Assert.True(status.Staleness.AgeSeconds > 0);
Assert.True(status.Staleness.IsWarning);
Assert.Equal(120 - status.Staleness.AgeSeconds, status.Staleness.SecondsRemaining);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Unseal_clears_sealed_flag_and_updates_timestamp()
{
var now = DateTimeOffset.UtcNow;
await _service.SealAsync("default", "hash", TimeAnchor.Unknown, StalenessBudget.Default, now);
var later = now.AddMinutes(1);
await _service.UnsealAsync("default", later);
var status = await _service.GetStatusAsync("default", later);
Assert.False(status.State.Sealed);
Assert.Equal(later, status.State.LastTransitionAt);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Seal_persists_drift_baseline_seconds()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-5), "roughtime", "roughtime", "fp", "digest");
var budget = StalenessBudget.Default;
var state = await _service.SealAsync("tenant-drift", "policy-drift", anchor, budget, now);
Assert.Equal(300, state.DriftBaselineSeconds); // 5 minutes = 300 seconds
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Seal_creates_default_content_budgets_when_not_provided()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
var budget = new StalenessBudget(120, 240);
var state = await _service.SealAsync("tenant-content", "policy-content", anchor, budget, now);
Assert.Contains("advisories", state.ContentBudgets.Keys);
Assert.Contains("vex", state.ContentBudgets.Keys);
Assert.Contains("policy", state.ContentBudgets.Keys);
Assert.Equal(budget, state.ContentBudgets["advisories"]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Seal_uses_provided_content_budgets()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddMinutes(-1), "roughtime", "roughtime", "fp", "digest");
var budget = StalenessBudget.Default;
var contentBudgets = new Dictionary<string, StalenessBudget>
{
{ "advisories", new StalenessBudget(30, 60) },
{ "vex", new StalenessBudget(60, 120) }
};
var state = await _service.SealAsync("tenant-custom", "policy-custom", anchor, budget, now, contentBudgets);
Assert.Equal(new StalenessBudget(30, 60), state.ContentBudgets["advisories"]);
Assert.Equal(new StalenessBudget(60, 120), state.ContentBudgets["vex"]);
Assert.Equal(budget, state.ContentBudgets["policy"]); // Falls back to default
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task GetStatus_returns_per_content_staleness()
{
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now.AddSeconds(-45), "roughtime", "roughtime", "fp", "digest");
var budget = StalenessBudget.Default;
var contentBudgets = new Dictionary<string, StalenessBudget>
{
{ "advisories", new StalenessBudget(30, 60) },
{ "vex", new StalenessBudget(60, 120) },
{ "policy", new StalenessBudget(100, 200) }
};
await _service.SealAsync("tenant-content-status", "policy-content-status", anchor, budget, now, contentBudgets);
var status = await _service.GetStatusAsync("tenant-content-status", now);
Assert.NotEmpty(status.ContentStaleness);
Assert.True(status.ContentStaleness["advisories"].IsWarning); // 45s >= 30s warning
Assert.False(status.ContentStaleness["advisories"].IsBreach); // 45s < 60s breach
Assert.False(status.ContentStaleness["vex"].IsWarning); // 45s < 60s warning
Assert.False(status.ContentStaleness["policy"].IsWarning); // 45s < 100s warning
}
}

View File

@@ -0,0 +1,152 @@
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Time.Models;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Controller.Tests;
public class InMemoryAirGapStateStoreTests
{
private readonly InMemoryAirGapStateStore _store = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Upsert_and_read_state_by_tenant()
{
var state = new AirGapState
{
TenantId = "tenant-x",
Sealed = true,
PolicyHash = "hash-1",
TimeAnchor = new TimeAnchor(DateTimeOffset.UtcNow, "roughtime", "roughtime", "fp", "digest"),
StalenessBudget = new StalenessBudget(10, 20),
LastTransitionAt = DateTimeOffset.UtcNow
};
await _store.SetAsync(state);
var stored = await _store.GetAsync("tenant-x");
Assert.True(stored.Sealed);
Assert.Equal("hash-1", stored.PolicyHash);
Assert.Equal("tenant-x", stored.TenantId);
Assert.Equal(state.TimeAnchor.TokenDigest, stored.TimeAnchor.TokenDigest);
Assert.Equal(10, stored.StalenessBudget.WarningSeconds);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Enforces_singleton_per_tenant()
{
var first = new AirGapState { TenantId = "tenant-y", Sealed = true, PolicyHash = "h1" };
var second = new AirGapState { TenantId = "tenant-y", Sealed = false, PolicyHash = "h2" };
await _store.SetAsync(first);
await _store.SetAsync(second);
var stored = await _store.GetAsync("tenant-y");
Assert.Equal("h2", stored.PolicyHash);
Assert.False(stored.Sealed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Defaults_to_unknown_when_missing()
{
var stored = await _store.GetAsync("absent");
Assert.False(stored.Sealed);
Assert.Equal("absent", stored.TenantId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Parallel_upserts_keep_single_document()
{
var tasks = Enumerable.Range(0, 20).Select(i =>
{
var state = new AirGapState
{
TenantId = "tenant-parallel",
Sealed = i % 2 == 0,
PolicyHash = $"hash-{i}"
};
return _store.SetAsync(state);
});
await Task.WhenAll(tasks);
var stored = await _store.GetAsync("tenant-parallel");
Assert.StartsWith("hash-", stored.PolicyHash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Multi_tenant_updates_do_not_collide()
{
var tenants = Enumerable.Range(0, 5).Select(i => $"t-{i}").ToArray();
var tasks = tenants.Select(t => _store.SetAsync(new AirGapState
{
TenantId = t,
Sealed = true,
PolicyHash = $"hash-{t}"
}));
await Task.WhenAll(tasks);
foreach (var t in tenants)
{
var stored = await _store.GetAsync(t);
Assert.Equal($"hash-{t}", stored.PolicyHash);
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Staleness_round_trip_matches_budget()
{
var anchor = new TimeAnchor(DateTimeOffset.UtcNow.AddMinutes(-3), "roughtime", "roughtime", "fp", "digest");
var budget = new StalenessBudget(60, 600);
await _store.SetAsync(new AirGapState
{
TenantId = "tenant-staleness",
Sealed = true,
PolicyHash = "hash-s",
TimeAnchor = anchor,
StalenessBudget = budget,
LastTransitionAt = DateTimeOffset.UtcNow
});
var stored = await _store.GetAsync("tenant-staleness");
Assert.Equal(anchor.TokenDigest, stored.TimeAnchor.TokenDigest);
Assert.Equal(budget.WarningSeconds, stored.StalenessBudget.WarningSeconds);
Assert.Equal(budget.BreachSeconds, stored.StalenessBudget.BreachSeconds);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Multi_tenant_states_preserve_transition_times()
{
var tenants = new[] { "a", "b", "c" };
var now = DateTimeOffset.UtcNow;
foreach (var t in tenants)
{
await _store.SetAsync(new AirGapState
{
TenantId = t,
Sealed = true,
PolicyHash = $"ph-{t}",
LastTransitionAt = now
});
}
foreach (var t in tenants)
{
var state = await _store.GetAsync(t);
Assert.Equal(now, state.LastTransitionAt);
Assert.Equal($"ph-{t}", state.PolicyHash);
}
}
}

View File

@@ -0,0 +1,98 @@
using StellaOps.AirGap.Controller.Endpoints.Contracts;
using StellaOps.AirGap.Controller.Services;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Controller.Tests;
public class ReplayVerificationServiceTests
{
private readonly ReplayVerificationService _service;
private readonly AirGapStateService _stateService;
private readonly StalenessCalculator _staleness = new();
private readonly InMemoryAirGapStateStore _store = new();
public ReplayVerificationServiceTests()
{
_stateService = new AirGapStateService(_store, _staleness);
_service = new ReplayVerificationService(_stateService, new ReplayVerifier());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Passes_full_recompute_when_hashes_match()
{
var now = DateTimeOffset.Parse("2025-12-02T01:00:00Z");
await _stateService.SealAsync("tenant-a", "policy-x", TimeAnchor.Unknown, StalenessBudget.Default, now);
var request = new VerifyRequest
{
Depth = ReplayDepth.FullRecompute,
ManifestSha256 = new string('a', 64),
BundleSha256 = new string('b', 64),
ComputedManifestSha256 = new string('a', 64),
ComputedBundleSha256 = new string('b', 64),
ManifestCreatedAt = now.AddHours(-2),
StalenessWindowHours = 24,
BundlePolicyHash = "policy-x"
};
var result = await _service.VerifyAsync("tenant-a", request, now);
Assert.True(result.IsValid);
Assert.Equal("full-recompute-passed", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Detects_stale_manifest()
{
var now = DateTimeOffset.UtcNow;
var request = new VerifyRequest
{
Depth = ReplayDepth.HashOnly,
ManifestSha256 = new string('a', 64),
BundleSha256 = new string('b', 64),
ComputedManifestSha256 = new string('a', 64),
ComputedBundleSha256 = new string('b', 64),
ManifestCreatedAt = now.AddHours(-30),
StalenessWindowHours = 12
};
var result = await _service.VerifyAsync("default", request, now);
Assert.False(result.IsValid);
Assert.Equal("manifest-stale", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Policy_freeze_requires_matching_policy()
{
var now = DateTimeOffset.UtcNow;
await _stateService.SealAsync("tenant-b", "sealed-policy", TimeAnchor.Unknown, StalenessBudget.Default, now);
var request = new VerifyRequest
{
Depth = ReplayDepth.PolicyFreeze,
ManifestSha256 = new string('a', 64),
BundleSha256 = new string('b', 64),
ComputedManifestSha256 = new string('a', 64),
ComputedBundleSha256 = new string('b', 64),
ManifestCreatedAt = now,
StalenessWindowHours = 48,
BundlePolicyHash = "bundle-policy"
};
var result = await _service.VerifyAsync("tenant-b", request, now);
Assert.False(result.IsValid);
Assert.Equal("policy-hash-drift", result.Reason);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
<Compile Include="../../shared/*.cs" Link="Shared/%(Filename)%(Extension)" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,4 @@
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// AirGapControllerContractTests.cs
// Sprint: SPRINT_5100_0010_0004_airgap_tests
// Tasks: AIRGAP-5100-010, AIRGAP-5100-011, AIRGAP-5100-012
@@ -364,7 +364,6 @@ public sealed class AirGapControllerContractTests
{
// Arrange - Create a trace context
using var activity = new Activity("test-airgap-operation");
using StellaOps.TestKit;
activity.Start();
// Act

View File

@@ -0,0 +1,44 @@
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Planning;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Importer.Tests;
public class BundleImportPlannerTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ReturnsFailureWhenBundlePathMissing()
{
var planner = new BundleImportPlanner();
var result = planner.CreatePlan(string.Empty, TrustRootConfig.Empty("/tmp"));
Assert.False(result.InitialState.IsValid);
Assert.Equal("bundle-path-required", result.InitialState.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ReturnsFailureWhenTrustRootsMissing()
{
var planner = new BundleImportPlanner();
var result = planner.CreatePlan("bundle.tar", TrustRootConfig.Empty("/tmp"));
Assert.False(result.InitialState.IsValid);
Assert.Equal("trust-roots-required", result.InitialState.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ReturnsDefaultPlanWhenInputsProvided()
{
var planner = new BundleImportPlanner();
var trust = new TrustRootConfig("/tmp/trust.json", new[] { "abc" }, new[] { "ed25519" }, null, null, new Dictionary<string, byte[]>());
var result = planner.CreatePlan("bundle.tar", trust);
Assert.True(result.InitialState.IsValid);
Assert.Contains("verify-dsse-signature", result.Steps);
Assert.Equal("bundle.tar", result.Inputs["bundlePath"]);
}
}

View File

@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Importer.Tests;
public class DsseVerifierTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FailsWhenUntrustedKey()
{
var verifier = new DsseVerifier();
var envelope = new DsseEnvelope("text/plain", Convert.ToBase64String("hi"u8), new[] { new DsseSignature("k1", "sig") });
var trust = TrustRootConfig.Empty("/tmp");
var result = verifier.Verify(envelope, trust);
Assert.False(result.IsValid);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void VerifiesRsaPssSignature()
{
using var rsa = RSA.Create(2048);
var pub = rsa.ExportSubjectPublicKeyInfo();
var payload = "hello-world";
var payloadType = "application/vnd.stella.bundle";
var pae = BuildPae(payloadType, payload);
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
{
new DsseSignature("k1", Convert.ToBase64String(sig))
});
var trust = new TrustRootConfig(
"/tmp/root.json",
new[] { Fingerprint(pub) },
new[] { "rsassa-pss-sha256" },
null,
null,
new Dictionary<string, byte[]> { ["k1"] = pub });
var result = new DsseVerifier().Verify(envelope, trust);
Assert.True(result.IsValid);
Assert.Equal("dsse-signature-verified", result.Reason);
}
private static byte[] BuildPae(string payloadType, string payload)
{
var parts = new[] { "DSSEv1", payloadType, payload };
var paeBuilder = new System.Text.StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return System.Text.Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private static string Fingerprint(byte[] pub)
{
return Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,242 @@
using System.Security.Cryptography;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Quarantine;
using StellaOps.AirGap.Importer.Validation;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Importer.Tests;
public sealed class ImportValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_WhenTufInvalid_ShouldFailAndQuarantine()
{
var quarantine = new CapturingQuarantineService();
var monotonicity = new CapturingMonotonicityChecker();
var validator = new ImportValidator(
new DsseVerifier(),
new TufMetadataValidator(),
new MerkleRootCalculator(),
new RootRotationPolicy(),
monotonicity,
quarantine,
NullLogger<ImportValidator>.Instance);
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
try
{
var request = BuildRequest(bundlePath, rootJson: "{}", snapshotJson: "{}", timestampJson: "{}");
var result = await validator.ValidateAsync(request);
result.IsValid.Should().BeFalse();
result.Reason.Should().StartWith("tuf:");
quarantine.Requests.Should().HaveCount(1);
quarantine.Requests[0].TenantId.Should().Be("tenant-a");
}
finally
{
try
{
Directory.Delete(tempRoot, recursive: true);
}
catch
{
// best-effort cleanup
}
}
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateAsync_WhenAllChecksPass_ShouldSucceedAndRecordActivation()
{
var root = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\"}";
var snapshot = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"meta\":{\"snapshot\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
var timestamp = "{\"version\":1,\"expiresUtc\":\"2030-01-01T00:00:00Z\",\"snapshot\":{\"meta\":{\"hashes\":{\"sha256\":\"abc\"}}}}";
using var rsa = RSA.Create(2048);
var pub = rsa.ExportSubjectPublicKeyInfo();
var payload = "bundle-body";
var payloadType = "application/vnd.stella.bundle";
var pae = BuildPae(payloadType, payload);
var sig = rsa.SignData(pae, HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
var envelope = new DsseEnvelope(payloadType, Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(payload)), new[]
{
new DsseSignature("k1", Convert.ToBase64String(sig))
});
var trustStore = new TrustStore();
trustStore.LoadActive(new Dictionary<string, byte[]> { ["k1"] = pub });
trustStore.StagePending(new Dictionary<string, byte[]> { ["k2"] = pub });
var quarantine = new CapturingQuarantineService();
var monotonicity = new CapturingMonotonicityChecker();
var validator = new ImportValidator(
new DsseVerifier(),
new TufMetadataValidator(),
new MerkleRootCalculator(),
new RootRotationPolicy(),
monotonicity,
quarantine,
NullLogger<ImportValidator>.Instance);
var tempRoot = Path.Combine(Path.GetTempPath(), "stellaops-airgap-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempRoot);
var bundlePath = Path.Combine(tempRoot, "bundle.tar.zst");
await File.WriteAllTextAsync(bundlePath, "bundle-bytes");
try
{
var request = new ImportValidationRequest(
TenantId: "tenant-a",
BundleType: "offline-kit",
BundleDigest: "sha256:bundle",
BundlePath: bundlePath,
ManifestJson: "{\"version\":\"1.0.0\"}",
ManifestVersion: "1.0.0",
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
ForceActivate: false,
ForceActivateReason: null,
Envelope: envelope,
TrustRoots: new TrustRootConfig("/tmp/root.json", new[] { Fingerprint(pub) }, new[] { "rsassa-pss-sha256" }, null, null, new Dictionary<string, byte[]> { ["k1"] = pub }),
RootJson: root,
SnapshotJson: snapshot,
TimestampJson: timestamp,
PayloadEntries: new List<NamedStream> { new("a.txt", new MemoryStream("data"u8.ToArray())) },
TrustStore: trustStore,
ApproverIds: new[] { "approver-1", "approver-2" });
var result = await validator.ValidateAsync(request);
result.IsValid.Should().BeTrue();
result.Reason.Should().Be("import-validated");
monotonicity.RecordedActivations.Should().HaveCount(1);
monotonicity.RecordedActivations[0].BundleDigest.Should().Be("sha256:bundle");
monotonicity.RecordedActivations[0].Version.SemVer.Should().Be("1.0.0");
quarantine.Requests.Should().BeEmpty();
}
finally
{
try
{
Directory.Delete(tempRoot, recursive: true);
}
catch
{
// best-effort cleanup
}
}
}
private static byte[] BuildPae(string payloadType, string payload)
{
var parts = new[] { "DSSEv1", payloadType, payload };
var paeBuilder = new System.Text.StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return System.Text.Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private static string Fingerprint(byte[] pub) => Convert.ToHexString(SHA256.HashData(pub)).ToLowerInvariant();
private static ImportValidationRequest BuildRequest(string bundlePath, string rootJson, string snapshotJson, string timestampJson)
{
var envelope = new DsseEnvelope("text/plain", Convert.ToBase64String("hi"u8), Array.Empty<DsseSignature>());
var trustRoot = TrustRootConfig.Empty("/tmp");
var trustStore = new TrustStore();
return new ImportValidationRequest(
TenantId: "tenant-a",
BundleType: "offline-kit",
BundleDigest: "sha256:bundle",
BundlePath: bundlePath,
ManifestJson: null,
ManifestVersion: "1.0.0",
ManifestCreatedAt: DateTimeOffset.Parse("2025-12-15T00:00:00Z"),
ForceActivate: false,
ForceActivateReason: null,
Envelope: envelope,
TrustRoots: trustRoot,
RootJson: rootJson,
SnapshotJson: snapshotJson,
TimestampJson: timestampJson,
PayloadEntries: Array.Empty<NamedStream>(),
TrustStore: trustStore,
ApproverIds: Array.Empty<string>());
}
private sealed class CapturingMonotonicityChecker : IVersionMonotonicityChecker
{
public List<(BundleVersion Version, string BundleDigest)> RecordedActivations { get; } = new();
public Task<MonotonicityCheckResult> CheckAsync(string tenantId, string bundleType, BundleVersion incomingVersion, CancellationToken cancellationToken = default)
{
return Task.FromResult(new MonotonicityCheckResult(
IsMonotonic: true,
CurrentVersion: null,
CurrentBundleDigest: null,
CurrentActivatedAt: null,
ReasonCode: "FIRST_ACTIVATION"));
}
public Task RecordActivationAsync(
string tenantId,
string bundleType,
BundleVersion version,
string bundleDigest,
bool wasForceActivated = false,
string? forceActivateReason = null,
CancellationToken cancellationToken = default)
{
RecordedActivations.Add((version, bundleDigest));
return Task.CompletedTask;
}
}
private sealed class CapturingQuarantineService : IQuarantineService
{
public List<QuarantineRequest> Requests { get; } = new();
public Task<QuarantineResult> QuarantineAsync(QuarantineRequest request, CancellationToken cancellationToken = default)
{
Requests.Add(request);
return Task.FromResult(new QuarantineResult(
Success: true,
QuarantineId: "test",
QuarantinePath: "(memory)",
QuarantinedAt: DateTimeOffset.UnixEpoch));
}
public Task<IReadOnlyList<QuarantineEntry>> ListAsync(string tenantId, QuarantineListOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<QuarantineEntry>>(Array.Empty<QuarantineEntry>());
public Task<bool> RemoveAsync(string tenantId, string quarantineId, string removalReason, CancellationToken cancellationToken = default) =>
Task.FromResult(false);
public Task<int> CleanupExpiredAsync(TimeSpan retentionPeriod, CancellationToken cancellationToken = default) =>
Task.FromResult(0);
}
}

View File

@@ -0,0 +1,68 @@
using StellaOps.AirGap.Importer.Models;
using StellaOps.AirGap.Importer.Repositories;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Importer.Tests;
public class InMemoryBundleRepositoriesTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CatalogUpsertOverwritesPerTenant()
{
var repo = new InMemoryBundleCatalogRepository();
var entry1 = new BundleCatalogEntry("t1", "b1", "d1", DateTimeOffset.UnixEpoch, new[] { "a" });
var entry2 = new BundleCatalogEntry("t1", "b1", "d2", DateTimeOffset.UnixEpoch.AddMinutes(1), new[] { "b" });
await repo.UpsertAsync(entry1, default);
await repo.UpsertAsync(entry2, default);
var list = await repo.ListAsync("t1", default);
Assert.Single(list);
Assert.Equal("d2", list[0].Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CatalogIsTenantIsolated()
{
var repo = new InMemoryBundleCatalogRepository();
await repo.UpsertAsync(new BundleCatalogEntry("t1", "b1", "d1", DateTimeOffset.UnixEpoch, Array.Empty<string>()), default);
await repo.UpsertAsync(new BundleCatalogEntry("t2", "b1", "d2", DateTimeOffset.UnixEpoch, Array.Empty<string>()), default);
var t1 = await repo.ListAsync("t1", default);
Assert.Single(t1);
Assert.Equal("d1", t1[0].Digest);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ItemsOrderedByPath()
{
var repo = new InMemoryBundleItemRepository();
await repo.UpsertManyAsync(new[]
{
new BundleItem("t1", "b1", "b.txt", "d2", 10),
new BundleItem("t1", "b1", "a.txt", "d1", 5)
}, default);
var list = await repo.ListByBundleAsync("t1", "b1", default);
Assert.Equal(new[] { "a.txt", "b.txt" }, list.Select(i => i.Path).ToArray());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ItemsTenantIsolated()
{
var repo = new InMemoryBundleItemRepository();
await repo.UpsertManyAsync(new[]
{
new BundleItem("t1", "b1", "a.txt", "d1", 1),
new BundleItem("t2", "b1", "a.txt", "d2", 1)
}, default);
var list = await repo.ListByBundleAsync("t1", "b1", default);
Assert.Single(list);
Assert.Equal("d1", list[0].Digest);
}
}

View File

@@ -0,0 +1,31 @@
using StellaOps.AirGap.Importer.Validation;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Importer.Tests;
public class MerkleRootCalculatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EmptySetProducesEmptyRoot()
{
var calc = new MerkleRootCalculator();
var root = calc.ComputeRoot(Array.Empty<NamedStream>());
Assert.Equal(string.Empty, root);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void DeterministicAcrossOrder()
{
var calc = new MerkleRootCalculator();
var a = new NamedStream("b.txt", new MemoryStream("two"u8.ToArray()));
var b = new NamedStream("a.txt", new MemoryStream("one"u8.ToArray()));
var root1 = calc.ComputeRoot(new[] { a, b });
var root2 = calc.ComputeRoot(new[] { b, a });
Assert.Equal(root1, root2);
Assert.NotEqual(string.Empty, root1);
}
}

View File

@@ -0,0 +1,120 @@
using System.Diagnostics.Metrics;
using StellaOps.AirGap.Importer.Telemetry;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Importer.Tests;
public sealed class OfflineKitMetricsTests : IDisposable
{
private readonly MeterListener _listener;
private readonly List<RecordedMeasurement> _measurements = [];
public OfflineKitMetricsTests()
{
_listener = new MeterListener();
_listener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == OfflineKitMetrics.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
};
_listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
{
_measurements.Add(new RecordedMeasurement(instrument.Name, measurement, tags.ToArray()));
});
_listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
{
_measurements.Add(new RecordedMeasurement(instrument.Name, measurement, tags.ToArray()));
});
_listener.Start();
}
public void Dispose() => _listener.Dispose();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RecordImport_EmitsCounterWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordImport(status: "success", tenantId: "tenant-a");
Assert.Contains(_measurements, m =>
m.Name == "offlinekit_import_total" &&
m.Value is long v &&
v == 1 &&
m.HasTag(OfflineKitMetrics.TagNames.Status, "success") &&
m.HasTag(OfflineKitMetrics.TagNames.TenantId, "tenant-a"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RecordAttestationVerifyLatency_EmitsHistogramWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordAttestationVerifyLatency(attestationType: "dsse", seconds: 1.234, success: true);
Assert.Contains(_measurements, m =>
m.Name == "offlinekit_attestation_verify_latency_seconds" &&
m.Value is double v &&
Math.Abs(v - 1.234) < 0.000_001 &&
m.HasTag(OfflineKitMetrics.TagNames.AttestationType, "dsse") &&
m.HasTag(OfflineKitMetrics.TagNames.Success, "true"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RecordRekorSuccess_EmitsCounterWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordRekorSuccess(mode: "offline");
Assert.Contains(_measurements, m =>
m.Name == "attestor_rekor_success_total" &&
m.Value is long v &&
v == 1 &&
m.HasTag(OfflineKitMetrics.TagNames.Mode, "offline"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RecordRekorRetry_EmitsCounterWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordRekorRetry(reason: "stale_snapshot");
Assert.Contains(_measurements, m =>
m.Name == "attestor_rekor_retry_total" &&
m.Value is long v &&
v == 1 &&
m.HasTag(OfflineKitMetrics.TagNames.Reason, "stale_snapshot"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RecordRekorInclusionLatency_EmitsHistogramWithLabels()
{
using var metrics = new OfflineKitMetrics();
metrics.RecordRekorInclusionLatency(seconds: 0.5, success: false);
Assert.Contains(_measurements, m =>
m.Name == "rekor_inclusion_latency" &&
m.Value is double v &&
Math.Abs(v - 0.5) < 0.000_001 &&
m.HasTag(OfflineKitMetrics.TagNames.Success, "false"));
}
private sealed record RecordedMeasurement(string Name, object Value, IReadOnlyList<KeyValuePair<string, object?>> Tags)
{
public bool HasTag(string key, string expectedValue) =>
Tags.Any(t => t.Key == key && string.Equals(t.Value?.ToString(), expectedValue, StringComparison.Ordinal));
}
}

View File

@@ -8,23 +8,21 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1" />
</ItemGroup>
<ItemGroup>
<Using 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>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\..\\StellaOps.AirGap.Importer\\StellaOps.AirGap.Importer.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,127 @@
using System.Reflection;
using Npgsql;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.Infrastructure.Postgres.Testing;
using Xunit;
namespace StellaOps.AirGap.Persistence.Tests;
/// <summary>
/// PostgreSQL integration test fixture for the AirGap module.
/// Runs migrations from embedded resources and provides test isolation.
/// </summary>
public sealed class AirGapPostgresFixture : PostgresIntegrationFixture, ICollectionFixture<AirGapPostgresFixture>
{
protected override Assembly? GetMigrationAssembly()
=> typeof(AirGapDataSource).Assembly;
protected override string GetModuleName() => "AirGap";
protected override string? GetResourcePrefix() => "Migrations";
/// <summary>
/// Gets all table names in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetTableNamesAsync(CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = @schema AND table_type = 'BASE TABLE';
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
tables.Add(reader.GetString(0));
}
return tables;
}
/// <summary>
/// Gets all column names for a specific table in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetColumnNamesAsync(string tableName, CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = @schema AND table_name = @table;
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
cmd.Parameters.AddWithValue("table", tableName);
var columns = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
columns.Add(reader.GetString(0));
}
return columns;
}
/// <summary>
/// Gets all index names for a specific table in the test schema.
/// </summary>
public async Task<IReadOnlyList<string>> GetIndexNamesAsync(string tableName, CancellationToken cancellationToken = default)
{
await using var connection = new NpgsqlConnection(ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(
"""
SELECT indexname FROM pg_indexes
WHERE schemaname = @schema AND tablename = @table;
""",
connection);
cmd.Parameters.AddWithValue("schema", SchemaName);
cmd.Parameters.AddWithValue("table", tableName);
var indexes = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
indexes.Add(reader.GetString(0));
}
return indexes;
}
/// <summary>
/// Ensures migrations have been run. This is idempotent and safe to call multiple times.
/// </summary>
public async Task EnsureMigrationsRunAsync(CancellationToken cancellationToken = default)
{
var migrationAssembly = GetMigrationAssembly();
if (migrationAssembly != null)
{
await Fixture.RunMigrationsFromAssemblyAsync(
migrationAssembly,
GetModuleName(),
GetResourcePrefix(),
cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// Collection definition for AirGap PostgreSQL integration tests.
/// Tests in this collection share a single PostgreSQL container instance.
/// </summary>
[CollectionDefinition(Name)]
public sealed class AirGapPostgresCollection : ICollectionFixture<AirGapPostgresFixture>
{
public const string Name = "AirGapPostgres";
}

View File

@@ -9,13 +9,14 @@ using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Storage.Postgres.Repositories;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.AirGap.Time.Models;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.TestKit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Storage.Postgres.Tests;
namespace StellaOps.AirGap.Persistence.Tests;
/// <summary>
/// S1 Storage Layer Tests for AirGap
@@ -237,12 +238,14 @@ public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
{
// Arrange
var tenantId = $"tenant-budgets-{Guid.NewGuid():N}";
var state = CreateTestState(tenantId);
state.ContentBudgets = new Dictionary<string, StalenessBudget>
var state = CreateTestState(tenantId) with
{
["zebra"] = new StalenessBudget(100, 200),
["alpha"] = new StalenessBudget(300, 400),
["middle"] = new StalenessBudget(500, 600)
ContentBudgets = new Dictionary<string, StalenessBudget>
{
["zebra"] = new StalenessBudget(100, 200),
["alpha"] = new StalenessBudget(300, 400),
["middle"] = new StalenessBudget(500, 600)
}
};
await _store.SetAsync(state);
@@ -269,14 +272,16 @@ public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
{
// Arrange
var tenantId = $"tenant-anchor-{Guid.NewGuid():N}";
var timestamp = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
var state = CreateTestState(tenantId);
state.TimeAnchor = new TimeAnchor(
timestamp,
"tsa.example.com",
"RFC3161",
"sha256:fingerprint",
"sha256:tokendigest");
var anchorTime = DateTimeOffset.Parse("2025-06-15T12:00:00Z");
var state = CreateTestState(tenantId) with
{
TimeAnchor = new TimeAnchor(
anchorTime,
"tsa.example.com",
"RFC3161",
"sha256:fingerprint",
"sha256:tokendigest")
};
await _store.SetAsync(state);
// Act
@@ -285,7 +290,7 @@ public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
// Assert
fetched1.TimeAnchor.Should().BeEquivalentTo(fetched2.TimeAnchor);
fetched1.TimeAnchor.Timestamp.Should().Be(timestamp);
fetched1.TimeAnchor.AnchorTime.Should().Be(anchorTime);
fetched1.TimeAnchor.Source.Should().Be("tsa.example.com");
}
@@ -323,7 +328,6 @@ public sealed class AirGapStorageIntegrationTests : IAsyncLifetime
TenantId = tenantId,
Sealed = sealed_,
PolicyHash = policyHash,
TimeAnchor = null,
LastTransitionAt = DateTimeOffset.UtcNow,
StalenessBudget = new StalenessBudget(1800, 3600),
DriftBaselineSeconds = 5,

View File

@@ -2,14 +2,14 @@ using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Controller.Domain;
using StellaOps.AirGap.Storage.Postgres;
using StellaOps.AirGap.Storage.Postgres.Repositories;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.AirGap.Time.Models;
using StellaOps.Infrastructure.Postgres.Options;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Storage.Postgres.Tests;
namespace StellaOps.AirGap.Persistence.Tests;
[Collection(AirGapPostgresCollection.Name)]
public sealed class PostgresAirGapStateStoreTests : IAsyncLifetime

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" ?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>StellaOps.AirGap.Persistence.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.AirGap.Persistence\StellaOps.AirGap.Persistence.csproj" />
<ProjectReference Include="..\..\StellaOps.AirGap.Controller\StellaOps.AirGap.Controller.csproj" />
<ProjectReference Include="..\..\..\__Tests\__Libraries\StellaOps.Infrastructure.Postgres.Testing\StellaOps.Infrastructure.Postgres.Testing.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,39 @@
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Config;
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class AirGapOptionsValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FailsWhenTenantMissing()
{
var opts = new AirGapOptions { TenantId = "" };
var validator = new AirGapOptionsValidator();
var result = validator.Validate(null, opts);
Assert.True(result.Failed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FailsWhenWarningExceedsBreach()
{
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 20, BreachSeconds = 10 } };
var validator = new AirGapOptionsValidator();
var result = validator.Validate(null, opts);
Assert.True(result.Failed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SucceedsForValidOptions()
{
var opts = new AirGapOptions { TenantId = "t", Staleness = new StalenessOptions { WarningSeconds = 10, BreachSeconds = 20 } };
var validator = new AirGapOptionsValidator();
var result = validator.Validate(null, opts);
Assert.True(result.Succeeded);
}
}

View File

@@ -0,0 +1 @@
global using Xunit;

View File

@@ -0,0 +1,100 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for Rfc3161Verifier with real SignedCms verification.
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
/// </summary>
public class Rfc3161VerifierTests
{
private readonly Rfc3161Verifier _verifier = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("rfc3161-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenInvalidAsn1Structure()
{
var token = new byte[] { 0x01, 0x02, 0x03 }; // Invalid ASN.1
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0x30, 0x00 }; // Empty SEQUENCE (minimal valid ASN.1)
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
// Should fail on CMS decode but attempt was made
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_HandlesExceptionsGracefully()
{
// Create bytes that might cause internal exceptions
var token = new byte[256];
new Random(42).NextBytes(token);
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out var anchor);
// Should not throw, should return failure result
Assert.False(result.IsValid);
Assert.Contains("rfc3161-", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReportsDecodeErrorForMalformedCms()
{
// Create something that looks like CMS but isn't valid
var token = new byte[] { 0x30, 0x82, 0x00, 0x10, 0x06, 0x09 };
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa") };
var result = _verifier.Verify(token, trust, out _);
Assert.False(result.IsValid);
// Should report either decode or error
Assert.True(result.Reason?.Contains("rfc3161-") ?? false);
}
}

View File

@@ -0,0 +1,158 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for RoughtimeVerifier with real Ed25519 signature verification.
/// Per AIRGAP-TIME-57-001: Trusted time-anchor service.
/// </summary>
public class RoughtimeVerifierTests
{
private readonly RoughtimeVerifier _verifier = new();
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTrustRootsEmpty()
{
var token = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result = _verifier.Verify(token, Array.Empty<TimeTrustRoot>(), out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-trust-roots-required", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenEmpty()
{
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(ReadOnlySpan<byte>.Empty, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenTokenTooShort()
{
var token = new byte[] { 0x01, 0x02, 0x03 };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-message-too-short", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenInvalidTagCount()
{
// Create a minimal wire format with invalid tag count
var token = new byte[8];
// Set num_tags to 0 (invalid)
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)0);
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("roughtime-invalid-tag-count", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenNonEd25519Algorithm()
{
// Create a minimal valid-looking wire format
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "rsa") }; // Wrong algorithm
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
// Should fail either on parsing or signature verification
Assert.Contains("roughtime-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ReturnsFailure_WhenKeyLengthWrong()
{
var token = CreateMinimalRoughtimeToken();
var trust = new[] { new TimeTrustRoot("root1", new byte[16], "ed25519") }; // Wrong key length
var result = _verifier.Verify(token, trust, out var anchor);
Assert.False(result.IsValid);
Assert.Contains("roughtime-", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Verify_ProducesTokenDigest()
{
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
var trust = new[] { new TimeTrustRoot("root1", new byte[32], "ed25519") };
var result = _verifier.Verify(token, trust, out _);
// Even on failure, we should get a deterministic result
Assert.False(result.IsValid);
}
/// <summary>
/// Creates a minimal Roughtime wire format token for testing parsing paths.
/// Note: This will fail signature verification but tests the parsing logic.
/// </summary>
private static byte[] CreateMinimalRoughtimeToken()
{
// Roughtime wire format:
// [num_tags:u32] [offsets:u32[n-1]] [tags:u32[n]] [values...]
// We'll create 2 tags: SIG and SREP
const uint TagSig = 0x00474953; // "SIG\0"
const uint TagSrep = 0x50455253; // "SREP"
var sigValue = new byte[64]; // Ed25519 signature
var srepValue = CreateMinimalSrep();
// Header: num_tags=2, offset[0]=64 (sig length), tags=[SIG, SREP]
var headerSize = 4 + 4 + 8; // num_tags + 1 offset + 2 tags = 16 bytes
var token = new byte[headerSize + sigValue.Length + srepValue.Length];
BitConverter.TryWriteBytes(token.AsSpan(0, 4), (uint)2); // num_tags = 2
BitConverter.TryWriteBytes(token.AsSpan(4, 4), (uint)64); // offset[0] = 64 (sig length)
BitConverter.TryWriteBytes(token.AsSpan(8, 4), TagSig);
BitConverter.TryWriteBytes(token.AsSpan(12, 4), TagSrep);
sigValue.CopyTo(token.AsSpan(16));
srepValue.CopyTo(token.AsSpan(16 + 64));
return token;
}
private static byte[] CreateMinimalSrep()
{
// SREP with MIDP tag containing 8-byte timestamp
const uint TagMidp = 0x5044494D; // "MIDP"
// Header: num_tags=1, tags=[MIDP]
var headerSize = 4 + 4; // num_tags + 1 tag = 8 bytes
var srepValue = new byte[headerSize + 8]; // + 8 bytes for MIDP value
BitConverter.TryWriteBytes(srepValue.AsSpan(0, 4), (uint)1); // num_tags = 1
BitConverter.TryWriteBytes(srepValue.AsSpan(4, 4), TagMidp);
// MIDP value: microseconds since Unix epoch (example: 2025-01-01 00:00:00 UTC)
BitConverter.TryWriteBytes(srepValue.AsSpan(8, 8), 1735689600000000L);
return srepValue;
}
}

View File

@@ -0,0 +1,68 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class SealedStartupValidatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FailsWhenAnchorMissing()
{
var validator = Build(out var statusService);
var result = await validator.ValidateAsync("t1", StalenessBudget.Default, default);
Assert.False(result.IsValid);
Assert.Equal("time-anchor-missing", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FailsWhenBreach()
{
var validator = Build(out var statusService);
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
var status = await statusService.GetStatusAsync("t1", now);
var result = status.Staleness.IsBreach;
Assert.True(result);
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
Assert.False(validation.IsValid);
Assert.Equal("time-anchor-stale", validation.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task SucceedsWhenFresh()
{
var validator = Build(out var statusService);
var now = DateTimeOffset.UtcNow;
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
Assert.True(validation.IsValid);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FailsOnBudgetMismatch()
{
var validator = Build(out var statusService);
var anchor = new TimeAnchor(DateTimeOffset.UtcNow, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var validation = await validator.ValidateAsync("t1", new StalenessBudget(5, 15), default);
Assert.False(validation.IsValid);
Assert.Equal("time-anchor-budget-mismatch", validation.Reason);
}
private static SealedStartupValidator Build(out TimeStatusService statusService)
{
var store = new InMemoryTimeAnchorStore();
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), Microsoft.Extensions.Options.Options.Create(new AirGapOptions()));
return new SealedStartupValidator(statusService);
}
}

View File

@@ -0,0 +1,47 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class StalenessCalculatorTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void UnknownWhenNoAnchor()
{
var calc = new StalenessCalculator();
var result = calc.Evaluate(TimeAnchor.Unknown, StalenessBudget.Default, DateTimeOffset.UnixEpoch);
Assert.False(result.IsWarning);
Assert.False(result.IsBreach);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void BreachWhenBeyondBudget()
{
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
var budget = new StalenessBudget(10, 20);
var calc = new StalenessCalculator();
var result = calc.Evaluate(anchor, budget, DateTimeOffset.UnixEpoch.AddSeconds(25));
Assert.True(result.IsBreach);
Assert.True(result.IsWarning);
Assert.Equal(25, result.AgeSeconds);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void WarningWhenBetweenWarningAndBreach()
{
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
var budget = new StalenessBudget(10, 20);
var calc = new StalenessCalculator();
var result = calc.Evaluate(anchor, budget, DateTimeOffset.UnixEpoch.AddSeconds(15));
Assert.True(result.IsWarning);
Assert.False(result.IsBreach);
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.AirGap.Time/StellaOps.AirGap.Time.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,66 @@
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class TimeAnchorLoaderTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RejectsInvalidHex()
{
var loader = Build();
var trust = new[] { new TimeTrustRoot("k1", new byte[32], "ed25519") };
var result = loader.TryLoadHex("not-hex", TimeTokenFormat.Roughtime, trust, out _);
Assert.False(result.IsValid);
Assert.Equal("token-hex-invalid", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void LoadsHexToken()
{
var loader = Build();
var hex = "01020304";
var trust = new[] { new TimeTrustRoot("k1", new byte[32], "ed25519") };
var result = loader.TryLoadHex(hex, TimeTokenFormat.Roughtime, trust, out var anchor);
Assert.True(result.IsValid);
Assert.Equal("Roughtime", anchor.Format);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RejectsIncompatibleTrustRoots()
{
var loader = Build();
var hex = "010203";
var rsaKey = new byte[128];
var trust = new[] { new TimeTrustRoot("k1", rsaKey, "rsa") };
var result = loader.TryLoadHex(hex, TimeTokenFormat.Roughtime, trust, out _);
Assert.False(result.IsValid);
Assert.Equal("trust-roots-incompatible-format", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RejectsWhenTrustRootsMissing()
{
var loader = Build();
var result = loader.TryLoadHex("010203", TimeTokenFormat.Roughtime, Array.Empty<TimeTrustRoot>(), out _);
Assert.False(result.IsValid);
Assert.Equal("trust-roots-required", result.Reason);
}
private static TimeAnchorLoader Build()
{
var options = Options.Create(new AirGapOptions { AllowUntrustedAnchors = false });
return new TimeAnchorLoader(new TimeVerificationService(), new TimeTokenParser(), options);
}
}

View File

@@ -0,0 +1,273 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
/// <summary>
/// Tests for TimeAnchorPolicyService.
/// Per AIRGAP-TIME-57-001: Time-anchor policy enforcement.
/// </summary>
public class TimeAnchorPolicyServiceTests
{
private readonly TimeProvider _fixedTimeProvider;
private readonly InMemoryTimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
private readonly TimeTelemetry _telemetry;
private readonly TimeStatusService _statusService;
private readonly AirGapOptions _airGapOptions;
public TimeAnchorPolicyServiceTests()
{
_fixedTimeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 1, 15, 12, 0, 0, TimeSpan.Zero));
_store = new InMemoryTimeAnchorStore();
_calculator = new StalenessCalculator();
_telemetry = new TimeTelemetry();
_airGapOptions = new AirGapOptions
{
Staleness = new StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 },
ContentBudgets = new Dictionary<string, StalenessOptions>()
};
_statusService = new TimeStatusService(_store, _calculator, _telemetry, Options.Create(_airGapOptions));
}
private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null)
{
return new TimeAnchorPolicyService(
_statusService,
Options.Create(options ?? new TimeAnchorPolicyOptions()),
NullLogger<TimeAnchorPolicyService>.Instance,
_fixedTimeProvider);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenNoAnchor()
{
var service = CreateService();
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
Assert.NotNull(result.Remediation);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsSuccess_WhenAnchorValid()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed);
Assert.Null(result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.False(result.Staleness.IsBreach);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsWarning_WhenAnchorStale()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-5000), // Past warning threshold
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.True(result.Allowed); // Allowed but with warning
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsWarning);
Assert.Contains("warning", result.Reason, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ValidateTimeAnchorAsync_ReturnsFailure_WhenAnchorBreached()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddSeconds(-8000), // Past breach threshold
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.ValidateTimeAnchorAsync("tenant-1");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorBreached, result.ErrorCode);
Assert.NotNull(result.Staleness);
Assert.True(result.Staleness.IsBreach);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceBundleImportPolicyAsync_AllowsImport_WhenAnchorValid()
{
var service = CreateService();
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
_fixedTimeProvider.GetUtcNow().AddMinutes(-15));
Assert.True(result.Allowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceBundleImportPolicyAsync_BlocksImport_WhenDriftExceeded()
{
var options = new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 }; // 1 hour max
var service = CreateService(options);
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow().AddMinutes(-30),
"test-source",
"Roughtime",
"fingerprint",
"digest123");
var budget = new StalenessBudget(86400, 172800); // Large budget
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var bundleTimestamp = _fixedTimeProvider.GetUtcNow().AddDays(-2); // 2 days ago
var result = await service.EnforceBundleImportPolicyAsync(
"tenant-1",
"bundle-123",
bundleTimestamp);
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.DriftExceeded, result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceOperationPolicyAsync_BlocksStrictOperations_WhenNoAnchor()
{
var options = new TimeAnchorPolicyOptions
{
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "attestation.sign");
Assert.False(result.Allowed);
Assert.Equal(TimeAnchorPolicyErrorCodes.AnchorMissing, result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnforceOperationPolicyAsync_AllowsNonStrictOperations_InNonStrictMode()
{
var options = new TimeAnchorPolicyOptions
{
StrictEnforcement = false,
StrictOperations = new[] { "attestation.sign" }
};
var service = CreateService(options);
var result = await service.EnforceOperationPolicyAsync("tenant-1", "some.other.operation");
Assert.True(result.Allowed);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_ReturnsNoDrift_WhenNoAnchor()
{
var service = CreateService();
var result = await service.CalculateDriftAsync("tenant-1", _fixedTimeProvider.GetUtcNow());
Assert.False(result.HasAnchor);
Assert.Equal(TimeSpan.Zero, result.Drift);
Assert.Null(result.AnchorTime);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_ReturnsDrift_WhenAnchorExists()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 3600 });
var anchorTime = _fixedTimeProvider.GetUtcNow().AddMinutes(-30);
var anchor = new TimeAnchor(anchorTime, "test", "Roughtime", "fp", "digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(15);
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.Equal(anchorTime, result.AnchorTime);
Assert.Equal(45, (int)result.Drift.TotalMinutes); // 30 min + 15 min
Assert.False(result.DriftExceedsThreshold);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateDriftAsync_DetectsExcessiveDrift()
{
var service = CreateService(new TimeAnchorPolicyOptions { MaxDriftSeconds = 60 }); // 1 minute max
var anchor = new TimeAnchor(
_fixedTimeProvider.GetUtcNow(),
"test",
"Roughtime",
"fp",
"digest");
var budget = new StalenessBudget(3600, 7200);
await _store.SetAsync("tenant-1", anchor, budget, CancellationToken.None);
var targetTime = _fixedTimeProvider.GetUtcNow().AddMinutes(5); // 5 minutes drift
var result = await service.CalculateDriftAsync("tenant-1", targetTime);
Assert.True(result.HasAnchor);
Assert.True(result.DriftExceedsThreshold);
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FakeTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,26 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class TimeStatusDtoTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SerializesDeterministically()
{
var status = new TimeStatus(
new TimeAnchor(DateTimeOffset.Parse("2025-01-01T00:00:00Z"), "source", "fmt", "fp", "digest"),
new StalenessEvaluation(42, 10, 20, true, false),
new StalenessBudget(10, 20),
new Dictionary<string, StalenessEvaluation>
{
{ "advisories", new StalenessEvaluation(42, 10, 20, true, false) }
},
DateTimeOffset.Parse("2025-01-02T00:00:00Z"));
var json = TimeStatusDto.FromStatus(status).ToJson();
Assert.Contains("\"contentStaleness\":{\"advisories\":{", json);
Assert.Contains("\"ageSeconds\":42", json);
}
}

View File

@@ -0,0 +1,48 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class TimeStatusServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ReturnsUnknownWhenNoAnchor()
{
var svc = Build(out var telemetry);
var status = await svc.GetStatusAsync("t1", DateTimeOffset.UnixEpoch);
Assert.Equal(TimeAnchor.Unknown, status.Anchor);
Assert.False(status.Staleness.IsWarning);
Assert.Equal(0, telemetry.GetLatest("t1")?.AgeSeconds ?? 0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task PersistsAnchorAndBudget()
{
var svc = Build(out var telemetry);
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
var budget = new StalenessBudget(10, 20);
await svc.SetAnchorAsync("t1", anchor, budget);
var status = await svc.GetStatusAsync("t1", DateTimeOffset.UnixEpoch.AddSeconds(15));
Assert.Equal(anchor, status.Anchor);
Assert.True(status.Staleness.IsWarning);
Assert.False(status.Staleness.IsBreach);
Assert.Equal(15, status.Staleness.AgeSeconds);
var snap = telemetry.GetLatest("t1");
Assert.NotNull(snap);
Assert.Equal(status.Staleness.AgeSeconds, snap!.AgeSeconds);
Assert.True(snap.IsWarning);
}
private static TimeStatusService Build(out TimeTelemetry telemetry)
{
telemetry = new TimeTelemetry();
var options = Microsoft.Extensions.Options.Options.Create(new AirGapOptions());
return new TimeStatusService(new InMemoryTimeAnchorStore(), new StalenessCalculator(), telemetry, options);
}
}

View File

@@ -0,0 +1,29 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class TimeTelemetryTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Records_latest_snapshot_per_tenant()
{
var telemetry = new TimeTelemetry();
var status = new TimeStatus(
new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest"),
new StalenessEvaluation(90, 60, 120, true, false),
StalenessBudget.Default,
new Dictionary<string, StalenessEvaluation>{{"advisories", new StalenessEvaluation(90,60,120,true,false)}},
DateTimeOffset.UtcNow);
telemetry.Record("t1", status);
var snap = telemetry.GetLatest("t1");
Assert.NotNull(snap);
Assert.Equal(90, snap!.AgeSeconds);
Assert.True(snap.IsWarning);
Assert.False(snap.IsBreach);
}
}

View File

@@ -0,0 +1,37 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class TimeTokenParserTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void EmptyTokenFails()
{
var parser = new TimeTokenParser();
var result = parser.TryParse(Array.Empty<byte>(), TimeTokenFormat.Roughtime, out var anchor);
Assert.False(result.IsValid);
Assert.Equal("token-empty", result.Reason);
Assert.Equal(TimeAnchor.Unknown, anchor);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RoughtimeTokenProducesDigest()
{
var parser = new TimeTokenParser();
var token = new byte[] { 0x01, 0x02, 0x03 };
var result = parser.TryParse(token, TimeTokenFormat.Roughtime, out var anchor);
Assert.True(result.IsValid);
Assert.Equal("Roughtime", anchor.Format);
Assert.Equal("roughtime-token", anchor.Source);
Assert.Equal("structure-stubbed", result.Reason);
Assert.Matches("^[0-9a-f]{64}$", anchor.TokenDigest);
Assert.NotEqual(DateTimeOffset.UnixEpoch, anchor.AnchorTime); // deterministic derivation
}
}

View File

@@ -0,0 +1,31 @@
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Parsing;
using StellaOps.AirGap.Time.Services;
using StellaOps.TestKit;
namespace StellaOps.AirGap.Time.Tests;
public class TimeVerificationServiceTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void FailsWithoutTrustRoots()
{
var svc = new TimeVerificationService();
var result = svc.Verify(new byte[] { 0x01 }, TimeTokenFormat.Roughtime, Array.Empty<TimeTrustRoot>(), out _);
Assert.False(result.IsValid);
Assert.Equal("trust-roots-required", result.Reason);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void SucceedsForRoughtimeWithTrustRoot()
{
var svc = new TimeVerificationService();
var trust = new[] { new TimeTrustRoot("k1", new byte[] { 0x01 }, "rsassa-pss-sha256") };
var result = svc.Verify(new byte[] { 0x01, 0x02 }, TimeTokenFormat.Roughtime, trust, out var anchor);
Assert.True(result.IsValid);
Assert.Equal("Roughtime", anchor.Format);
Assert.Equal("k1", anchor.SignatureFingerprint);
}
}