Fix build and code structure improvements. New but essential UI functionality. CI improvements. Documentation improvements. AI module improvements.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.AirGap.Controller": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62500;http://localhost:62503"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -13,5 +13,4 @@
|
||||
<ProjectReference Include="..\StellaOps.AirGap.Policy\StellaOps.AirGap.Policy.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"StellaOps.AirGap.Time": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:62505;http://localhost:62506"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
450
src/AirGap/StellaOps.AirGap.sln
Normal file
450
src/AirGap/StellaOps.AirGap.sln
Normal 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
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user