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 _responder; public Handler(Func 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 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); } }