Files
register-referentie/services/domain/Big.Tests/ClamdDocumentScannerTests.cs
T
notandClaude Opus 5.5 e39a46febc
CI / k8s (pull_request) Successful in 18s
CI / build (pull_request) Successful in 5m23s
CI / lint (pull_request) Successful in 5m55s
CI / docs (pull_request) Successful in 1m3s
CI / unit (pull_request) Successful in 1m33s
CI / frontend (pull_request) Successful in 5m31s
CI / mutation (pull_request) Successful in 8m11s
CI / verify-stack (pull_request) Skipped
test(domain): cover clamd timeout, caller cancellation, null content and defaults (refs #192)
Domain mutation score 89.87% → 91.98%, back above the 90% break.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:53:23 +02:00

118 lines
4.6 KiB
C#

using System.Net;
using System.Net.Sockets;
using Big.Application;
using Big.Infrastructure;
namespace Big.Tests;
// S-29 (#192, ADR-0036): the clamd INSTREAM adapter, against a fake clamd on a loopback socket. The live
// engine (EICAR → FOUND) is verified by verify-clamav.
public class ClamdDocumentScannerTests
{
/// <summary>A one-shot fake clamd: reads one INSTREAM request to its zero-length terminator,
/// records it, and answers <paramref name="reply"/>.</summary>
private sealed class FakeClamd : IDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
public Task<byte[]> Received { get; }
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
/// <param name="reply">The answer, or null to accept the request and never answer (a hung clamd).</param>
public FakeClamd(string? reply)
{
_listener.Start();
Received = Serve(reply);
}
private async Task<byte[]> Serve(string? reply)
{
using var client = await _listener.AcceptTcpClientAsync();
var stream = client.GetStream();
var received = new MemoryStream();
var buffer = new byte[4096];
while (!EndsWithTerminator(received))
{
var n = await stream.ReadAsync(buffer);
if (n == 0) break;
received.Write(buffer, 0, n);
}
if (reply is null)
await Task.Delay(TimeSpan.FromSeconds(10)); // long past any test timeout
else
await stream.WriteAsync(System.Text.Encoding.ASCII.GetBytes(reply + "\0"));
return received.ToArray();
}
// The request is "zINSTREAM\0" + chunks + a 4-byte zero length; it is complete once it ends in it.
private static bool EndsWithTerminator(MemoryStream s)
=> s.Length > 14 && s.ToArray()[^4..].All(b => b == 0);
public void Dispose() => _listener.Stop();
}
private static ClamdDocumentScanner ScannerFor(int port) =>
new(new ClamAvOptions { Host = "127.0.0.1", Port = port, Timeout = TimeSpan.FromSeconds(5) });
[Fact]
public async Task Sends_the_document_as_one_length_prefixed_instream_chunk()
{
using var clamd = new FakeClamd("stream: OK");
await ScannerFor(clamd.Port).ScanAsync([1, 2, 3]);
Assert.Equal("zINSTREAM\0"u8.ToArray().Concat(new byte[] { 0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0 }),
await clamd.Received.WaitAsync(TimeSpan.FromSeconds(5)));
}
[Theory]
[InlineData("stream: OK", ScanVerdict.Clean)]
[InlineData("stream: Eicar-Test-Signature FOUND", ScanVerdict.Infected)]
[InlineData("INSTREAM size limit exceeded. ERROR", ScanVerdict.Unavailable)]
public async Task Maps_the_clamd_reply_to_a_verdict(string reply, ScanVerdict expected)
{
using var clamd = new FakeClamd(reply);
Assert.Equal(expected, await ScannerFor(clamd.Port).ScanAsync([1, 2, 3]));
}
[Fact]
public async Task An_unreachable_clamd_is_unavailable_not_an_exception()
{
// Grab a free port, then close it, so nothing listens there.
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
Assert.Equal(ScanVerdict.Unavailable, await ScannerFor(port).ScanAsync([1, 2, 3]));
}
[Fact]
public async Task A_clamd_that_never_answers_is_unavailable_after_the_timeout()
{
using var clamd = new FakeClamd(reply: null);
var scanner = new ClamdDocumentScanner(
new ClamAvOptions { Host = "127.0.0.1", Port = clamd.Port, Timeout = TimeSpan.FromMilliseconds(300) });
Assert.Equal(ScanVerdict.Unavailable, await scanner.ScanAsync([1, 2, 3]));
}
[Fact]
public async Task A_cancelled_request_is_cancelled_not_reported_unavailable()
{
// The caller giving up is not a scanner outage: it propagates instead of becoming a 503.
using var clamd = new FakeClamd(reply: null);
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => ScannerFor(clamd.Port).ScanAsync([1, 2, 3], cts.Token));
}
[Fact]
public async Task Rejects_null_content()
=> await Assert.ThrowsAsync<ArgumentNullException>(() => ScannerFor(1).ScanAsync(null!));
[Fact]
public void Defaults_to_the_clamav_service_on_the_clamd_port()
=> Assert.Equal(("clamav", 3310), (new ClamAvOptions().Host, new ClamAvOptions().Port));
}