w4c-workflows-api/w4c-workflows-api.Tests/OpenSearchActionAuditSinkTests.cs

129 lines
4.6 KiB
C#
Raw Permalink Normal View History

2026-09-11 22:02:46 +00:00
using System.Net;
using System.Text;
using w4c_workflows.Services.Audit;
using Xunit;
namespace w4c_workflows.Tests;
public class OpenSearchActionAuditSinkTests
{
private sealed class Handler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public Handler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public int Calls { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
LastRequest = request;
LastBody = request.Content == null ? null : await request.Content.ReadAsStringAsync(ct);
Calls++;
return _responder(request);
}
}
private sealed class Factory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public Factory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static ActionAuditEntry Entry(string tenant = "acme") => new()
{
Timestamp = new DateTime(2026, 9, 11, 12, 0, 0, DateTimeKind.Utc),
TenantId = tenant,
Action = "node.executed",
Outcome = "succeeded",
RunId = "run-1",
NodeId = "step",
};
[Theory]
[InlineData("w4c-actions", "ACME Corp", "w4c-actions-acme-corp-2026.09")]
[InlineData("w4c-actions", "tenant/with*chars", "w4c-actions-tenant-with-chars-2026.09")]
[InlineData("w4c-actions", "", "w4c-actions-unknown-2026.09")]
[InlineData("", "acme", "w4c-actions-acme-2026.09")]
public void Index_name_is_tenant_scoped_and_monthly(string prefix, string tenant, string expected)
{
Assert.Equal(
expected,
OpenSearchActionAuditSink.IndexName(prefix, tenant, new DateTime(2026, 9, 11)));
}
[Fact]
public async Task Posts_one_document_to_the_tenant_index()
{
var handler = new Handler(_ => new HttpResponseMessage(HttpStatusCode.Created));
var options = new ActionAuditOptions { Enabled = true, Url = "http://os:9200" };
var sink = new OpenSearchActionAuditSink(new Factory(handler), options);
await sink.RecordAsync(Entry(), default);
Assert.Equal(
"http://os:9200/w4c-actions-acme-2026.09/_doc",
handler.LastRequest!.RequestUri!.ToString());
Assert.Equal(HttpMethod.Post, handler.LastRequest.Method);
Assert.Contains("\"action\":\"node.executed\"", handler.LastBody);
Assert.Contains("\"tenantId\":\"acme\"", handler.LastBody);
}
[Fact]
public async Task Sends_basic_auth_when_configured()
{
var handler = new Handler(_ => new HttpResponseMessage(HttpStatusCode.Created));
var options = new ActionAuditOptions
{
Enabled = true,
Url = "http://os:9200/",
Username = "aud",
Password = "pw",
};
var sink = new OpenSearchActionAuditSink(new Factory(handler), options);
await sink.RecordAsync(Entry(), default);
var expected = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("aud:pw"));
Assert.Equal(expected, handler.LastRequest!.Headers.Authorization!.ToString());
}
[Fact]
public async Task Empty_url_is_a_no_op()
{
var handler = new Handler(_ => new HttpResponseMessage(HttpStatusCode.Created));
var sink = new OpenSearchActionAuditSink(new Factory(handler), new ActionAuditOptions());
await sink.RecordAsync(Entry(), default);
Assert.Equal(0, handler.Calls);
}
[Fact]
public async Task Transport_failure_is_swallowed()
{
var handler = new Handler(_ => throw new HttpRequestException("os down"));
var options = new ActionAuditOptions { Enabled = true, Url = "http://os:9200" };
var sink = new OpenSearchActionAuditSink(new Factory(handler), options);
// Best-effort: must not throw.
await sink.RecordAsync(Entry(), default);
}
[Fact]
public async Task Error_status_is_not_thrown()
{
var handler = new Handler(_ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
var options = new ActionAuditOptions { Enabled = true, Url = "http://os:9200" };
var sink = new OpenSearchActionAuditSink(new Factory(handler), options);
await sink.RecordAsync(Entry(), default);
Assert.Equal(1, handler.Calls);
}
}