Improve testability, packaging, and docs

This commit is contained in:
Vibe Myass
2026-03-16 03:45:00 +00:00
parent 775bb7813d
commit 458494221e
12 changed files with 565 additions and 110 deletions

View File

@@ -0,0 +1,64 @@
using System.Globalization;
namespace LibNftables.Tests;
internal static class NativeTestSupport
{
internal static bool HasCapNetAdmin()
{
const int capNetAdminBit = 12;
const ulong mask = 1UL << capNetAdminBit;
try
{
foreach (var line in File.ReadLines("/proc/self/status"))
{
if (!line.StartsWith("CapEff:", StringComparison.Ordinal))
{
continue;
}
var hex = line["CapEff:".Length..].Trim();
if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value))
{
return (value & mask) != 0;
}
}
}
catch
{
// If capability probing fails, keep tests conservative.
}
return false;
}
internal static bool TryCreateContext(out NftContext context)
{
try
{
context = new NftContext();
return true;
}
catch (DllNotFoundException)
{
context = null!;
return false;
}
catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException)
{
context = null!;
return false;
}
catch (BadImageFormatException)
{
context = null!;
return false;
}
catch (EntryPointNotFoundException)
{
context = null!;
return false;
}
}
}

View File

@@ -1,5 +1,3 @@
using System.Globalization;
namespace LibNftables.Tests;
public sealed class NftContextTests
@@ -7,7 +5,7 @@ public sealed class NftContextTests
[Fact]
public void ContextFlagsRoundTrip_Works()
{
if (!TryCreateContext(out var ctx))
if (!NativeTestSupport.TryCreateContext(out var ctx))
{
return;
}
@@ -38,7 +36,7 @@ public sealed class NftContextTests
[Fact]
public void InvalidCommand_ThrowsNftExceptionWithErrorBuffer()
{
if (!TryCreateContext(out var ctx))
if (!NativeTestSupport.TryCreateContext(out var ctx))
{
return;
}
@@ -57,12 +55,12 @@ public sealed class NftContextTests
[Fact]
public void ValidDryRunCommand_CanExecuteAndBufferOutput()
{
if (!TryCreateContext(out var ctx))
if (!NativeTestSupport.TryCreateContext(out var ctx))
{
return;
}
if (!HasCapNetAdmin())
if (!NativeTestSupport.HasCapNetAdmin())
{
return;
}
@@ -82,61 +80,4 @@ public sealed class NftContextTests
}
}
private static bool HasCapNetAdmin()
{
const int capNetAdminBit = 12;
const ulong mask = 1UL << capNetAdminBit;
try
{
foreach (var line in File.ReadLines("/proc/self/status"))
{
if (!line.StartsWith("CapEff:", StringComparison.Ordinal))
{
continue;
}
var hex = line["CapEff:".Length..].Trim();
if (ulong.TryParse(hex, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out var value))
{
return (value & mask) != 0;
}
}
}
catch
{
// If capability probing fails, keep test conservative.
}
return false;
}
private static bool TryCreateContext(out NftContext context)
{
try
{
context = new NftContext();
return true;
}
catch (DllNotFoundException)
{
context = null!;
return false;
}
catch (TypeInitializationException ex) when (ex.InnerException is DllNotFoundException)
{
context = null!;
return false;
}
catch (BadImageFormatException)
{
context = null!;
return false;
}
catch (EntryPointNotFoundException)
{
context = null!;
return false;
}
}
}

View File

@@ -0,0 +1,41 @@
namespace LibNftables.Tests;
public sealed class NftErrorTranslatorTests
{
[Fact]
public void PermissionErrors_AreClassified()
{
NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 1, "Operation not permitted");
Assert.IsType<NftPermissionException>(ex);
Assert.Equal(1, ex.NativeErrorCode);
}
[Fact]
public void ValidationErrors_AreClassified()
{
NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 2, "syntax error, unexpected token");
Assert.IsType<NftValidationException>(ex);
Assert.Equal(2, ex.NativeErrorCode);
}
[Fact]
public void UnsupportedErrors_AreClassified()
{
NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 3, "operation not supported");
Assert.IsType<NftUnsupportedException>(ex);
Assert.Equal(3, ex.NativeErrorCode);
}
[Fact]
public void UnknownErrors_FallBackToBaseException()
{
NftException ex = NftErrorTranslator.FromOperationFailure("RunCommand", 4, "unexpected native failure");
Assert.IsType<NftException>(ex);
Assert.Equal(4, ex.NativeErrorCode);
Assert.DoesNotContain("syntax error", ex.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,23 +1,15 @@
namespace LibNftables.Tests;
public sealed class NftablesClientTests
public sealed class NftablesClientIntegrationTests
{
[Fact]
public void Apply_WithBothTextAndFile_ThrowsValidationException()
{
var client = new NftablesClient();
var request = new NftApplyRequest
{
RulesetText = "flush ruleset",
RulesetFilePath = "/tmp/does-not-matter.nft",
};
Assert.Throws<NftValidationException>(() => client.Apply(request));
}
[Fact]
public void Validate_InvalidRuleset_ReturnsInvalidResult()
{
if (!CanCreateClient())
{
return;
}
var client = new NftablesClient();
var request = NftApplyRequest.FromText("this is not valid nft syntax");
@@ -28,8 +20,13 @@ public sealed class NftablesClientTests
}
[Fact]
public async System.Threading.Tasks.Task ValidateAsync_InvalidRuleset_ReturnsInvalidResult()
public async Task ValidateAsync_InvalidRuleset_ReturnsInvalidResult()
{
if (!CanCreateClient())
{
return;
}
var client = new NftablesClient();
var request = NftApplyRequest.FromText("this is not valid nft syntax");
@@ -42,6 +39,11 @@ public sealed class NftablesClientTests
[Fact]
public void Apply_InvalidRuleset_ThrowsValidationException()
{
if (!CanCreateClient())
{
return;
}
var client = new NftablesClient();
var request = NftApplyRequest.FromText("this is not valid nft syntax");
@@ -51,6 +53,11 @@ public sealed class NftablesClientTests
[Fact]
public void Snapshot_WithInsufficientPrivileges_ThrowsPermissionOrReturnsRuleset()
{
if (!CanCreateClient())
{
return;
}
var client = new NftablesClient();
try
@@ -63,4 +70,17 @@ public sealed class NftablesClientTests
// Expected in unprivileged environments.
}
}
private static bool CanCreateClient()
{
try
{
_ = new NftablesClient();
return true;
}
catch (NftException)
{
return false;
}
}
}

View File

@@ -0,0 +1,252 @@
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 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 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;
}
}
}
}