Files
libnftables-dotnet/tests/LibNftables.Tests/NftablesClientUnitTests.cs
2026-03-16 04:16:40 +00:00

437 lines
15 KiB
C#

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<NftValidationException>(() => client.Apply(request));
}
[Fact]
public void Apply_WithMissingFile_ThrowsValidationException()
{
var client = CreateClient();
var request = NftApplyRequest.FromFile("/tmp/does-not-exist.nft");
Assert.Throws<NftValidationException>(() => client.Apply(request));
}
[Fact]
public void Apply_NullRequest_ThrowsValidationException()
{
var client = CreateClient();
Assert.Throws<NftValidationException>(() => 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<ArgumentException>(() => 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<NftPermissionException>(() => 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<NftValidationException>(() => client.Restore(null!));
}
[Fact]
public void Restore_WithEmptyRuleset_ThrowsValidationException()
{
var client = CreateClient();
Assert.Throws<NftValidationException>(() => 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<INftContextHandle>? 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;
}
}
}
}