namespace LibNftables.Tests; public sealed class NftablesClientUnitTests { [Fact] public void Apply_WithBothTextAndFile_ThrowsValidationException() { var client = CreateClient(); var request = new NftApplyRequest { RulesetText = "flush ruleset", RulesetFilePath = "/tmp/does-not-matter.nft", }; Assert.Throws(() => client.Apply(request)); } [Fact] public void Apply_WithMissingFile_ThrowsValidationException() { var client = CreateClient(); var request = NftApplyRequest.FromFile("/tmp/does-not-exist.nft"); Assert.Throws(() => client.Apply(request)); } [Fact] public void Apply_NullRequest_ThrowsValidationException() { var client = CreateClient(); Assert.Throws(() => client.Apply(null!)); } [Fact] public void Validate_WhenCommandThrowsValidation_ReturnsInvalidResult() { var context = new FakeContext { CommandException = new NftValidationException("invalid ruleset", 7, "syntax error"), }; var client = CreateClient(() => context); NftValidationResult result = client.Validate(NftApplyRequest.FromText("invalid")); Assert.False(result.IsValid); Assert.Equal("syntax error", result.Diagnostics); } [Fact] public async Task ValidateAsync_UsesSynchronousValidationFlow() { var context = new FakeContext { OutputBuffer = "ok", ErrorBuffer = string.Empty, }; var client = CreateClient(() => context); NftValidationResult result = await client.ValidateAsync(NftApplyRequest.FromText("flush ruleset", dryRun: false)); Assert.True(result.IsValid); Assert.True(context.DryRun); Assert.Equal("ok", result.Output); } [Fact] public void ValidateRuleset_RendersTypedRulesetAndUsesDryRun() { var context = new FakeContext { OutputBuffer = "ok", ErrorBuffer = string.Empty, }; var client = CreateClient(() => context); NftValidationResult result = client.ValidateRuleset(CreateTypedRuleset()); Assert.True(result.IsValid); Assert.True(context.DryRun); Assert.Equal( "add table inet filter" + Environment.NewLine + "add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine + "add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine + "add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine + "add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine, context.LastCommandText); } [Fact] public void ValidateAndRenderRuleset_ReturnsRenderedTextAndValidation() { var context = new FakeContext { OutputBuffer = "ok", ErrorBuffer = string.Empty, }; var client = CreateClient(() => context); NftRenderedValidationResult result = client.ValidateAndRenderRuleset(CreateTypedRuleset()); Assert.True(result.ValidationResult.IsValid); Assert.Equal(result.RenderedRulesetText, context.LastCommandText); Assert.Equal( "add table inet filter" + Environment.NewLine + "add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine + "add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine + "add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine + "add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine, result.RenderedRulesetText); } [Fact] public void ApplyRuleset_RendersTypedRulesetAndExecutesCommand() { var context = new FakeContext(); var client = CreateClient(() => context); client.ApplyRuleset(CreateTypedRuleset()); Assert.False(context.DryRun); Assert.Equal( "add table inet filter" + Environment.NewLine + "add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine + "add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine + "add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine + "add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine, context.LastCommandText); } [Fact] public async Task ApplyRulesetAsync_UsesTypedRulesetText() { var context = new FakeContext(); var client = CreateClient(() => context); await client.ApplyRulesetAsync(CreateTypedRuleset()); Assert.Equal( "add table inet filter" + Environment.NewLine + "add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine + "add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine + "add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine + "add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine, context.LastCommandText); } [Fact] public void Map_Add_DuplicateKey_ThrowsArgumentException() { var map = new NftMap { Name = "service_policy", KeyType = NftMapType.InetService, ValueType = NftMapType.Verdict, }; map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept)); Assert.Throws(() => map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Drop))); } [Fact] public void Map_Set_ReplacesValueWithoutChangingInsertionOrder() { var map = new NftMap { Name = "service_policy", KeyType = NftMapType.InetService, ValueType = NftMapType.Verdict, }; map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept)); map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop)); map.Set(NftValue.Port(80), NftValue.Verdict(NftVerdict.Reject)); Assert.Equal(2, map.Count); Assert.Equal("80", map.Entries[0].Key!.RenderedText); Assert.Equal("reject", map.Entries[0].Value!.RenderedText); Assert.Equal("443", map.Entries[1].Key!.RenderedText); } [Fact] public void RenderRuleset_ReturnsTypedRulesetTextWithoutExecuting() { var context = new FakeContext(); var client = CreateClient(() => context); string rendered = client.RenderRuleset(CreateTypedRuleset()); Assert.Equal( "add table inet filter" + Environment.NewLine + "add set inet filter blocked_ipv4 { type ipv4_addr; elements = { 10.0.0.1, 10.0.0.2 }; }" + Environment.NewLine + "add map inet filter service_policy { type inet_service : verdict; elements = { 80 : accept, 443 : drop }; }" + Environment.NewLine + "add chain inet filter input { type filter hook input priority 0; policy drop; }" + Environment.NewLine + "add rule inet filter input iifname \"eth0\" ip saddr @blocked_ipv4 tcp dport 22 accept" + Environment.NewLine, rendered); Assert.Null(context.LastCommandText); } [Fact] public void Apply_WithFileRequest_UsesFileExecutionPath() { string path = Path.GetTempFileName(); try { var context = new FakeContext(); var client = CreateClient(() => context); client.Apply(NftApplyRequest.FromFile(path)); Assert.Equal(path, context.LastFilePath); Assert.Null(context.LastCommandText); } finally { File.Delete(path); } } [Fact] public void Apply_PropagatesOptionsAndRequestFlags() { var context = new FakeContext(); var options = new NftablesClientOptions { DefaultInputFlags = NftInputFlags.NoDns, DefaultOutputFlags = NftOutputFlags.Json, DefaultDebugFlags = NftDebugLevel.Parser, DefaultOptimizeFlags = NftOptimizeFlags.Enabled, }; var request = NftApplyRequest.FromText("flush ruleset", dryRun: true); request.InputFlags = NftInputFlags.Json; request.OutputFlags = NftOutputFlags.Echo; request.DebugFlags = NftDebugLevel.Scanner; var client = CreateClient(() => context, options); client.Apply(request); Assert.True(context.DryRun); Assert.Equal(NftInputFlags.NoDns | NftInputFlags.Json, context.InputFlags); Assert.Equal(NftOutputFlags.Json | NftOutputFlags.Echo, context.OutputFlags); Assert.Equal(NftDebugLevel.Parser | NftDebugLevel.Scanner, context.DebugFlags); Assert.Equal(NftOptimizeFlags.Enabled, context.OptimizeFlags); Assert.True(context.BufferOutputCalled); Assert.True(context.BufferErrorCalled); } [Fact] public void Snapshot_WithOutput_ReturnsSnapshot() { var context = new FakeContext { OutputBuffer = "table inet filter { }", ErrorBuffer = string.Empty, }; var client = CreateClient(() => context); NftSnapshot snapshot = client.Snapshot(); Assert.Equal("table inet filter { }", snapshot.RulesetText); Assert.Equal("list ruleset", context.LastCommandText); } [Fact] public void Snapshot_WithPermissionError_ThrowsPermissionException() { var context = new FakeContext { OutputBuffer = string.Empty, ErrorBuffer = "Operation not permitted", }; var client = CreateClient(() => context); Assert.Throws(() => client.Snapshot()); } [Fact] public void Snapshot_WithEmptyOutputAndNoError_ReturnsFlushRulesetFallback() { var context = new FakeContext { OutputBuffer = string.Empty, ErrorBuffer = string.Empty, }; var client = CreateClient(() => context); NftSnapshot snapshot = client.Snapshot(); Assert.Equal("flush ruleset", snapshot.RulesetText); } [Fact] public void Restore_WithNullSnapshot_ThrowsValidationException() { var client = CreateClient(); Assert.Throws(() => client.Restore(null!)); } [Fact] public void Restore_WithEmptyRuleset_ThrowsValidationException() { var client = CreateClient(); Assert.Throws(() => client.Restore(new NftSnapshot(" ", DateTimeOffset.UtcNow))); } [Fact] public async Task RestoreAsync_UsesSnapshotRulesetText() { var context = new FakeContext(); var client = CreateClient(() => context); var snapshot = new NftSnapshot("flush ruleset", DateTimeOffset.UtcNow); await client.RestoreAsync(snapshot); Assert.Equal("flush ruleset", context.LastCommandText); } private static NftablesClient CreateClient( Func? contextFactory = null, NftablesClientOptions? options = null) => new(options, contextFactory ?? (() => new FakeContext()), skipRuntimeGuard: true); private static NftRuleset CreateTypedRuleset() { var ruleset = new NftRuleset(); var table = new NftTable { Family = NftFamily.Inet, Name = "filter", }; var set = new NftSet { Name = "blocked_ipv4", Type = NftSetType.Ipv4Address, }; set.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.1"))); set.Elements.Add(NftValue.Address(System.Net.IPAddress.Parse("10.0.0.2"))); table.Sets.Add(set); var map = new NftMap { Name = "service_policy", KeyType = NftMapType.InetService, ValueType = NftMapType.Verdict, }; map.Add(NftValue.Port(80), NftValue.Verdict(NftVerdict.Accept)); map.Add(NftValue.Port(443), NftValue.Verdict(NftVerdict.Drop)); table.Maps.Add(map); var chain = new NftChain { Name = "input", Type = NftChainType.Filter, Hook = NftHook.Input, Priority = 0, Policy = NftChainPolicy.Drop, }; chain.Rules.Add(new NftRule { InputInterface = NftValue.Interface("eth0"), SourceAddressSetName = "blocked_ipv4", TransportProtocol = NftTransportProtocol.Tcp, DestinationPort = NftValue.Port(22), Verdict = NftVerdict.Accept, }); table.Chains.Add(chain); ruleset.Tables.Add(table); return ruleset; } private sealed class FakeContext : INftContextHandle { public bool DryRun { get; set; } public NftOptimizeFlags OptimizeFlags { get; set; } public NftInputFlags InputFlags { get; set; } public NftOutputFlags OutputFlags { get; set; } public NftDebugLevel DebugFlags { get; set; } public bool BufferOutputCalled { get; private set; } public bool BufferErrorCalled { get; private set; } public string? OutputBuffer { get; set; } public string? ErrorBuffer { get; set; } public string? LastCommandText { get; private set; } public string? LastFilePath { get; private set; } public Exception? CommandException { get; set; } public void BufferOutput() => BufferOutputCalled = true; public void BufferError() => BufferErrorCalled = true; public string? GetOutputBuffer() => OutputBuffer; public string? GetErrorBuffer() => ErrorBuffer; public void RunCommand(string commandText) { LastCommandText = commandText; ThrowIfNeeded(); } public void RunCommandFromFile(string path) { LastFilePath = path; ThrowIfNeeded(); } public void Dispose() { } private void ThrowIfNeeded() { if (CommandException is not null) { throw CommandException; } } } }