diff --git a/EnterpriseIntegrationPlatform/rules/completion-log.md b/EnterpriseIntegrationPlatform/rules/completion-log.md
index a0d330a..d86c8cf 100644
--- a/EnterpriseIntegrationPlatform/rules/completion-log.md
+++ b/EnterpriseIntegrationPlatform/rules/completion-log.md
@@ -4,6 +4,89 @@ Detailed record of completed chunks, files created/modified, and notes.
See `milestones.md` for current phase status and next chunk.
+## Chunk 362 — ControlBusPublisher & DlqManagementService Tests
+
+- **Date**: 2026-04-09
+- **Phase**: 36 — Auth, Observability & System Management Test Hardening
+- **Status**: done
+- **Goal**: Dedicated unit tests for ControlBusPublisher (10 tests) covering publish success/failure, command intent, topic routing, argument validation, subscribe registration. DlqManagementService (2 tests) covering replay delegation and filter passthrough.
+- **Files created**:
+ - `tests/UnitTests/ControlBusPublisherTests.cs` — 12 tests across 2 fixtures: ControlBusPublisherTests (PublishCommandAsync success/topic/intent/failure/null command/empty type, SubscribeAsync registration/null handler/empty type, constructor null producer/consumer), DlqManagementServiceTests (ResubmitAsync delegates to replayer, passes filter)
+- **Notes**:
+ - ControlBusPublisher.PublishCommandAsync sets MessageIntent.Command on the envelope
+ - NSubstitute When..Do pattern used to capture generic envelope argument
+ - All 12 tests pass. UnitTests total: 1919 (cumulative with chunks 360–361)
+
+## Chunk 361 — LokiObservabilityEventLog Tests
+
+- **Date**: 2026-04-09
+- **Phase**: 36 — Auth, Observability & System Management Test Hardening
+- **Status**: done
+- **Goal**: Dedicated unit tests for LokiObservabilityEventLog covering RecordAsync (Loki push + in-memory fallback), GetByCorrelationId (Loki query + fallback), GetByBusinessKey (fallback + case-insensitive matching).
+- **Files created**:
+ - `tests/UnitTests/LokiObservabilityEventLogTests.cs` — 11 tests: RecordAsync (posts to push endpoint, Loki unavailable doesn't throw, always stores in fallback, correct content type, payload contains correlation_id, multiple events stored), GetByCorrelationId (Loki returns results, no matching returns empty), GetByBusinessKey (Loki unavailable uses fallback, case-insensitive fallback)
+- **Notes**:
+ - Uses MockLokiHandler (DelegatingHandler) for HTTP interception with separate push/query status codes
+ - Uses reflection to clear the static FallbackStore between tests for isolation
+ - Loki query_range response format: values are [timestamp_string, json_string] — log line is a JSON-escaped string
+ - All 11 tests pass
+
+## Chunk 360 — ApiKeyAuthenticationHandler Tests
+
+- **Date**: 2026-04-09
+- **Phase**: 36 — Auth, Observability & System Management Test Hardening
+- **Status**: done
+- **Goal**: Dedicated unit tests for the security-critical ApiKeyAuthenticationHandler covering all authentication paths.
+- **Files created/modified**:
+ - `tests/UnitTests/ApiKeyAuthenticationHandlerTests.cs` — 10 tests: missing header fails, invalid key fails, valid key succeeds, valid key sets Admin role claim, sets Name claim, sets apikey_prefix claim with masking, multiple configured keys accepts any, case-sensitive comparison rejects wrong case, empty configured keys rejects all, short key masks entirely
+ - `src/Admin.Api/Admin.Api.csproj` — added ``
+ - `tests/UnitTests/UnitTests.csproj` — added ``
+- **Notes**:
+ - ApiKeyAuthenticationHandler is `internal sealed` — InternalsVisibleTo enables direct test instantiation
+ - Tests use `AuthenticationHandler.InitializeAsync` + `AuthenticateAsync` pattern with `DefaultHttpContext`
+ - MaskKey masks keys longer than 4 chars to `first4****`, short keys to `****`
+ - All 10 tests pass
+
+## Chunk 352 — HttpEnrichmentSource & DatabaseEnrichmentSource Tests
+
+- **Date**: 2026-04-09
+- **Phase**: 35 — Observability, Validation & Enrichment Test Hardening
+- **Status**: done
+- **Goal**: Dedicated unit tests for HttpEnrichmentSource (6 tests) and DatabaseEnrichmentSource (6 tests) covering constructor validation, successful enrichment, URL key substitution, HTTP error handling, empty DB results, and column-to-JSON mapping.
+- **Files created**:
+ - `tests/UnitTests/HttpDatabaseEnrichmentSourceTests.cs` — 12 tests: HttpEnrichmentSource (constructor null factory/options/logger, FetchAsync success returns JsonNode, FetchAsync replaces key placeholder, FetchAsync non-success throws), DatabaseEnrichmentSource (constructor null factory, empty SQL, empty param, null logger, FetchAsync no rows returns null, FetchAsync with row returns JsonObject)
+- **Notes**:
+ - HttpEnrichmentSource tests use a lightweight MockHandler (DelegatingHandler subclass) for HTTP interception
+ - DatabaseEnrichmentSource tests use concrete ADO.NET test doubles (InMemoryDbConnection/Command/Reader) since DbCommand.Parameters is non-virtual
+ - All 12 tests pass. UnitTests total: 1886 (cumulative with chunks 350–351)
+
+## Chunk 351 — Activity Service & Message Validation Tests
+
+- **Date**: 2026-04-09
+- **Phase**: 35 — Observability, Validation & Enrichment Test Hardening
+- **Status**: done
+- **Goal**: Dedicated unit tests for DefaultMessageValidationService (8 tests), DefaultCompensationActivityService (2 tests), MessageValidationResult factory methods (2 tests), and MessageHistoryEntry record (2 tests).
+- **Files created**:
+ - `tests/UnitTests/ActivityServiceTests.cs` — 14 tests across 4 fixtures: DefaultMessageValidationServiceTests (empty/whitespace type, empty/whitespace payload, non-JSON, valid JSON object, valid JSON array, whitespace-prefixed JSON), DefaultCompensationActivityServiceTests (valid input returns true, any step returns true), MessageValidationResultTests (Success/Failure factories), MessageHistoryEntryTests (constructor properties, enum values)
+- **Notes**:
+ - DefaultMessageValidationService validates: non-empty message type, non-empty payload, payload starts with `{` or `[`
+ - DefaultCompensationActivityService always returns true (logs compensation; real implementations override)
+ - All 14 tests pass
+
+## Chunk 350 — CorrelationPropagator, PlatformMeters & MessageTracer Tests
+
+- **Date**: 2026-04-09
+- **Phase**: 35 — Observability, Validation & Enrichment Test Hardening
+- **Status**: done
+- **Goal**: Dedicated unit tests for CorrelationPropagator (6 tests), PlatformMeters (5 tests), and MessageTracer (6 tests) covering trace context injection/extraction, metric recording via MeterListener, and message lifecycle tracing.
+- **Files created**:
+ - `tests/UnitTests/CorrelationPropagatorTests.cs` — 17 tests across 3 fixtures: CorrelationPropagatorTests (inject without Activity, inject with Activity sets TraceId/SpanId, extract with valid headers, extract without headers, extract default kind, extract explicit kind), PlatformMetersTests (RecordReceived, RecordProcessed, RecordFailed, RecordDeadLettered, RecordRetry via MeterListener), MessageTracerTests (TraceIngestion/Routing/Transformation/Delivery, CompleteSuccess sets Ok status, CompleteFailed sets Error status)
+- **Notes**:
+ - CorrelationPropagator tests use ActivityListener to capture Activities from DiagnosticsConfig.ActivitySource
+ - PlatformMeters tests use System.Diagnostics.Metrics.MeterListener to verify counter/histogram/gauge recordings
+ - MessageTracer tests verify that trace stages create properly tagged Activity spans
+ - All 17 tests pass
+
## Chunk 344 — AI & Remaining DI Tests
- **Date**: 2026-04-09
diff --git a/EnterpriseIntegrationPlatform/rules/milestones.md b/EnterpriseIntegrationPlatform/rules/milestones.md
index a73b016..b647ed8 100644
--- a/EnterpriseIntegrationPlatform/rules/milestones.md
+++ b/EnterpriseIntegrationPlatform/rules/milestones.md
@@ -248,3 +248,62 @@ Tests verify every `Add*` method registers the correct service types in the cont
### Next Chunk
Phase 34 is complete. No remaining chunks.
+
+---
+
+## Phase 35 — Observability, Validation & Enrichment Test Hardening
+
+> **Origin:** Audit revealed that CorrelationPropagator, PlatformMeters, and MessageTracer
+> (core observability infrastructure used by every service) had **zero dedicated unit tests**.
+> DefaultMessageValidationService and DefaultCompensationActivityService (core activity services)
+> were also untested. HttpEnrichmentSource and DatabaseEnrichmentSource (external enrichment
+> patterns) lacked tests. This phase closes these test gaps.
+
+| Chunk | Description | Status |
+|-------|-------------|--------|
+| 350 | **CorrelationPropagator, PlatformMeters & MessageTracer Tests** — see `rules/completion-log.md` | `done` |
+| 351 | **Activity Service & Message Validation Tests** — see `rules/completion-log.md` | `done` |
+| 352 | **HttpEnrichmentSource & DatabaseEnrichmentSource Tests** — see `rules/completion-log.md` | `done` |
+
+### Summary
+
+Phase 35 complete — 3 chunks (350–352). 43 new unit tests. UnitTests total: 1886 (was 1843).
+CorrelationPropagator now has 6 tests covering trace context injection/extraction.
+PlatformMeters has 5 tests verifying counter/histogram/gauge recordings via MeterListener.
+MessageTracer has 6 tests covering all 4 trace stage methods plus success/failure completion.
+DefaultMessageValidationService has 8 tests covering all validation paths.
+DefaultCompensationActivityService has 2 tests. MessageValidationResult factory methods tested.
+MessageHistoryEntry record and enum values tested. HttpEnrichmentSource has 6 tests with mock
+HTTP handler. DatabaseEnrichmentSource has 6 tests with mock ADO.NET infrastructure.
+
+---
+
+## Phase 36 — Auth, Observability & System Management Test Hardening
+
+> **Origin:** Audit revealed that `ApiKeyAuthenticationHandler` (security-critical auth handler)
+> had **zero unit tests**. `LokiObservabilityEventLog` (core observability event storage with
+> Loki HTTP API + in-memory fallback) was untested. `ControlBusPublisher` (EIP Control Bus pattern)
+> and `DlqManagementService` (DLQ resubmission) lacked dedicated test fixtures. This phase
+> closes these critical test gaps.
+
+| Chunk | Description | Status |
+|-------|-------------|--------|
+| 360 | **ApiKeyAuthenticationHandler Tests** — see `rules/completion-log.md` | `done` |
+| 361 | **LokiObservabilityEventLog Tests** — see `rules/completion-log.md` | `done` |
+| 362 | **ControlBusPublisher & DlqManagementService Tests** — see `rules/completion-log.md` | `done` |
+
+### Summary
+
+Phase 36 complete — 3 chunks (360–362). 33 new unit tests. UnitTests total: 1919 (was 1886).
+ApiKeyAuthenticationHandler now has 10 tests covering missing header, invalid key, valid key
+with claim verification, case-sensitive comparison, multiple keys, empty config, short key masking.
+LokiObservabilityEventLog has 11 tests covering RecordAsync (push, fallback, error handling),
+GetByCorrelationId (Loki results, fallback, empty), GetByBusinessKey (fallback, case-insensitive).
+ControlBusPublisher has 10 tests covering publish success/failure/intent, subscribe, arg validation.
+DlqManagementService has 2 tests covering replay delegation and filter passthrough.
+
+---
+
+### Next Chunk
+
+Phase 36 is complete. No remaining chunks.
diff --git a/EnterpriseIntegrationPlatform/src/Admin.Api/Admin.Api.csproj b/EnterpriseIntegrationPlatform/src/Admin.Api/Admin.Api.csproj
index 688e18e..63307ba 100644
--- a/EnterpriseIntegrationPlatform/src/Admin.Api/Admin.Api.csproj
+++ b/EnterpriseIntegrationPlatform/src/Admin.Api/Admin.Api.csproj
@@ -1,4 +1,7 @@
+
+
+
diff --git a/EnterpriseIntegrationPlatform/tests/UnitTests/ActivityServiceTests.cs b/EnterpriseIntegrationPlatform/tests/UnitTests/ActivityServiceTests.cs
new file mode 100644
index 0000000..18836d5
--- /dev/null
+++ b/EnterpriseIntegrationPlatform/tests/UnitTests/ActivityServiceTests.cs
@@ -0,0 +1,168 @@
+using EnterpriseIntegrationPlatform.Activities;
+using EnterpriseIntegrationPlatform.Contracts;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using NUnit.Framework;
+
+namespace EnterpriseIntegrationPlatform.Tests.Unit;
+
+[TestFixture]
+public sealed class DefaultMessageValidationServiceTests
+{
+ private DefaultMessageValidationService _sut = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _sut = new DefaultMessageValidationService();
+ }
+
+ [Test]
+ public async Task ValidateAsync_EmptyMessageType_ReturnsFailure()
+ {
+ var result = await _sut.ValidateAsync("", """{"id":1}""");
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Reason, Is.EqualTo("Message type must not be empty."));
+ }
+
+ [Test]
+ public async Task ValidateAsync_WhitespaceMessageType_ReturnsFailure()
+ {
+ var result = await _sut.ValidateAsync(" ", """{"id":1}""");
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Reason, Is.EqualTo("Message type must not be empty."));
+ }
+
+ [Test]
+ public async Task ValidateAsync_EmptyPayload_ReturnsFailure()
+ {
+ var result = await _sut.ValidateAsync("OrderCreated", "");
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Reason, Is.EqualTo("Payload must not be empty."));
+ }
+
+ [Test]
+ public async Task ValidateAsync_WhitespacePayload_ReturnsFailure()
+ {
+ var result = await _sut.ValidateAsync("OrderCreated", " ");
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Reason, Is.EqualTo("Payload must not be empty."));
+ }
+
+ [Test]
+ public async Task ValidateAsync_NonJsonPayload_ReturnsFailure()
+ {
+ var result = await _sut.ValidateAsync("OrderCreated", "plain text");
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Reason, Is.EqualTo("Payload is not valid JSON."));
+ }
+
+ [Test]
+ public async Task ValidateAsync_ValidJsonObject_ReturnsSuccess()
+ {
+ var result = await _sut.ValidateAsync("OrderCreated", """{"id":1}""");
+
+ Assert.That(result.IsValid, Is.True);
+ Assert.That(result.Reason, Is.Null);
+ }
+
+ [Test]
+ public async Task ValidateAsync_ValidJsonArray_ReturnsSuccess()
+ {
+ var result = await _sut.ValidateAsync("OrderCreated", "[1,2,3]");
+
+ Assert.That(result.IsValid, Is.True);
+ Assert.That(result.Reason, Is.Null);
+ }
+
+ [Test]
+ public async Task ValidateAsync_WhitespaceBeforeJson_ReturnsSuccess()
+ {
+ var result = await _sut.ValidateAsync("OrderCreated", """ {"id":1}""");
+
+ Assert.That(result.IsValid, Is.True);
+ Assert.That(result.Reason, Is.Null);
+ }
+}
+
+[TestFixture]
+public sealed class DefaultCompensationActivityServiceTests
+{
+ private DefaultCompensationActivityService _sut = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ var logger = NullLogger.Instance;
+ _sut = new DefaultCompensationActivityService(logger);
+ }
+
+ [Test]
+ public async Task CompensateAsync_ValidInput_ReturnsTrue()
+ {
+ var result = await _sut.CompensateAsync(Guid.NewGuid(), "DebitAccount");
+
+ Assert.That(result, Is.True);
+ }
+
+ [Test]
+ public async Task CompensateAsync_AnyStepName_ReturnsTrue()
+ {
+ var result = await _sut.CompensateAsync(Guid.NewGuid(), "AnyArbitraryStep");
+
+ Assert.That(result, Is.True);
+ }
+}
+
+[TestFixture]
+public sealed class MessageValidationResultTests
+{
+ [Test]
+ public void Success_IsValid_ReturnsTrue()
+ {
+ var result = MessageValidationResult.Success;
+
+ Assert.That(result.IsValid, Is.True);
+ Assert.That(result.Reason, Is.Null);
+ }
+
+ [Test]
+ public void Failure_IsNotValid_ReturnsFalseWithReason()
+ {
+ var result = MessageValidationResult.Failure("Something went wrong.");
+
+ Assert.That(result.IsValid, Is.False);
+ Assert.That(result.Reason, Is.EqualTo("Something went wrong."));
+ }
+}
+
+[TestFixture]
+public sealed class MessageHistoryEntryTests
+{
+ [Test]
+ public void Constructor_SetsProperties()
+ {
+ var timestamp = DateTimeOffset.UtcNow;
+ var entry = new MessageHistoryEntry("Validate", timestamp, MessageHistoryStatus.Completed, "All good");
+
+ Assert.That(entry.ActivityName, Is.EqualTo("Validate"));
+ Assert.That(entry.Timestamp, Is.EqualTo(timestamp));
+ Assert.That(entry.Status, Is.EqualTo(MessageHistoryStatus.Completed));
+ Assert.That(entry.Detail, Is.EqualTo("All good"));
+ }
+
+ [Test]
+ public void Status_Enum_HasExpectedValues()
+ {
+ Assert.That((int)MessageHistoryStatus.Completed, Is.EqualTo(0));
+ Assert.That((int)MessageHistoryStatus.Skipped, Is.EqualTo(1));
+ Assert.That((int)MessageHistoryStatus.Failed, Is.EqualTo(2));
+ Assert.That((int)MessageHistoryStatus.InProgress, Is.EqualTo(3));
+ }
+}
diff --git a/EnterpriseIntegrationPlatform/tests/UnitTests/ApiKeyAuthenticationHandlerTests.cs b/EnterpriseIntegrationPlatform/tests/UnitTests/ApiKeyAuthenticationHandlerTests.cs
new file mode 100644
index 0000000..29c0f82
--- /dev/null
+++ b/EnterpriseIntegrationPlatform/tests/UnitTests/ApiKeyAuthenticationHandlerTests.cs
@@ -0,0 +1,197 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using EnterpriseIntegrationPlatform.Admin.Api;
+using EnterpriseIntegrationPlatform.Admin.Api.Authentication;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NUnit.Framework;
+
+namespace EnterpriseIntegrationPlatform.Tests.Unit;
+
+[TestFixture]
+public class ApiKeyAuthenticationHandlerTests
+{
+ private const string ValidKey = "super-secret-key-12345";
+ private const string ShortKey = "abc";
+
+ private ApiKeyAuthenticationHandler CreateHandler(
+ HttpContext context,
+ IReadOnlyList? configuredKeys = null)
+ {
+ configuredKeys ??= [ValidKey];
+
+ var adminOptions = Options.Create(new AdminApiOptions
+ {
+ ApiKeys = configuredKeys,
+ });
+
+ var optionsMonitor = new TestOptionsMonitor();
+ var loggerFactory = NullLoggerFactory.Instance;
+
+ var handler = new ApiKeyAuthenticationHandler(
+ optionsMonitor,
+ loggerFactory,
+ UrlEncoder.Default,
+ adminOptions);
+
+ // Initialize the handler with the scheme and context
+ handler.InitializeAsync(
+ new AuthenticationScheme(
+ ApiKeyAuthenticationHandler.SchemeName,
+ displayName: null,
+ typeof(ApiKeyAuthenticationHandler)),
+ context).GetAwaiter().GetResult();
+
+ return handler;
+ }
+
+ private static DefaultHttpContext CreateHttpContext(string? apiKey = null)
+ {
+ var context = new DefaultHttpContext();
+ if (apiKey is not null)
+ {
+ context.Request.Headers["X-Api-Key"] = apiKey;
+ }
+ return context;
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_MissingHeader_ReturnsFail()
+ {
+ var context = CreateHttpContext(apiKey: null);
+ var handler = CreateHandler(context);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.False);
+ Assert.That(result.Failure?.Message, Does.Contain("Missing"));
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_InvalidKey_ReturnsFail()
+ {
+ var context = CreateHttpContext("wrong-key");
+ var handler = CreateHandler(context);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.False);
+ Assert.That(result.Failure?.Message, Does.Contain("Invalid"));
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_ValidKey_ReturnsSuccess()
+ {
+ var context = CreateHttpContext(ValidKey);
+ var handler = CreateHandler(context);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.True);
+ Assert.That(result.Ticket, Is.Not.Null);
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_ValidKey_SetsAdminRole()
+ {
+ var context = CreateHttpContext(ValidKey);
+ var handler = CreateHandler(context);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.True);
+ var roleClaim = result.Principal!.FindFirst(ClaimTypes.Role);
+ Assert.That(roleClaim, Is.Not.Null);
+ Assert.That(roleClaim!.Value, Is.EqualTo("Admin"));
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_ValidKey_SetsNameClaim()
+ {
+ var context = CreateHttpContext(ValidKey);
+ var handler = CreateHandler(context);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.True);
+ var nameClaim = result.Principal!.FindFirst(ClaimTypes.Name);
+ Assert.That(nameClaim, Is.Not.Null);
+ Assert.That(nameClaim!.Value, Is.EqualTo("admin"));
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_ValidKey_SetsApiKeyPrefixClaim()
+ {
+ var context = CreateHttpContext(ValidKey);
+ var handler = CreateHandler(context);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.True);
+ var prefixClaim = result.Principal!.FindFirst("apikey_prefix");
+ Assert.That(prefixClaim, Is.Not.Null);
+ // Key "super-secret-key-12345" → first 4 chars = "supe" + "****"
+ Assert.That(prefixClaim!.Value, Is.EqualTo("supe****"));
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_MultipleConfiguredKeys_AcceptsAny()
+ {
+ var context = CreateHttpContext("key-beta-67890");
+ var handler = CreateHandler(context,
+ configuredKeys: ["key-alpha-12345", "key-beta-67890"]);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.True);
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_CaseSensitive_RejectsWrongCase()
+ {
+ var context = CreateHttpContext(ValidKey.ToUpperInvariant());
+ var handler = CreateHandler(context);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.False);
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_EmptyConfiguredKeys_RejectsAll()
+ {
+ var context = CreateHttpContext(ValidKey);
+ var handler = CreateHandler(context, configuredKeys: []);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.False);
+ }
+
+ [Test]
+ public async Task HandleAuthenticate_ShortKey_MasksEntirely()
+ {
+ var context = CreateHttpContext(ShortKey);
+ var handler = CreateHandler(context, configuredKeys: [ShortKey]);
+
+ var result = await handler.AuthenticateAsync();
+
+ Assert.That(result.Succeeded, Is.True);
+ var prefixClaim = result.Principal!.FindFirst("apikey_prefix");
+ Assert.That(prefixClaim, Is.Not.Null);
+ Assert.That(prefixClaim!.Value, Is.EqualTo("****"));
+ }
+
+ ///
+ /// Minimal IOptionsMonitor implementation for AuthenticationSchemeOptions.
+ ///
+ private sealed class TestOptionsMonitor : IOptionsMonitor
+ {
+ public AuthenticationSchemeOptions CurrentValue { get; } = new();
+ public AuthenticationSchemeOptions Get(string? name) => CurrentValue;
+ public IDisposable? OnChange(Action listener) => null;
+ }
+}
diff --git a/EnterpriseIntegrationPlatform/tests/UnitTests/ControlBusPublisherTests.cs b/EnterpriseIntegrationPlatform/tests/UnitTests/ControlBusPublisherTests.cs
new file mode 100644
index 0000000..be2ddba
--- /dev/null
+++ b/EnterpriseIntegrationPlatform/tests/UnitTests/ControlBusPublisherTests.cs
@@ -0,0 +1,252 @@
+using EnterpriseIntegrationPlatform.Admin.Api.Services;
+using EnterpriseIntegrationPlatform.Contracts;
+using EnterpriseIntegrationPlatform.Ingestion;
+using EnterpriseIntegrationPlatform.Processing.Replay;
+using EnterpriseIntegrationPlatform.SystemManagement;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+using NUnit.Framework;
+
+namespace EnterpriseIntegrationPlatform.Tests.Unit;
+
+[TestFixture]
+public class ControlBusPublisherTests
+{
+ private IMessageBrokerProducer _producer = null!;
+ private IMessageBrokerConsumer _consumer = null!;
+ private ControlBusOptions _options = null!;
+ private ControlBusPublisher _sut = null!;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _producer = Substitute.For();
+ _consumer = Substitute.For();
+ _options = new ControlBusOptions
+ {
+ ControlTopic = "test.control",
+ ConsumerGroup = "test-consumers",
+ Source = "UnitTest",
+ };
+ _sut = new ControlBusPublisher(
+ _producer,
+ _consumer,
+ Options.Create(_options),
+ NullLogger.Instance);
+ }
+
+ [TearDown]
+ public async Task TearDown()
+ {
+ await _consumer.DisposeAsync();
+ }
+
+ // ── PublishCommandAsync tests ────────────────────────────────────────
+
+ [Test]
+ public async Task PublishCommandAsync_Success_ReturnsSucceededTrue()
+ {
+ var result = await _sut.PublishCommandAsync(
+ new { Action = "restart" },
+ "RestartService");
+
+ Assert.That(result.Succeeded, Is.True);
+ Assert.That(result.ControlTopic, Is.EqualTo("test.control"));
+ Assert.That(result.FailureReason, Is.Null);
+ }
+
+ [Test]
+ public async Task PublishCommandAsync_Success_PublishesToControlTopic()
+ {
+ await _sut.PublishCommandAsync(
+ new { Action = "restart" },
+ "RestartService");
+
+ await _producer.Received(1).PublishAsync(
+ Arg.Any>(),
+ Arg.Is("test.control"),
+ Arg.Any());
+ }
+
+ [Test]
+ public async Task PublishCommandAsync_Success_SetsCommandIntent()
+ {
+ // Capture the envelope via When..Do pattern on the generic method
+ MessageIntent? capturedIntent = null;
+ string? capturedMessageType = null;
+ string? capturedSource = null;
+
+ _producer.When(x => x.PublishAsync(
+ Arg.Any>(),
+ Arg.Any(),
+ Arg.Any()))
+ .Do(call =>
+ {
+ var env = call.Arg>();
+ capturedIntent = env.Intent;
+ capturedMessageType = env.MessageType;
+ capturedSource = env.Source;
+ });
+
+ await _sut.PublishCommandAsync
+
+
+