up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
This commit is contained in:
6
tests/AirGap/README.md
Normal file
6
tests/AirGap/README.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# AirGap Tests
|
||||
|
||||
## Notes
|
||||
- Mongo-backed tests use Mongo2Go and require the OpenSSL 1.1 shim. The shim is auto-initialized via `OpenSslAutoInit` from `tests/shared`.
|
||||
- If Mongo2Go fails to start (missing `libssl.so.1.1` / `libcrypto.so.1.1`), ensure `tests/shared/native/linux-x64` is on `LD_LIBRARY_PATH` (handled by the shim) or install OpenSSL 1.1 compatibility libs locally.
|
||||
- Tests default to in-memory stores unless `AirGap:Mongo:ConnectionString` is provided.
|
||||
@@ -0,0 +1,163 @@
|
||||
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;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
public class AirGapStartupDiagnosticsHostedServiceTests
|
||||
{
|
||||
[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);
|
||||
}
|
||||
|
||||
[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
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public class AirGapStateServiceTests
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.AirGap.Controller.Domain;
|
||||
using StellaOps.AirGap.Controller.Stores;
|
||||
@@ -66,6 +67,115 @@ public class MongoAirGapStateStoreTests : IDisposable
|
||||
Assert.Equal("absent", stored.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creates_unique_index_on_tenant_and_id()
|
||||
{
|
||||
var indexes = await _collection.Indexes.List().ToListAsync();
|
||||
var match = indexes.FirstOrDefault(idx =>
|
||||
{
|
||||
var key = idx["key"].AsBsonDocument;
|
||||
return key.ElementCount == 2
|
||||
&& key.Names.ElementAt(0) == "tenant_id"
|
||||
&& key.Names.ElementAt(1) == "_id";
|
||||
});
|
||||
|
||||
Assert.NotNull(match);
|
||||
Assert.True(match!["unique"].AsBoolean);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
var count = await _collection.CountDocumentsAsync(Builders<AirGapStateDocument>.Filter.Eq(x => x.TenantId, "tenant-parallel"));
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
var totalDocs = await _collection.CountDocumentsAsync(FilterDefinition<AirGapStateDocument>.Empty);
|
||||
Assert.Equal(tenants.Length, totalDocs);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_mongo.Dispose();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Mongo2Go;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Testing;
|
||||
|
||||
namespace StellaOps.AirGap.Controller.Tests;
|
||||
|
||||
internal sealed class MongoRunnerFixture : IDisposable
|
||||
{
|
||||
private readonly MongoDbRunner _runner;
|
||||
|
||||
public MongoRunnerFixture()
|
||||
{
|
||||
OpenSslAutoInit.Init();
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
Client = new MongoClient(_runner.ConnectionString);
|
||||
}
|
||||
|
||||
public IMongoClient Client { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_runner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../shared/Testing.Shared/Testing.Shared.csproj" />
|
||||
<ProjectReference Include="../../../src/AirGap/StellaOps.AirGap.Controller/StellaOps.AirGap.Controller.csproj" />
|
||||
<Compile Include="../../shared/*.cs" Link="Shared/%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -12,7 +12,7 @@ public class AirGapOptionsValidatorTests
|
||||
var opts = new AirGapOptions { TenantId = "" };
|
||||
var validator = new AirGapOptionsValidator();
|
||||
var result = validator.Validate(null, opts);
|
||||
Assert.True(result is ValidateOptionsResultFailure);
|
||||
Assert.True(result.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -21,7 +21,7 @@ public class AirGapOptionsValidatorTests
|
||||
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 is ValidateOptionsResultFailure);
|
||||
Assert.True(result.Failed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
1
tests/AirGap/StellaOps.AirGap.Time.Tests/GlobalUsings.cs
Normal file
1
tests/AirGap/StellaOps.AirGap.Time.Tests/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
@@ -1,6 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -9,24 +6,18 @@ namespace StellaOps.AirGap.Time.Tests;
|
||||
public class Rfc3161VerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void SignedCmsTokenVerifies()
|
||||
public void StubTokenProducesDeterministicAnchor()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest("CN=tsa", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddHours(1));
|
||||
|
||||
var content = new ContentInfo(new byte[] { 0x01, 0x02, 0x03 });
|
||||
var cms = new SignedCms(content, detached: false);
|
||||
cms.ComputeSignature(new CmsSigner(cert));
|
||||
var tokenBytes = cms.Encode();
|
||||
|
||||
var tokenBytes = new byte[] { 0x01, 0x02, 0x03 };
|
||||
var verifier = new Rfc3161Verifier();
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", cert.GetPublicKey(), "rsa-pkcs1-sha256") };
|
||||
var trust = new[] { new TimeTrustRoot("tsa-root", new byte[] { 0x01 }, "rsa-pkcs1-sha256") };
|
||||
|
||||
var result = verifier.Verify(tokenBytes, trust, out var anchor);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("rfc3161-verified", result.Reason);
|
||||
Assert.Equal("rfc3161-stub-verified", result.Reason);
|
||||
Assert.Equal("RFC3161", anchor.Format);
|
||||
Assert.Equal("tsa-root", anchor.SignatureFingerprint);
|
||||
Assert.False(string.IsNullOrEmpty(anchor.TokenDigest));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -7,33 +6,17 @@ namespace StellaOps.AirGap.Time.Tests;
|
||||
public class RoughtimeVerifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidEd25519SignaturePasses()
|
||||
public void StubTokenProducesDeterministicAnchor()
|
||||
{
|
||||
if (!Ed25519.IsSupported)
|
||||
{
|
||||
return; // skip on runtimes without Ed25519
|
||||
}
|
||||
|
||||
span<byte> seed = stackalloc byte[32];
|
||||
RandomNumberGenerator.Fill(seed);
|
||||
var key = Ed25519.Create();
|
||||
key.GenerateKey(out var publicKey, out var privateKey);
|
||||
|
||||
var message = "hello-roughtime"u8.ToArray();
|
||||
var signature = new byte[64];
|
||||
Ed25519.Sign(message, privateKey, signature);
|
||||
|
||||
var token = new byte[message.Length + signature.Length];
|
||||
Buffer.BlockCopy(message, 0, token, 0, message.Length);
|
||||
Buffer.BlockCopy(signature, 0, token, message.Length, signature.Length);
|
||||
var token = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
|
||||
|
||||
var verifier = new RoughtimeVerifier();
|
||||
var trust = new[] { new TimeTrustRoot("root1", publicKey, "ed25519") };
|
||||
var trust = new[] { new TimeTrustRoot("root1", new byte[] { 0x10, 0x20 }, "ed25519") };
|
||||
|
||||
var result = verifier.Verify(token, trust, out var anchor);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal("roughtime-verified", result.Reason);
|
||||
Assert.Equal("roughtime-stub-verified", result.Reason);
|
||||
Assert.Equal("Roughtime", anchor.Format);
|
||||
Assert.Equal("root1", anchor.SignatureFingerprint);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ public class SealedStartupValidatorTests
|
||||
public async Task SucceedsWhenFresh()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest");
|
||||
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);
|
||||
@@ -44,7 +45,7 @@ public class SealedStartupValidatorTests
|
||||
public async Task FailsOnBudgetMismatch()
|
||||
{
|
||||
var validator = Build(out var statusService);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest");
|
||||
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);
|
||||
@@ -56,7 +57,7 @@ public class SealedStartupValidatorTests
|
||||
private static SealedStartupValidator Build(out TimeStatusService statusService)
|
||||
{
|
||||
var store = new InMemoryTimeAnchorStore();
|
||||
statusService = new TimeStatusService(store, new StalenessCalculator());
|
||||
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), Microsoft.Extensions.Options.Options.Create(new AirGapOptions()));
|
||||
return new SealedStartupValidator(statusService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Parsing;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
@@ -8,8 +10,9 @@ public class TimeAnchorLoaderTests
|
||||
[Fact]
|
||||
public void RejectsInvalidHex()
|
||||
{
|
||||
var loader = new TimeAnchorLoader();
|
||||
var result = loader.TryLoadHex("not-hex", TimeTokenFormat.Roughtime, Array.Empty<TimeTrustRoot>(), out _);
|
||||
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);
|
||||
}
|
||||
@@ -17,9 +20,9 @@ public class TimeAnchorLoaderTests
|
||||
[Fact]
|
||||
public void LoadsHexToken()
|
||||
{
|
||||
var loader = new TimeAnchorLoader();
|
||||
var loader = Build();
|
||||
var hex = "01020304";
|
||||
var trust = new[] { new TimeTrustRoot("k1", new byte[] { 0x01 }, "rsassa-pss-sha256") };
|
||||
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);
|
||||
@@ -29,7 +32,7 @@ public class TimeAnchorLoaderTests
|
||||
[Fact]
|
||||
public void RejectsIncompatibleTrustRoots()
|
||||
{
|
||||
var loader = new TimeAnchorLoader();
|
||||
var loader = Build();
|
||||
var hex = "010203";
|
||||
var rsaKey = new byte[128];
|
||||
var trust = new[] { new TimeTrustRoot("k1", rsaKey, "rsa") };
|
||||
@@ -43,10 +46,16 @@ public class TimeAnchorLoaderTests
|
||||
[Fact]
|
||||
public void RejectsWhenTrustRootsMissing()
|
||||
{
|
||||
var loader = new TimeAnchorLoader();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,14 @@ public class TimeStatusDtoTests
|
||||
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.Equal("{\"anchorTime\":\"2025-01-01T00:00:00.0000000Z\",\"format\":\"fmt\",\"source\":\"source\",\"fingerprint\":\"fp\",\"digest\":\"digest\",\"ageSeconds\":42,\"warningSeconds\":10,\"breachSeconds\":20,\"isWarning\":true,\"isBreach\":false,\"evaluatedAtUtc\":\"2025-01-02T00:00:00.0000000Z\"}", json);
|
||||
Assert.Contains("\"contentStaleness\":{\"advisories\":{", json);
|
||||
Assert.Contains("\"ageSeconds\":42", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,17 @@ public class TimeStatusServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnsUnknownWhenNoAnchor()
|
||||
{
|
||||
var svc = Build();
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PersistsAnchorAndBudget()
|
||||
{
|
||||
var svc = Build();
|
||||
var svc = Build(out var telemetry);
|
||||
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "source", "fmt", "fp", "digest");
|
||||
var budget = new StalenessBudget(10, 20);
|
||||
|
||||
@@ -28,7 +29,16 @@ public class TimeStatusServiceTests
|
||||
Assert.Equal(anchor, status.Anchor);
|
||||
Assert.True(status.Staleness.IsWarning);
|
||||
Assert.False(status.Staleness.IsBreach);
|
||||
var snap = telemetry.GetLatest("t1");
|
||||
Assert.NotNull(snap);
|
||||
Assert.Equal(status.Staleness.AgeSeconds, snap!.AgeSeconds);
|
||||
Assert.True(snap.IsWarning);
|
||||
}
|
||||
|
||||
private static TimeStatusService Build() => new(new InMemoryTimeAnchorStore(), new StalenessCalculator());
|
||||
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,27 @@
|
||||
using StellaOps.AirGap.Time.Models;
|
||||
using StellaOps.AirGap.Time.Services;
|
||||
|
||||
namespace StellaOps.AirGap.Time.Tests;
|
||||
|
||||
public class TimeTelemetryTests
|
||||
{
|
||||
[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,405 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using StellaOps.Scanner.Reachability.Lifters;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public sealed class ReachabilityLifterTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public ReachabilityLifterTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"lifter-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeLifter_ExtractsPackageInfo()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"lodash": "^4.17.0"
|
||||
}
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var lifter = new NodeReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-001"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("node");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().NotBeEmpty();
|
||||
graph.Nodes.Should().Contain(n => n.Display == "my-app");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "express");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "lodash");
|
||||
|
||||
graph.Edges.Should().NotBeEmpty();
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "import");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeLifter_ExtractsEntrypoints()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "my-cli",
|
||||
"version": "2.0.0",
|
||||
"main": "lib/index.js",
|
||||
"module": "lib/index.mjs",
|
||||
"bin": {
|
||||
"mycli": "bin/cli.js"
|
||||
}
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var lifter = new NodeReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-002"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("node");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().Contain(n => n.Kind == "entrypoint");
|
||||
graph.Nodes.Should().Contain(n => n.Kind == "binary");
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "loads");
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "spawn");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NodeLifter_ExtractsImportsFromSource()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var sourceCode = """
|
||||
import express from 'express';
|
||||
const lodash = require('lodash');
|
||||
import('./dynamic-module.js');
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "index.js"), sourceCode);
|
||||
|
||||
var lifter = new NodeReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-003"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("node");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().Contain(n => n.Display == "express");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "lodash");
|
||||
graph.Edges.Count(e => e.EdgeType == "import").Should().BeGreaterOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DotNetLifter_ExtractsProjectInfo()
|
||||
{
|
||||
// Arrange
|
||||
var csproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>MyApp</AssemblyName>
|
||||
<RootNamespace>MyCompany.MyApp</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "MyApp.csproj"), csproj);
|
||||
|
||||
var lifter = new DotNetReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-004"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("dotnet");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().NotBeEmpty();
|
||||
graph.Nodes.Should().Contain(n => n.Display == "MyApp");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "Newtonsoft.Json");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "Serilog");
|
||||
graph.Nodes.Should().Contain(n => n.Kind == "namespace" && n.Display == "MyCompany.MyApp");
|
||||
|
||||
graph.Edges.Should().NotBeEmpty();
|
||||
graph.Edges.Count(e => e.EdgeType == "import").Should().BeGreaterOrEqualTo(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DotNetLifter_ExtractsProjectReferences()
|
||||
{
|
||||
// Arrange
|
||||
var libDir = Path.Combine(_tempDir, "Lib");
|
||||
Directory.CreateDirectory(libDir);
|
||||
|
||||
var libCsproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>MyLib</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(libDir, "MyLib.csproj"), libCsproj);
|
||||
|
||||
var appCsproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>MyApp</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="Lib/MyLib.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "MyApp.csproj"), appCsproj);
|
||||
|
||||
var lifter = new DotNetReachabilityLifter();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-005"
|
||||
};
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
await lifter.LiftAsync(context, builder, CancellationToken.None);
|
||||
var graph = builder.ToUnionGraph("dotnet");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().Contain(n => n.Display == "MyApp");
|
||||
graph.Nodes.Should().Contain(n => n.Display == "MyLib");
|
||||
graph.Edges.Should().Contain(e => e.EdgeType == "import");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifterRegistry_CombinesMultipleLanguages()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "hybrid-app",
|
||||
"version": "1.0.0",
|
||||
"dependencies": { "express": "^4.0.0" }
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var csproj = """
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<AssemblyName>HybridBackend</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "Backend.csproj"), csproj);
|
||||
|
||||
var registry = new ReachabilityLifterRegistry();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-006"
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await registry.LiftAllAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
registry.Lifters.Should().HaveCountGreaterOrEqualTo(2);
|
||||
graph.Nodes.Should().Contain(n => n.Lang == "node");
|
||||
graph.Nodes.Should().Contain(n => n.Lang == "dotnet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifterRegistry_SelectsSpecificLanguages()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "node-only",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var registry = new ReachabilityLifterRegistry();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-analysis-007"
|
||||
};
|
||||
|
||||
// Act
|
||||
var graph = await registry.LiftAsync(context, new[] { "node" }, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().OnlyContain(n => n.Lang == "node");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LifterRegistry_LiftAndWrite_CreatesOutputFiles()
|
||||
{
|
||||
// Arrange
|
||||
var packageJson = """
|
||||
{
|
||||
"name": "write-test",
|
||||
"version": "1.0.0",
|
||||
"dependencies": { "lodash": "^4.0.0" }
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), packageJson);
|
||||
|
||||
var outputDir = Path.Combine(_tempDir, "output");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var registry = new ReachabilityLifterRegistry();
|
||||
var context = new ReachabilityLifterContext
|
||||
{
|
||||
RootPath = _tempDir,
|
||||
AnalysisId = "test-write-001"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await registry.LiftAndWriteAsync(context, outputDir, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
File.Exists(result.MetaPath).Should().BeTrue();
|
||||
File.Exists(result.Nodes.Path).Should().BeTrue();
|
||||
File.Exists(result.Edges.Path).Should().BeTrue();
|
||||
result.Nodes.RecordCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphBuilder_AddsRichNodes()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
builder.AddNode(
|
||||
symbolId: "sym:test:abc123",
|
||||
lang: "test",
|
||||
kind: "function",
|
||||
display: "myFunction",
|
||||
sourceFile: "src/main.ts",
|
||||
sourceLine: 42,
|
||||
attributes: new System.Collections.Generic.Dictionary<string, string>
|
||||
{
|
||||
["visibility"] = "public",
|
||||
["async"] = "true"
|
||||
});
|
||||
|
||||
var graph = builder.ToUnionGraph("test");
|
||||
|
||||
// Assert
|
||||
graph.Nodes.Should().HaveCount(1);
|
||||
var node = graph.Nodes.First();
|
||||
node.SymbolId.Should().Be("sym:test:abc123");
|
||||
node.Lang.Should().Be("test");
|
||||
node.Kind.Should().Be("function");
|
||||
node.Display.Should().Be("myFunction");
|
||||
node.Attributes.Should().ContainKey("visibility");
|
||||
node.Source.Should().NotBeNull();
|
||||
node.Source!.Evidence.Should().Contain("src/main.ts:42");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GraphBuilder_AddsRichEdges()
|
||||
{
|
||||
// Arrange
|
||||
var builder = new ReachabilityGraphBuilder();
|
||||
|
||||
// Act
|
||||
builder.AddEdge(
|
||||
from: "sym:test:from",
|
||||
to: "sym:test:to",
|
||||
edgeType: EdgeTypes.Call,
|
||||
confidence: EdgeConfidence.High,
|
||||
origin: "static",
|
||||
provenance: Provenance.Il,
|
||||
evidence: "file:src/main.cs:100");
|
||||
|
||||
var graph = builder.ToUnionGraph("test");
|
||||
|
||||
// Assert
|
||||
graph.Edges.Should().HaveCount(1);
|
||||
var edge = graph.Edges.First();
|
||||
edge.From.Should().Be("sym:test:from");
|
||||
edge.To.Should().Be("sym:test:to");
|
||||
edge.EdgeType.Should().Be("call");
|
||||
edge.Confidence.Should().Be("high");
|
||||
edge.Source.Should().NotBeNull();
|
||||
edge.Source!.Origin.Should().Be("static");
|
||||
edge.Source.Provenance.Should().Be("il");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using FluentAssertions;
|
||||
using StellaOps.Scanner.Reachability;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Reachability.FixtureTests;
|
||||
|
||||
public sealed class SymbolIdTests
|
||||
{
|
||||
[Fact]
|
||||
public void ForJava_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForJava("com.example", "MyClass", "doSomething", "(Ljava/lang/String;)V");
|
||||
|
||||
id.Should().StartWith("sym:java:");
|
||||
id.Should().HaveLength("sym:java:".Length + 43); // Base64url SHA-256 without padding = 43 chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForJava_IsDeterministic()
|
||||
{
|
||||
var id1 = SymbolId.ForJava("com.example", "MyClass", "doSomething", "(Ljava/lang/String;)V");
|
||||
var id2 = SymbolId.ForJava("com.example", "MyClass", "doSomething", "(Ljava/lang/String;)V");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForJava_IsCaseInsensitive()
|
||||
{
|
||||
var id1 = SymbolId.ForJava("com.example", "MyClass", "doSomething", "()V");
|
||||
var id2 = SymbolId.ForJava("COM.EXAMPLE", "MYCLASS", "DOSOMETHING", "()V");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForDotNet_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForDotNet("MyAssembly", "MyNamespace", "MyClass", "MyMethod(System.String)");
|
||||
|
||||
id.Should().StartWith("sym:dotnet:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForDotNet_DifferentSignaturesProduceDifferentIds()
|
||||
{
|
||||
var id1 = SymbolId.ForDotNet("MyAssembly", "MyNamespace", "MyClass", "Method(String)");
|
||||
var id2 = SymbolId.ForDotNet("MyAssembly", "MyNamespace", "MyClass", "Method(Int32)");
|
||||
|
||||
id1.Should().NotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForNode_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForNode("express", "lib/router", "function");
|
||||
|
||||
id.Should().StartWith("sym:node:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForNode_HandlesScopedPackages()
|
||||
{
|
||||
var id1 = SymbolId.ForNode("@angular/core", "src/render", "function");
|
||||
var id2 = SymbolId.ForNode("@angular/core", "src/render", "function");
|
||||
|
||||
id1.Should().Be(id2);
|
||||
id1.Should().StartWith("sym:node:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForGo_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForGo("github.com/example/repo", "pkg/http", "Server", "HandleRequest");
|
||||
|
||||
id.Should().StartWith("sym:go:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForGo_FunctionWithoutReceiver()
|
||||
{
|
||||
var id = SymbolId.ForGo("github.com/example/repo", "pkg/main", "", "main");
|
||||
|
||||
id.Should().StartWith("sym:go:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForRust_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForRust("my_crate", "foo::bar", "my_function", "_ZN8my_crate3foo3bar11my_functionE");
|
||||
|
||||
id.Should().StartWith("sym:rust:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForSwift_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForSwift("MyModule", "MyClass", "myMethod", null);
|
||||
|
||||
id.Should().StartWith("sym:swift:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForShell_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForShell("scripts/deploy.sh", "run_migration");
|
||||
|
||||
id.Should().StartWith("sym:shell:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForBinary_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForBinary("7f6e5d4c3b2a1908", ".text", "_start");
|
||||
|
||||
id.Should().StartWith("sym:binary:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForPython_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForPython("requests", "requests.api", "get");
|
||||
|
||||
id.Should().StartWith("sym:python:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForRuby_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForRuby("rails", "ActiveRecord::Base", "#save");
|
||||
|
||||
id.Should().StartWith("sym:ruby:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForPhp_CreatesCanonicalSymbolId()
|
||||
{
|
||||
var id = SymbolId.ForPhp("laravel/framework", "Illuminate\\Http", "Request::input");
|
||||
|
||||
id.Should().StartWith("sym:php:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ValidSymbolId_ReturnsComponents()
|
||||
{
|
||||
var id = SymbolId.ForJava("com.example", "MyClass", "method", "()V");
|
||||
|
||||
var result = SymbolId.Parse(id);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result!.Value.Lang.Should().Be("java");
|
||||
result.Value.Fragment.Should().HaveLength(43);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidSymbolId_ReturnsNull()
|
||||
{
|
||||
SymbolId.Parse("invalid").Should().BeNull();
|
||||
SymbolId.Parse("sym:").Should().BeNull();
|
||||
SymbolId.Parse("sym:java").Should().BeNull();
|
||||
SymbolId.Parse("").Should().BeNull();
|
||||
SymbolId.Parse(null!).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromTuple_CreatesSymbolIdFromRawTuple()
|
||||
{
|
||||
var tuple = "my\0canonical\0tuple";
|
||||
var id = SymbolId.FromTuple("custom", tuple);
|
||||
|
||||
id.Should().StartWith("sym:custom:");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllLanguagesAreDifferent()
|
||||
{
|
||||
// Same tuple data should produce different IDs for different languages
|
||||
var java = SymbolId.ForJava("pkg", "cls", "meth", "()V");
|
||||
var dotnet = SymbolId.ForDotNet("pkg", "cls", "meth", "()V");
|
||||
var node = SymbolId.ForNode("pkg", "cls", "meth");
|
||||
|
||||
java.Should().NotBe(dotnet);
|
||||
dotnet.Should().NotBe(node);
|
||||
java.Should().NotBe(node);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user