129 lines
4.6 KiB
C#
129 lines
4.6 KiB
C#
|
|
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);
|
||
|
|
}
|
||
|
|
}
|