From 530dd84ad1386c9ef69ed5e3d256f9f5f00b789f Mon Sep 17 00:00:00 2001 From: Tim Peel Date: Fri, 11 Sep 2026 17:01:02 +0100 Subject: [PATCH 1/3] MO-523 align analytics event envelope --- ...analytics-compliance-declaration-events.md | 50 +++++---- ...iance-declaration-end-to-end-event-flow.md | 13 ++- .../EmbeddedEntityJsonSchemaProvider.cs | 18 ++- .../Services/ComplianceDeclarationService.cs | 3 +- src/AuditEvents/Analytics/AnalyticsEvent.cs | 10 +- .../Analytics/AnalyticsEventMappers.cs | 17 ++- .../Analytics/AnalyticsEventVocabulary.cs | 88 +++++++++++++++ ...cMillisecondDateTimeOffsetJsonConverter.cs | 22 ++++ .../AnalyticsAuditEventProcessorTests.cs | 7 +- .../Analytics/SnsAnalyticsEventSenderTests.cs | 15 ++- .../CreateComplianceDeclarationTests.cs | 2 +- .../DeleteComplianceDeclarationTests.cs | 4 +- .../UpdateComplianceDeclarationTests.cs | 6 +- .../ComplianceDeclarationServiceTests.cs | 2 +- .../Analytics/AnalyticsEventMappersTests.cs | 105 +++++++++++++++++- ...Before_ShouldSerializeAsJson.verified.json | 8 +- ...hAfter_ShouldSerializeAsJson.verified.json | 8 +- ...dAfter_ShouldSerializeAsJson.verified.json | 6 +- .../JsonAnalyticsEventSerializerTests.cs | 29 ++++- .../Metrics/AuditEventMetricsTests.cs | 2 +- .../EmbeddedEntityJsonSchemaProviderTests.cs | 10 ++ .../Entities/AnalyticsEventFixture.cs | 9 +- 22 files changed, 360 insertions(+), 74 deletions(-) create mode 100644 src/AuditEvents/Analytics/AnalyticsEventVocabulary.cs create mode 100644 src/AuditEvents/Analytics/UtcMillisecondDateTimeOffsetJsonConverter.cs diff --git a/docs/analytics-compliance-declaration-events.md b/docs/analytics-compliance-declaration-events.md index 7b6a6d47..28be44b8 100644 --- a/docs/analytics-compliance-declaration-events.md +++ b/docs/analytics-compliance-declaration-events.md @@ -4,7 +4,7 @@ The analytics topic receives generic analytics event envelopes. Each envelope id Compliance declaration create, update, and delete operations are captured internally in the same transaction as the declaration change. The analytics processor reads undispatched changes, serialises them as analytics events, and publishes them to the analytics SNS topic configured by `AnalyticsAuditEventProcessor:TopicArn`. -The nested compliance declaration payload is serialised using the embedded [compliance declaration schema](../src/Api/Schemas/ComplianceDeclaration/compliance-declaration.v1.3.schema.json). Its version history is recorded in the [compliance declaration schema changelog](../src/Api/Schemas/ComplianceDeclaration/CHANGELOG.md). For compliance declarations, the analytics message `schemaVersion` is currently `compliance_declaration.v1.3`. +The nested compliance declaration payload is serialised using the embedded [compliance declaration schema](../src/Api/Schemas/ComplianceDeclaration/compliance-declaration.v1.3.schema.json). Its version history is recorded in the [compliance declaration schema changelog](../src/Api/Schemas/ComplianceDeclaration/CHANGELOG.md). For compliance declarations, the analytics message `schemaVersion` is currently `compliance_declaration_v1.3`. ## Message transport @@ -22,17 +22,18 @@ All analytics events use this generic envelope: "sequence": 123, "entity": "entity_name", "entityId": "entity_name_entity-id", - "operation": "insert", + "operation": "create", "eventType": "domain.event", "deletedReason": null, "piiKeyRef": null, - "occurredAt": "2026-01-02T03:04:05+00:00", - "recordedAt": "2026-01-02T03:04:06+00:00", + "occurredAt": "2026-01-02T03:04:05.000Z", + "recordedAt": "2026-01-02T03:04:06.000Z", "actor": "service:waste-obligations", + "correlationId": "cdp-request-id", "version": 1, "before": null, "after": {}, - "schemaVersion": "entity_name.v1.0" + "schemaVersion": "entity_name_v1.0" } ``` @@ -45,12 +46,13 @@ All analytics events use this generic envelope: | `operation` | Event operation. See the operation values below. | | `eventType` | Business event name. | | `deletedReason` | Reason the entity was deleted. This is only set when `operation` is `delete`; otherwise it is `null`. | -| `piiKeyRef` | Currently always `null`. | -| `occurredAt` | Time the entity change occurred, in ISO 8601 format with offset. | -| `recordedAt` | Time the analytics event was recorded, in ISO 8601 format with offset. | +| `piiKeyRef` | Always `null`; PII classification and protection are outside the scope of this change. | +| `occurredAt` | Time the entity change occurred, as a UTC ISO 8601 timestamp with millisecond precision. | +| `recordedAt` | Time the analytics event was recorded, as a UTC ISO 8601 timestamp with millisecond precision. | | `actor` | Service actor that wrote the event. | +| `correlationId` | The incoming `x-cdp-request-id` when present; omitted when no request ID is available. | | `version` | Entity version after the operation. | -| `before` | Previous entity state. This is `null` for inserts. | +| `before` | Previous entity state. This is `null` for creates. | | `after` | New entity state. This is `null` for deletes. | | `schemaVersion` | Entity-qualified schema version for the `before` and `after` payloads. | @@ -58,25 +60,31 @@ All analytics events use this generic envelope: | Operation | Meaning | `before` | `after` | `deletedReason` | | --- | --- | --- | --- | --- | -| `insert` | Entity was created. | `null` | Created entity state. | `null` | +| `create` | Entity was created. | `null` | Created entity state. | `null` | | `update` | Entity was changed. | Previous entity state. | Updated entity state. | `null` | | `delete` | Entity was deleted. | Previous entity state. | `null` | Delete reason. | +## Controlled vocabulary + +The publisher validates the current compliance declaration entity, envelope operation, event type, actor prefix, deletion reason, and schema version before serialising a message. The supported operations are `create`, `update`, and `delete`; actor prefixes are `service`, `user`, `system`, and `integration`; and the supported declaration event types are `submission.created`, `submission.amended`, and `submission.removed`. + +The only current deletion reason is `elevated_system_allowed_removal`. This replaces the legacy free-text value `elevated system allowed removal`, which remains normalised for undispatched historical outbox events. The governed list maintained with the shared event schema must include `elevated_system_allowed_removal` before additional deletion paths are introduced. + ## Compliance declaration events -Compliance declaration events use `entity` set to `compliance_declaration`. The `entityId` value is prefixed with the entity type, for example `compliance_declaration_65f1f6570bb08052a8a27b01`. +Compliance declaration events use `entity` set to `compliance_declaration`. The current service identity is a Mongo ObjectId, so `entityId` remains prefixed as `compliance_declaration_65f1f6570bb08052a8a27b01`. The proposed `cdec_` format is not implemented because it is incompatible with the existing immutable identifier model; it requires a corrected specification before it can be adopted. The current compliance declaration event types are: | Event type | Operation | Description | | --- | --- | --- | -| `submission.created` | `insert` | Compliance declaration was created. | +| `submission.created` | `create` | Compliance declaration was created. | | `submission.amended` | `update` | Compliance declaration was updated. | | `submission.removed` | `delete` | Compliance declaration was deleted. | ## Created event -When a compliance declaration is created, the analytics topic receives an `insert` event with `eventType` set to `submission.created`. +When a compliance declaration is created, the analytics topic receives a `create` event with `eventType` set to `submission.created`. The `before` value is `null`. The `after` value is the created compliance declaration, serialised according to the linked compliance declaration schema. @@ -86,13 +94,14 @@ The `before` value is `null`. The `after` value is the created compliance declar "sequence": 123, "entity": "compliance_declaration", "entityId": "compliance_declaration_65f1f6570bb08052a8a27b01", - "operation": "insert", + "operation": "create", "eventType": "submission.created", "deletedReason": null, "piiKeyRef": null, - "occurredAt": "2026-01-02T03:04:05+00:00", - "recordedAt": "2026-01-02T03:04:06+00:00", + "occurredAt": "2026-01-02T03:04:05.000Z", + "recordedAt": "2026-01-02T03:04:06.000Z", "actor": "service:waste-obligations", + "correlationId": "cdp-request-id", "version": 1, "before": null, "after": { @@ -152,7 +161,7 @@ The `before` value is `null`. The `after` value is the created compliance declar "isRegulation43Compliant": true, "obligationCoveragePercentage": 40 }, - "schemaVersion": "compliance_declaration.v1.3" + "schemaVersion": "compliance_declaration_v1.3" } ``` @@ -172,9 +181,10 @@ The `before` value is the declaration state before the update. The `after` value "eventType": "submission.amended", "deletedReason": null, "piiKeyRef": null, - "occurredAt": "2026-01-02T03:05:05+00:00", - "recordedAt": "2026-01-02T03:05:06+00:00", + "occurredAt": "2026-01-02T03:05:05.000Z", + "recordedAt": "2026-01-02T03:05:06.000Z", "actor": "service:waste-obligations", + "correlationId": "cdp-request-id", "version": 2, "before": { "id": "65f1f6570bb08052a8a27b01", @@ -300,6 +310,6 @@ The `before` value is the declaration state before the update. The `after` value "isRegulation43Compliant": true, "obligationCoveragePercentage": 40 }, - "schemaVersion": "compliance_declaration.v1.3" + "schemaVersion": "compliance_declaration_v1.3" } ``` diff --git a/docs/compliance-declaration-end-to-end-event-flow.md b/docs/compliance-declaration-end-to-end-event-flow.md index 2f4638f4..3593d734 100644 --- a/docs/compliance-declaration-end-to-end-event-flow.md +++ b/docs/compliance-declaration-end-to-end-event-flow.md @@ -11,7 +11,7 @@ The service uses two audit concepts: | Action | Endpoint | Declaration change | Audit event operation | Audit event type | Analytics event | | --- | --- | --- | --- | --- | --- | -| Create | `POST /organisations/{organisationId}/compliance-declarations` | Creates a submitted declaration, assigns version `1`, and adds a `Submitted` entry to the embedded `audit` array. | `insert` | `submission.created` | `insert` with `before: null` and `after` set to the created declaration. | +| Create | `POST /organisations/{organisationId}/compliance-declarations` | Creates a submitted declaration, assigns version `1`, and adds a `Submitted` entry to the embedded `audit` array. | `insert` | `submission.created` | `create` with `before: null` and `after` set to the created declaration. | | Update status | `PATCH /organisations/{organisationId}/compliance-declarations/{complianceDeclarationId}` | Applies a valid status transition, increments the version, updates `updated`, and appends an embedded audit entry. | `update` | `submission.amended` | `update` with the previous declaration in `before` and the updated declaration in `after`. | | Delete | `DELETE /compliance-declarations/{id}` | Deletes the declaration using the current version as an optimistic concurrency check. | `delete` | `submission.removed` | `delete` with the previous declaration in `before`, `after: null`, and `deletedReason` set. | @@ -114,7 +114,7 @@ flowchart TD | `before` | The previous declaration BSON document, or `null` for create. | | `after` | The new declaration BSON document, or `null` for delete. | | `schemaVersion` | The declaration schema version, currently `v1.3`. | -| `traceId` | The propagated trace header value, used for service logging and not included in the analytics envelope. | +| `traceId` | The propagated `x-cdp-request-id`, used for service logging and mapped to the analytics envelope's `correlationId` when available. | | `dispatches` | A per-process outcome map, initially empty. | The `AuditEvent` collection is indexed by sequence, entity/entity id/version, and the analytics dispatch fields so the dispatcher can read the oldest undispatched or retryable events efficiently. @@ -184,10 +184,13 @@ sequenceDiagram The processor reads audit events where `dispatches.analytics` does not exist, or where it is `Failed` and `nextAttemptAt` is due. Each event is mapped to the analytics envelope: -- `eventId`, `sequence`, `entity`, `operation`, `eventType`, timestamps, actor, version, `before`, and `after` are copied from the audit event. +- `eventId`, `sequence`, `entity`, `eventType`, timestamps, actor, version, `before`, and `after` are copied from the audit event. - `entityId` is changed from the raw ObjectId string to `compliance_declaration_{objectId}`. -- `schemaVersion` is changed from `v1.3` to `compliance_declaration.v1.3`. -- `piiKeyRef` is currently set to `null`. +- `insert` is mapped to the envelope operation `create`; `update` and `delete` are unchanged. +- `schemaVersion` is changed from `v1.3` to `compliance_declaration_v1.3`. +- `traceId` is mapped to `correlationId` when the originating request supplied `x-cdp-request-id`. +- `piiKeyRef` is set to `null` under the instructed PII scope exclusion. +- The emitted entity, operation, event type, actor prefix, deletion reason, and schema version are validated against the current governed vocabulary before publishing. The serializer loads the embedded compliance declaration JSON schema and uses it to write the `before` and `after` BSON documents with the expected field names and JSON value formats. diff --git a/src/Api/Schemas/EmbeddedEntityJsonSchemaProvider.cs b/src/Api/Schemas/EmbeddedEntityJsonSchemaProvider.cs index 2973a056..cf2dcbec 100644 --- a/src/Api/Schemas/EmbeddedEntityJsonSchemaProvider.cs +++ b/src/Api/Schemas/EmbeddedEntityJsonSchemaProvider.cs @@ -50,14 +50,24 @@ private string FindResourceName(string schemaFileName) private static string GetSchemaFileName(string entity, string schemaVersionValue) { - var schemaVersionPrefix = $"{entity}."; - var schemaVersion = schemaVersionValue.StartsWith(schemaVersionPrefix, StringComparison.Ordinal) - ? schemaVersionValue[schemaVersionPrefix.Length..] - : schemaVersionValue; + var schemaVersion = GetSchemaVersion(entity, schemaVersionValue); return $"{ToKebabCase(entity)}.{schemaVersion}{SchemaFileSuffix}"; } + private static string GetSchemaVersion(string entity, string schemaVersionValue) + { + var dotPrefix = $"{entity}."; + if (schemaVersionValue.StartsWith(dotPrefix, StringComparison.Ordinal)) + return schemaVersionValue[dotPrefix.Length..]; + + var underscorePrefix = $"{entity}_"; + + return schemaVersionValue.StartsWith(underscorePrefix, StringComparison.Ordinal) + ? schemaVersionValue[underscorePrefix.Length..] + : schemaVersionValue; + } + private static string ToKebabCase(string value) { var builder = new StringBuilder(value.Length); diff --git a/src/Api/Services/ComplianceDeclarationService.cs b/src/Api/Services/ComplianceDeclarationService.cs index 9ac8f68d..03aa68af 100644 --- a/src/Api/Services/ComplianceDeclarationService.cs +++ b/src/Api/Services/ComplianceDeclarationService.cs @@ -4,6 +4,7 @@ using Defra.WasteObligations.Api.Utils.Logging; using Defra.WasteObligations.Api.Utils.Metrics; using Defra.WasteObligations.AuditEvents; +using Defra.WasteObligations.AuditEvents.Analytics; using MongoDB.Bson; using MongoDB.Driver; @@ -155,7 +156,7 @@ await auditEventService.RecordEvent( ComplianceDeclarationEntity, AuditEventOperation.Delete, "submission.removed", - "elevated system allowed removal", + AnalyticsEventVocabulary.ElevatedSystemAllowedRemoval, current.Id.ToString(), current.Version + 1, current.ToBsonDocument(), diff --git a/src/AuditEvents/Analytics/AnalyticsEvent.cs b/src/AuditEvents/Analytics/AnalyticsEvent.cs index 59fe4d28..2df9955a 100644 --- a/src/AuditEvents/Analytics/AnalyticsEvent.cs +++ b/src/AuditEvents/Analytics/AnalyticsEvent.cs @@ -29,17 +29,23 @@ public record AnalyticsEvent [JsonPropertyName("piiKeyRef")] public string? PiiKeyRef { get; init; } - [Description("ISO 8601 extended format with offset")] + [Description("UTC ISO 8601 format with millisecond precision")] + [JsonConverter(typeof(UtcMillisecondDateTimeOffsetJsonConverter))] [JsonPropertyName("occurredAt")] public DateTimeOffset OccurredAt { get; init; } - [Description("ISO 8601 extended format with offset")] + [Description("UTC ISO 8601 format with millisecond precision")] + [JsonConverter(typeof(UtcMillisecondDateTimeOffsetJsonConverter))] [JsonPropertyName("recordedAt")] public DateTimeOffset RecordedAt { get; init; } [JsonPropertyName("actor")] public required string Actor { get; init; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyName("correlationId")] + public string? CorrelationId { get; init; } + [JsonPropertyName("version")] public int Version { get; init; } diff --git a/src/AuditEvents/Analytics/AnalyticsEventMappers.cs b/src/AuditEvents/Analytics/AnalyticsEventMappers.cs index 5b8dfc97..77cf31b7 100644 --- a/src/AuditEvents/Analytics/AnalyticsEventMappers.cs +++ b/src/AuditEvents/Analytics/AnalyticsEventMappers.cs @@ -4,23 +4,30 @@ namespace Defra.WasteObligations.AuditEvents.Analytics; public static class AnalyticsEventMappers { - public static AnalyticsEvent ToAnalyticsEvent(this AuditEvent auditEvent) => - new() + public static AnalyticsEvent ToAnalyticsEvent(this AuditEvent auditEvent) + { + var analyticsEvent = new AnalyticsEvent { EventId = auditEvent.EventId, Sequence = auditEvent.Sequence, Entity = auditEvent.Entity, EntityId = $"{auditEvent.Entity}_{auditEvent.EntityId}", - Operation = auditEvent.Operation, + Operation = AnalyticsEventVocabulary.ToOperation(auditEvent.Operation), EventType = auditEvent.EventType, - DeletedReason = auditEvent.DeletedReason, + DeletedReason = AnalyticsEventVocabulary.NormalizeDeletedReason(auditEvent.DeletedReason), PiiKeyRef = null, OccurredAt = auditEvent.OccurredAt, RecordedAt = auditEvent.RecordedAt, Actor = auditEvent.Actor, + CorrelationId = auditEvent.TraceId, Version = auditEvent.Version, Before = auditEvent.Before, After = auditEvent.After, - SchemaVersion = $"{auditEvent.Entity}.{auditEvent.SchemaVersion}", + SchemaVersion = $"{auditEvent.Entity}_{auditEvent.SchemaVersion}", }; + + AnalyticsEventVocabulary.Validate(analyticsEvent); + + return analyticsEvent; + } } diff --git a/src/AuditEvents/Analytics/AnalyticsEventVocabulary.cs b/src/AuditEvents/Analytics/AnalyticsEventVocabulary.cs new file mode 100644 index 00000000..ea5e9d09 --- /dev/null +++ b/src/AuditEvents/Analytics/AnalyticsEventVocabulary.cs @@ -0,0 +1,88 @@ +namespace Defra.WasteObligations.AuditEvents.Analytics; + +public static class AnalyticsEventVocabulary +{ + public const string ElevatedSystemAllowedRemoval = "elevated_system_allowed_removal"; + + private const string ComplianceDeclarationEntity = "compliance_declaration"; + private const string CreateOperation = "create"; + private const string UpdateOperation = "update"; + private const string DeleteOperation = "delete"; + private const string LegacyElevatedSystemAllowedRemoval = "elevated system allowed removal"; + + private static readonly HashSet s_eventTypes = + [ + "submission.created", + "submission.amended", + "submission.removed", + ]; + + private static readonly HashSet s_actorPrefixes = ["service", "user", "system", "integration"]; + + private static readonly HashSet s_schemaVersions = + [ + "compliance_declaration_v1.0", + "compliance_declaration_v1.1", + "compliance_declaration_v1.2", + "compliance_declaration_v1.3", + ]; + + public static string ToOperation(string operation) => + operation switch + { + "insert" => CreateOperation, + UpdateOperation => UpdateOperation, + DeleteOperation => DeleteOperation, + _ => throw new InvalidOperationException($"Unregistered analytics operation '{operation}'."), + }; + + public static string? NormalizeDeletedReason(string? deletedReason) => + deletedReason switch + { + LegacyElevatedSystemAllowedRemoval => ElevatedSystemAllowedRemoval, + _ => deletedReason, + }; + + public static void Validate(AnalyticsEvent analyticsEvent) + { + if (analyticsEvent.Entity != ComplianceDeclarationEntity) + throw new InvalidOperationException($"Unregistered analytics entity '{analyticsEvent.Entity}'."); + + if (analyticsEvent.Operation is not (CreateOperation or UpdateOperation or DeleteOperation)) + throw new InvalidOperationException($"Unregistered analytics operation '{analyticsEvent.Operation}'."); + + if (!s_eventTypes.Contains(analyticsEvent.EventType)) + throw new InvalidOperationException($"Unregistered analytics event type '{analyticsEvent.EventType}'."); + + ValidateActor(analyticsEvent.Actor); + + if (!s_schemaVersions.Contains(analyticsEvent.SchemaVersion)) + throw new InvalidOperationException( + $"Unregistered analytics schema version '{analyticsEvent.SchemaVersion}'." + ); + + if (analyticsEvent.Operation == DeleteOperation) + { + if (analyticsEvent.DeletedReason != ElevatedSystemAllowedRemoval) + throw new InvalidOperationException( + $"Unregistered analytics deletion reason '{analyticsEvent.DeletedReason}'." + ); + + return; + } + + if (analyticsEvent.DeletedReason is not null) + throw new InvalidOperationException("An analytics deletion reason is only valid for delete operations."); + } + + private static void ValidateActor(string actor) + { + var separatorIndex = actor.IndexOf(':'); + if ( + separatorIndex <= 0 + || separatorIndex == actor.Length - 1 + || !s_actorPrefixes.Contains(actor[..separatorIndex]) + ) + throw new InvalidOperationException($"Unregistered analytics actor '{actor}'."); + } +} diff --git a/src/AuditEvents/Analytics/UtcMillisecondDateTimeOffsetJsonConverter.cs b/src/AuditEvents/Analytics/UtcMillisecondDateTimeOffsetJsonConverter.cs new file mode 100644 index 00000000..65df53b7 --- /dev/null +++ b/src/AuditEvents/Analytics/UtcMillisecondDateTimeOffsetJsonConverter.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Defra.WasteObligations.AuditEvents.Analytics; + +public sealed class UtcMillisecondDateTimeOffsetJsonConverter : JsonConverter +{ + private const string Format = "yyyy-MM-dd'T'HH:mm:ss.fff'Z'"; + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException($"{nameof(UtcMillisecondDateTimeOffsetJsonConverter)} only supports writing."); + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + var utcValue = value.ToUniversalTime(); + var milliseconds = utcValue.Ticks - utcValue.Ticks % TimeSpan.TicksPerMillisecond; + var utcMilliseconds = new DateTimeOffset(milliseconds, TimeSpan.Zero); + + writer.WriteStringValue(utcMilliseconds.ToString(Format, CultureInfo.InvariantCulture)); + } +} diff --git a/tests/Api.IntegrationTests/AuditEvents/Analytics/AnalyticsAuditEventProcessorTests.cs b/tests/Api.IntegrationTests/AuditEvents/Analytics/AnalyticsAuditEventProcessorTests.cs index 7b378204..ad18641f 100644 --- a/tests/Api.IntegrationTests/AuditEvents/Analytics/AnalyticsAuditEventProcessorTests.cs +++ b/tests/Api.IntegrationTests/AuditEvents/Analytics/AnalyticsAuditEventProcessorTests.cs @@ -388,7 +388,12 @@ private static AuditEvent CreateAuditEvent( string eventId, long sequence, Dictionary? dispatches = null - ) => AuditEventFixture.ComplianceDeclaration(eventId, sequence).With(x => x.Dispatches, dispatches ?? []).Create(); + ) => + AuditEventFixture + .ComplianceDeclaration(eventId, sequence) + .With(x => x.Actor, "service:waste-obligations") + .With(x => x.Dispatches, dispatches ?? []) + .Create(); private sealed class RecordingAnalyticsEventSender : IAnalyticsEventSender { diff --git a/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs b/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs index 1c43902f..31f69f01 100644 --- a/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs +++ b/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs @@ -22,20 +22,21 @@ public async Task WhenAuditEventCreated_ShouldPublishJsonToSubscribedQueue() var complianceDeclaration = await CreateComplianceDeclaration(client); using var deserializedMessage = await ReceiveAnalyticsEventsQueueJsonMessage( sqsClient, - MatchAnalyticsEvent(complianceDeclaration.Id, "insert", "submission.created") + MatchAnalyticsEvent(complianceDeclaration.Id, "create", "submission.created") ); var root = deserializedMessage.RootElement; root.GetProperty("eventId").GetString().Should().NotBeNullOrWhiteSpace(); root.GetProperty("entityId").GetString().Should().Be($"compliance_declaration_{complianceDeclaration.Id}"); - root.GetProperty("operation").GetString().Should().Be("insert"); + root.GetProperty("operation").GetString().Should().Be("create"); root.GetProperty("eventType").GetString().Should().Be("submission.created"); root.GetProperty("deletedReason").ValueKind.Should().Be(JsonValueKind.Null); root.GetProperty("piiKeyRef").ValueKind.Should().Be(JsonValueKind.Null); + root.TryGetProperty("correlationId", out _).Should().BeFalse(); root.GetProperty("schemaVersion") .GetString() .Should() - .Be($"compliance_declaration.{ComplianceDeclarationEntity.SchemaVersionValue}"); + .Be($"compliance_declaration_{ComplianceDeclarationEntity.SchemaVersionValue}"); root.GetProperty("before").ValueKind.Should().Be(JsonValueKind.Null); root.GetProperty("after").GetProperty("id").GetString().Should().Be(complianceDeclaration.Id); } @@ -49,7 +50,7 @@ public async Task WhenAuditEventUpdated_ShouldPublishJsonToSubscribedQueue() var complianceDeclaration = await CreateComplianceDeclaration(client); await ReceiveAnalyticsEventsQueueJsonMessage( sqsClient, - MatchAnalyticsEvent(complianceDeclaration.Id, "insert", "submission.created") + MatchAnalyticsEvent(complianceDeclaration.Id, "create", "submission.created") ); var response = await client.PatchAsJsonAsync( @@ -73,6 +74,7 @@ await ReceiveAnalyticsEventsQueueJsonMessage( root.GetProperty("eventType").GetString().Should().Be("submission.amended"); root.GetProperty("deletedReason").ValueKind.Should().Be(JsonValueKind.Null); root.GetProperty("piiKeyRef").ValueKind.Should().Be(JsonValueKind.Null); + root.GetProperty("correlationId").GetString().Should().Be(TraceId); root.GetProperty("version").GetInt32().Should().Be(2); root.GetProperty("before").GetProperty("status").GetString().Should().Be("Submitted"); root.GetProperty("after").GetProperty("status").GetString().Should().Be("Accepted"); @@ -86,7 +88,7 @@ public async Task WhenAuditEventDeleted_ShouldPublishJsonToSubscribedQueue() var complianceDeclaration = await CreateComplianceDeclaration(client); await ReceiveAnalyticsEventsQueueJsonMessage( sqsClient, - MatchAnalyticsEvent(complianceDeclaration.Id, "insert", "submission.created") + MatchAnalyticsEvent(complianceDeclaration.Id, "create", "submission.created") ); var response = await client.DeleteAsync( @@ -104,8 +106,9 @@ await ReceiveAnalyticsEventsQueueJsonMessage( root.GetProperty("entityId").GetString().Should().Be($"compliance_declaration_{complianceDeclaration.Id}"); root.GetProperty("operation").GetString().Should().Be("delete"); root.GetProperty("eventType").GetString().Should().Be("submission.removed"); - root.GetProperty("deletedReason").GetString().Should().Be("elevated system allowed removal"); + root.GetProperty("deletedReason").GetString().Should().Be("elevated_system_allowed_removal"); root.GetProperty("piiKeyRef").ValueKind.Should().Be(JsonValueKind.Null); + root.TryGetProperty("correlationId", out _).Should().BeFalse(); root.GetProperty("version").GetInt32().Should().Be(2); root.TryGetProperty("traceId", out _).Should().BeFalse(); root.GetProperty("before").GetProperty("id").GetString().Should().Be(complianceDeclaration.Id); diff --git a/tests/Api.IntegrationTests/Scenarios/CreateComplianceDeclarationTests.cs b/tests/Api.IntegrationTests/Scenarios/CreateComplianceDeclarationTests.cs index 884ef237..c27ad8c1 100644 --- a/tests/Api.IntegrationTests/Scenarios/CreateComplianceDeclarationTests.cs +++ b/tests/Api.IntegrationTests/Scenarios/CreateComplianceDeclarationTests.cs @@ -85,7 +85,7 @@ await AsyncWaiter.WaitForAsync( delay: TimeSpan.FromMilliseconds(100) ); - await AssertAnalyticsEventQueued(sqsClient, result.Id, "insert", "submission.created"); + await AssertAnalyticsEventQueued(sqsClient, result.Id, "create", "submission.created"); } [Fact] diff --git a/tests/Api.IntegrationTests/Scenarios/DeleteComplianceDeclarationTests.cs b/tests/Api.IntegrationTests/Scenarios/DeleteComplianceDeclarationTests.cs index 113f6ac4..b5b3d76c 100644 --- a/tests/Api.IntegrationTests/Scenarios/DeleteComplianceDeclarationTests.cs +++ b/tests/Api.IntegrationTests/Scenarios/DeleteComplianceDeclarationTests.cs @@ -39,7 +39,7 @@ await WireMockContext.WireMockAdminApi.StubWasteOrganisationsOrganisationRequest ); created.Should().NotBeNull(); - await AssertAnalyticsEventQueued(sqsClient, created.Id, "insert", "submission.created"); + await AssertAnalyticsEventQueued(sqsClient, created.Id, "create", "submission.created"); var filter = Builders.Filter.Eq(x => x.Id, ObjectId.Parse(created.Id)); var createdCount = await ComplianceDeclarations.CountDocumentsAsync( @@ -67,7 +67,7 @@ await AssertAnalyticsEventQueued( created.Id, "delete", "submission.removed", - "elevated system allowed removal" + "elevated_system_allowed_removal" ); } } diff --git a/tests/Api.IntegrationTests/Scenarios/UpdateComplianceDeclarationTests.cs b/tests/Api.IntegrationTests/Scenarios/UpdateComplianceDeclarationTests.cs index 7d93f376..67c03421 100644 --- a/tests/Api.IntegrationTests/Scenarios/UpdateComplianceDeclarationTests.cs +++ b/tests/Api.IntegrationTests/Scenarios/UpdateComplianceDeclarationTests.cs @@ -21,7 +21,7 @@ public class UpdateComplianceDeclarationTests : IntegrationTestBase { private const string Amended = "submission.amended"; private const string Created = "submission.created"; - private const string Insert = "insert"; + private const string Create = "create"; private const string Update = "update"; [Fact] @@ -53,7 +53,7 @@ await WireMockContext.WireMockAdminApi.StubTokenRequest( ); result.Should().NotBeNull(); - await AssertAnalyticsEventQueued(sqsClient, result.Id, Insert, Created); + await AssertAnalyticsEventQueued(sqsClient, result.Id, Create, Created); response = await client.PatchAsJsonAsync( Testing.Endpoints.Organisations.ComplianceDeclarations.Update(organisationId, result.Id), @@ -103,7 +103,7 @@ public async Task WhenCreatedAndCancelled_ShouldUpdate() ); result.Should().NotBeNull(); - await AssertAnalyticsEventQueued(sqsClient, result.Id, Insert, Created); + await AssertAnalyticsEventQueued(sqsClient, result.Id, Create, Created); response = await client.PatchAsJsonAsync( Testing.Endpoints.Organisations.ComplianceDeclarations.Update(organisationId, result.Id), diff --git a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs index 3d296872..12a54ce4 100644 --- a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs +++ b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs @@ -397,7 +397,7 @@ public async Task Delete_WhenDeleted_ShouldRemove() auditEvents[1].EntityId.Should().Be(initial.Id.ToString()); auditEvents[1].Operation.Should().Be("delete"); auditEvents[1].EventType.Should().Be("submission.removed"); - auditEvents[1].DeletedReason.Should().Be("elevated system allowed removal"); + auditEvents[1].DeletedReason.Should().Be("elevated_system_allowed_removal"); auditEvents[1].Version.Should().Be(2); auditEvents[1].TraceId.Should().Be(TraceId); auditEvents[1].Before.Should().NotBeNull(); diff --git a/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs b/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs index a9157ad3..4e28a0d4 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs +++ b/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs @@ -9,17 +9,20 @@ namespace Defra.WasteObligations.Api.Tests.AuditEvents.Analytics; public class AnalyticsEventMappersTests { [Fact] - public void ToAnalyticsEvent_ShouldPreserveEventIdAndMapEntityIdAsEntityAndEntityId() + public void ToAnalyticsEvent_ShouldMapTheOutboxEventToTheGovernedEnvelope() { const string entity = "compliance_declaration"; const string entityId = "6830b14f9d2a7c61f4e8b935"; const string eventId = "01JZ8RXBMTY2K15SJB3PCFN3D5"; + const string traceId = "cdp-request-id"; var auditEvent = AuditEventFixture .ComplianceDeclaration(eventId) .With(x => x.Entity, entity) .With(x => x.EntityId, entityId) .With(x => x.SchemaVersion, ComplianceDeclaration.SchemaVersionValue) + .With(x => x.Actor, "service:waste-obligations") + .With(x => x.TraceId, traceId) .Create(); var result = auditEvent.ToAnalyticsEvent(); @@ -27,10 +30,106 @@ public void ToAnalyticsEvent_ShouldPreserveEventIdAndMapEntityIdAsEntityAndEntit result.EventId.Should().Be(eventId); result.Entity.Should().Be(auditEvent.Entity); result.EntityId.Should().Be($"{entity}_{entityId}"); - result.Operation.Should().Be(auditEvent.Operation); + result.Operation.Should().Be("create"); result.EventType.Should().Be(auditEvent.EventType); result.DeletedReason.Should().Be(auditEvent.DeletedReason); result.PiiKeyRef.Should().BeNull(); - result.SchemaVersion.Should().Be($"{entity}.{ComplianceDeclaration.SchemaVersionValue}"); + result.CorrelationId.Should().Be(traceId); + result.SchemaVersion.Should().Be($"{entity}_{ComplianceDeclaration.SchemaVersionValue}"); + } + + [Fact] + public void ToAnalyticsEvent_WhenLegacyDeletionReason_ShouldMapToTheGovernedValue() + { + var auditEvent = AuditEventFixture + .ComplianceDeclaration() + .With(x => x.Operation, "delete") + .With(x => x.EventType, "submission.removed") + .With(x => x.DeletedReason, "elevated system allowed removal") + .With(x => x.Actor, "service:waste-obligations") + .Create(); + + var result = auditEvent.ToAnalyticsEvent(); + + result.DeletedReason.Should().Be(AnalyticsEventVocabulary.ElevatedSystemAllowedRemoval); + } + + [Fact] + public void ToAnalyticsEvent_WhenEntityIsUnregistered_ShouldThrow() + { + var auditEvent = AuditEventFixture.ComplianceDeclaration().With(x => x.Entity, "unknown_entity").Create(); + + var act = () => auditEvent.ToAnalyticsEvent(); + + act.Should().Throw().WithMessage("Unregistered analytics entity 'unknown_entity'."); + } + + [Fact] + public void ToAnalyticsEvent_WhenOperationIsUnregistered_ShouldThrow() + { + var auditEvent = AuditEventFixture.ComplianceDeclaration().With(x => x.Operation, "merge").Create(); + + var act = () => auditEvent.ToAnalyticsEvent(); + + act.Should().Throw().WithMessage("Unregistered analytics operation 'merge'."); + } + + [Fact] + public void ToAnalyticsEvent_WhenEventTypeIsUnregistered_ShouldThrow() + { + var auditEvent = AuditEventFixture + .ComplianceDeclaration() + .With(x => x.EventType, "submission.unknown") + .Create(); + + var act = () => auditEvent.ToAnalyticsEvent(); + + act.Should() + .Throw() + .WithMessage("Unregistered analytics event type 'submission.unknown'."); + } + + [Fact] + public void ToAnalyticsEvent_WhenActorIsUnregistered_ShouldThrow() + { + var auditEvent = AuditEventFixture.ComplianceDeclaration().With(x => x.Actor, "unknown:actor").Create(); + + var act = () => auditEvent.ToAnalyticsEvent(); + + act.Should().Throw().WithMessage("Unregistered analytics actor 'unknown:actor'."); + } + + [Fact] + public void ToAnalyticsEvent_WhenDeletionReasonIsUnregistered_ShouldThrow() + { + var auditEvent = AuditEventFixture + .ComplianceDeclaration() + .With(x => x.Operation, "delete") + .With(x => x.EventType, "submission.removed") + .With(x => x.DeletedReason, "unknown_reason") + .With(x => x.Actor, "service:waste-obligations") + .Create(); + + var act = () => auditEvent.ToAnalyticsEvent(); + + act.Should() + .Throw() + .WithMessage("Unregistered analytics deletion reason 'unknown_reason'."); + } + + [Fact] + public void ToAnalyticsEvent_WhenSchemaVersionIsUnregistered_ShouldThrow() + { + var auditEvent = AuditEventFixture + .ComplianceDeclaration() + .With(x => x.Actor, "service:waste-obligations") + .With(x => x.SchemaVersion, "v2.0") + .Create(); + + var act = () => auditEvent.ToAnalyticsEvent(); + + act.Should() + .Throw() + .WithMessage("Unregistered analytics schema version 'compliance_declaration_v2.0'."); } } diff --git a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenDeleteWithBefore_ShouldSerializeAsJson.verified.json b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenDeleteWithBefore_ShouldSerializeAsJson.verified.json index 41b1488d..74940284 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenDeleteWithBefore_ShouldSerializeAsJson.verified.json +++ b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenDeleteWithBefore_ShouldSerializeAsJson.verified.json @@ -5,10 +5,10 @@ "entityId": "compliance_declaration_65f1f6570bb08052a8a27b01", "operation": "delete", "eventType": "submission.removed", - "deletedReason": "elevated system allowed removal", + "deletedReason": "elevated_system_allowed_removal", "piiKeyRef": null, - "occurredAt": "2026-01-02T03:05:05+00:00", - "recordedAt": "2026-01-02T03:05:06+00:00", + "occurredAt": "2026-01-02T03:05:05.000Z", + "recordedAt": "2026-01-02T03:05:06.000Z", "actor": "service:waste-obligations", "version": 2, "before": { @@ -69,5 +69,5 @@ "obligationCoveragePercentage": 40 }, "after": null, - "schemaVersion": "compliance_declaration.v1.3" + "schemaVersion": "compliance_declaration_v1.3" } \ No newline at end of file diff --git a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenInsertWithAfter_ShouldSerializeAsJson.verified.json b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenInsertWithAfter_ShouldSerializeAsJson.verified.json index 349b3b91..feefb4a0 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenInsertWithAfter_ShouldSerializeAsJson.verified.json +++ b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenInsertWithAfter_ShouldSerializeAsJson.verified.json @@ -3,12 +3,12 @@ "sequence": 123, "entity": "compliance_declaration", "entityId": "compliance_declaration_65f1f6570bb08052a8a27b01", - "operation": "insert", + "operation": "create", "eventType": "submission.created", "deletedReason": null, "piiKeyRef": null, - "occurredAt": "2026-01-02T03:04:05+00:00", - "recordedAt": "2026-01-02T03:04:06+00:00", + "occurredAt": "2026-01-02T03:04:05.000Z", + "recordedAt": "2026-01-02T03:04:06.000Z", "actor": "service:waste-obligations", "version": 1, "before": null, @@ -69,5 +69,5 @@ "isRegulation43Compliant": true, "obligationCoveragePercentage": 40 }, - "schemaVersion": "compliance_declaration.v1.3" + "schemaVersion": "compliance_declaration_v1.3" } \ No newline at end of file diff --git a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenUpdateWithBeforeAndAfter_ShouldSerializeAsJson.verified.json b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenUpdateWithBeforeAndAfter_ShouldSerializeAsJson.verified.json index 35f8b6a7..45398ce4 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenUpdateWithBeforeAndAfter_ShouldSerializeAsJson.verified.json +++ b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.Serialize_WhenUpdateWithBeforeAndAfter_ShouldSerializeAsJson.verified.json @@ -7,8 +7,8 @@ "eventType": "submission.amended", "deletedReason": null, "piiKeyRef": null, - "occurredAt": "2026-01-02T03:05:05+00:00", - "recordedAt": "2026-01-02T03:05:06+00:00", + "occurredAt": "2026-01-02T03:05:05.000Z", + "recordedAt": "2026-01-02T03:05:06.000Z", "actor": "service:waste-obligations", "version": 2, "before": { @@ -136,5 +136,5 @@ "isRegulation43Compliant": true, "obligationCoveragePercentage": 40 }, - "schemaVersion": "compliance_declaration.v1.3" + "schemaVersion": "compliance_declaration_v1.3" } \ No newline at end of file diff --git a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs index fe65cda1..78596645 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs +++ b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs @@ -12,7 +12,7 @@ namespace Defra.WasteObligations.Api.Tests.AuditEvents.Analytics; public class JsonAnalyticsEventSerializerTests { private const string Entity = "compliance_declaration"; - private const string AnalyticsEventSchemaVersion = $"{Entity}.{ComplianceDeclaration.SchemaVersionValue}"; + private const string AnalyticsEventSchemaVersion = $"{Entity}_{ComplianceDeclaration.SchemaVersionValue}"; private static readonly ObjectId s_complianceDeclarationId = ObjectId.Parse("65f1f6570bb08052a8a27b01"); private static readonly Guid s_organisationId = Guid.Parse("5dbef606-3611-42f4-b39f-cad828badc12"); private static readonly DateTime s_submittedAt = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); @@ -51,13 +51,13 @@ public void Serialize_WhenPreviousSchemaVersion_ShouldSerializeWithoutBusinessCo var analyticsEvent = AnalyticsEventFixture .ComplianceDeclaration("01JZ8RXBMTY2K15SJB3PCFN3D0", 122) .With(x => x.After, document) - .With(x => x.SchemaVersion, "compliance_declaration.v1.2") + .With(x => x.SchemaVersion, "compliance_declaration_v1.2") .Create(); var result = subject.Serialize(analyticsEvent); using var resultDocument = JsonDocument.Parse(result); - resultDocument.RootElement.GetProperty("schemaVersion").GetString().Should().Be("compliance_declaration.v1.2"); + resultDocument.RootElement.GetProperty("schemaVersion").GetString().Should().Be("compliance_declaration_v1.2"); resultDocument .RootElement.GetProperty("after") .GetProperty("organisation") @@ -122,7 +122,7 @@ public async Task Serialize_WhenDeleteWithBefore_ShouldSerializeAsJson() .With(x => x.EntityId, "compliance_declaration_65f1f6570bb08052a8a27b01") .With(x => x.Operation, "delete") .With(x => x.EventType, "submission.removed") - .With(x => x.DeletedReason, "elevated system allowed removal") + .With(x => x.DeletedReason, AnalyticsEventVocabulary.ElevatedSystemAllowedRemoval) .With(x => x.OccurredAt, new DateTimeOffset(2026, 1, 2, 3, 5, 5, TimeSpan.Zero)) .With(x => x.RecordedAt, new DateTimeOffset(2026, 1, 2, 3, 5, 6, TimeSpan.Zero)) .With(x => x.Actor, "service:waste-obligations") @@ -185,6 +185,27 @@ public void Serialize_WhenDateSerializationIsNotSpecified_ShouldSerializeDateTim after.GetProperty("dateWithoutOffset").GetString().Should().Be("2026-01-02T03:04:05Z"); } + [Fact] + public void Serialize_WhenEnvelopeTimestampsAreWritten_ShouldUseUtcMilliseconds() + { + var subject = new JsonAnalyticsEventSerializer( + new InlineSchemaProvider("""{ "type": "object", "properties": {} }""") + ); + var analyticsEvent = AnalyticsEventFixture + .Default("01JZ8RXBMTY2K15SJB3PCFN3D8", 126) + .With(x => x.Entity, "test_entity") + .With(x => x.SchemaVersion, "test_entity_v1.0") + .With(x => x.OccurredAt, new DateTimeOffset(2026, 1, 2, 3, 4, 5, 123, TimeSpan.Zero)) + .With(x => x.RecordedAt, new DateTimeOffset(2026, 1, 2, 3, 4, 5, 456, TimeSpan.Zero)) + .Create(); + + var result = subject.Serialize(analyticsEvent); + using var document = JsonDocument.Parse(result); + + document.RootElement.GetProperty("occurredAt").GetString().Should().Be("2026-01-02T03:04:05.123Z"); + document.RootElement.GetProperty("recordedAt").GetString().Should().Be("2026-01-02T03:04:05.456Z"); + } + [Fact] public void Serialize_WhenBeforeOrAfterIsNotABsonDocument_ShouldThrow() { diff --git a/tests/Api.Tests/AuditEvents/Metrics/AuditEventMetricsTests.cs b/tests/Api.Tests/AuditEvents/Metrics/AuditEventMetricsTests.cs index f781fc84..b474b3d7 100644 --- a/tests/Api.Tests/AuditEvents/Metrics/AuditEventMetricsTests.cs +++ b/tests/Api.Tests/AuditEvents/Metrics/AuditEventMetricsTests.cs @@ -231,7 +231,7 @@ public void SnsPublishStarted_ShouldIncrementPublishCounter() publishMeasurements[0].Tags[ApiMetrics.Tags.ProcessName].Should().Be(ProcessName); publishMeasurements[0].Tags[ApiMetrics.Tags.TopicName].Should().Be(TopicName); publishMeasurements[0].Tags[ApiMetrics.Tags.Entity].Should().Be("compliance_declaration"); - publishMeasurements[0].Tags[ApiMetrics.Tags.Operation].Should().Be("insert"); + publishMeasurements[0].Tags[ApiMetrics.Tags.Operation].Should().Be("create"); publishMeasurements[0].Tags[ApiMetrics.Tags.EventType].Should().Be("submission.created"); var activeMeasurements = activeCollector.GetMeasurementSnapshot(); diff --git a/tests/Api.Tests/Schemas/EmbeddedEntityJsonSchemaProviderTests.cs b/tests/Api.Tests/Schemas/EmbeddedEntityJsonSchemaProviderTests.cs index ba0bcdaa..c491d8c6 100644 --- a/tests/Api.Tests/Schemas/EmbeddedEntityJsonSchemaProviderTests.cs +++ b/tests/Api.Tests/Schemas/EmbeddedEntityJsonSchemaProviderTests.cs @@ -46,4 +46,14 @@ public void Get_WhenComplianceDeclarationV1_3_ShouldLoadSchema() schema.Should().NotBeNull(); } + + [Fact] + public void Get_WhenComplianceDeclarationUsesEnvelopeSchemaVersion_ShouldLoadSchema() + { + var subject = new EmbeddedEntityJsonSchemaProvider(); + + var schema = subject.Get("compliance_declaration", "compliance_declaration_v1.3"); + + schema.Should().NotBeNull(); + } } diff --git a/tests/Testing/Fixtures/Entities/AnalyticsEventFixture.cs b/tests/Testing/Fixtures/Entities/AnalyticsEventFixture.cs index 4a11df38..57c4bc65 100644 --- a/tests/Testing/Fixtures/Entities/AnalyticsEventFixture.cs +++ b/tests/Testing/Fixtures/Entities/AnalyticsEventFixture.cs @@ -13,15 +13,16 @@ public static IPostprocessComposer AddDefaults(this ICustomizati return composer .With(x => x.Entity, "entity") .With(x => x.EntityId, "entity-1") - .With(x => x.Operation, "insert") + .With(x => x.Operation, "create") .With(x => x.EventType, "submission.created") .Without(x => x.DeletedReason) .Without(x => x.PiiKeyRef) + .Without(x => x.CorrelationId) .With(x => x.OccurredAt, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)) .With(x => x.RecordedAt, new DateTimeOffset(2026, 1, 1, 0, 0, 1, TimeSpan.Zero)) - .With(x => x.Actor, "user@example.com") + .With(x => x.Actor, "service:waste-obligations") .With(x => x.Version, 1) - .With(x => x.SchemaVersion, "entity.v1.0") + .With(x => x.SchemaVersion, "entity_v1.0") .Without(x => x.Before) .Without(x => x.After); } @@ -46,7 +47,7 @@ public static IPostprocessComposer ComplianceDeclaration( .With(x => x.EntityId, "compliance_declaration_entity-1") .With( x => x.SchemaVersion, - $"compliance_declaration.{Api.Data.Entities.ComplianceDeclaration.SchemaVersionValue}" + $"compliance_declaration_{Api.Data.Entities.ComplianceDeclaration.SchemaVersionValue}" ); } } From a06bf959dd80858368c1d6213a089a0f2b6bda9a Mon Sep 17 00:00:00 2001 From: Tim Peel Date: Fri, 11 Sep 2026 17:20:15 +0100 Subject: [PATCH 2/3] MO-523 restore Sonar coverage reporting --- .github/workflows/sonarcloud.yml | 4 +- .../Analytics/AnalyticsEventMappersTests.cs | 52 +++++++++++++++++-- .../JsonAnalyticsEventSerializerTests.cs | 10 ++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 7f85a85c..422880c9 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -64,6 +64,6 @@ jobs: run: | ./.sonar/scanner/dotnet-sonarscanner begin /k:"DEFRA_waste-obligations" /o:"defra" /d:sonar.token="$SONAR_TOKEN" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.cs.vscoveragexml.reportsPaths=coverage*.xml /d:sonar.coverage.exclusions=compose/journey-tests/** dotnet build --no-incremental - dotnet test --test-modules tests/Api.Tests/bin/Debug/net10.0/Api.Tests.dll --no-build --coverage --coverage-output-format xml --coverage-output "coverage.Api.Tests.xml" - dotnet test --test-modules tests/Api.IntegrationTests/bin/Debug/net10.0/Api.IntegrationTests.dll --no-build --coverage --coverage-output-format xml --coverage-output "coverage.Api.IntegrationTests.xml" + dotnet test --test-modules tests/Api.Tests/bin/Debug/net10.0/Api.Tests.dll --no-build -- --coverage --coverage-output-format xml --coverage-output "$GITHUB_WORKSPACE/coverage.Api.Tests.xml" + dotnet test --test-modules tests/Api.IntegrationTests/bin/Debug/net10.0/Api.IntegrationTests.dll --no-build -- --coverage --coverage-output-format xml --coverage-output "$GITHUB_WORKSPACE/coverage.Api.IntegrationTests.xml" ./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN" diff --git a/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs b/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs index 4e28a0d4..c6b58021 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs +++ b/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs @@ -38,6 +38,21 @@ public void ToAnalyticsEvent_ShouldMapTheOutboxEventToTheGovernedEnvelope() result.SchemaVersion.Should().Be($"{entity}_{ComplianceDeclaration.SchemaVersionValue}"); } + [Fact] + public void ToAnalyticsEvent_WhenOutboxOperationIsUpdate_ShouldRetainUpdate() + { + var auditEvent = AuditEventFixture + .ComplianceDeclaration() + .With(x => x.Operation, "update") + .With(x => x.EventType, "submission.amended") + .With(x => x.Actor, "service:waste-obligations") + .Create(); + + var result = auditEvent.ToAnalyticsEvent(); + + result.Operation.Should().Be("update"); + } + [Fact] public void ToAnalyticsEvent_WhenLegacyDeletionReason_ShouldMapToTheGovernedValue() { @@ -74,6 +89,16 @@ public void ToAnalyticsEvent_WhenOperationIsUnregistered_ShouldThrow() act.Should().Throw().WithMessage("Unregistered analytics operation 'merge'."); } + [Fact] + public void Validate_WhenOperationIsUnregistered_ShouldThrow() + { + var analyticsEvent = AnalyticsEventFixture.ComplianceDeclaration().With(x => x.Operation, "merge").Create(); + + var act = () => AnalyticsEventVocabulary.Validate(analyticsEvent); + + act.Should().Throw().WithMessage("Unregistered analytics operation 'merge'."); + } + [Fact] public void ToAnalyticsEvent_WhenEventTypeIsUnregistered_ShouldThrow() { @@ -89,14 +114,17 @@ public void ToAnalyticsEvent_WhenEventTypeIsUnregistered_ShouldThrow() .WithMessage("Unregistered analytics event type 'submission.unknown'."); } - [Fact] - public void ToAnalyticsEvent_WhenActorIsUnregistered_ShouldThrow() + [Theory] + [InlineData("")] + [InlineData("service:")] + [InlineData("unknown:actor")] + public void ToAnalyticsEvent_WhenActorIsUnregistered_ShouldThrow(string actor) { - var auditEvent = AuditEventFixture.ComplianceDeclaration().With(x => x.Actor, "unknown:actor").Create(); + var auditEvent = AuditEventFixture.ComplianceDeclaration().With(x => x.Actor, actor).Create(); var act = () => auditEvent.ToAnalyticsEvent(); - act.Should().Throw().WithMessage("Unregistered analytics actor 'unknown:actor'."); + act.Should().Throw().WithMessage($"Unregistered analytics actor '{actor}'."); } [Fact] @@ -117,6 +145,22 @@ public void ToAnalyticsEvent_WhenDeletionReasonIsUnregistered_ShouldThrow() .WithMessage("Unregistered analytics deletion reason 'unknown_reason'."); } + [Fact] + public void ToAnalyticsEvent_WhenDeletionReasonIsSetForANonDeleteOperation_ShouldThrow() + { + var auditEvent = AuditEventFixture + .ComplianceDeclaration() + .With(x => x.DeletedReason, AnalyticsEventVocabulary.ElevatedSystemAllowedRemoval) + .With(x => x.Actor, "service:waste-obligations") + .Create(); + + var act = () => auditEvent.ToAnalyticsEvent(); + + act.Should() + .Throw() + .WithMessage("An analytics deletion reason is only valid for delete operations."); + } + [Fact] public void ToAnalyticsEvent_WhenSchemaVersionIsUnregistered_ShouldThrow() { diff --git a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs index 78596645..6357e6a6 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs +++ b/tests/Api.Tests/AuditEvents/Analytics/JsonAnalyticsEventSerializerTests.cs @@ -206,6 +206,16 @@ public void Serialize_WhenEnvelopeTimestampsAreWritten_ShouldUseUtcMilliseconds( document.RootElement.GetProperty("recordedAt").GetString().Should().Be("2026-01-02T03:04:05.456Z"); } + [Fact] + public void Deserialize_WhenEnvelopeTimestampIsRead_ShouldThrow() + { + var act = () => JsonSerializer.Deserialize("""{ "occurredAt": "2026-01-02T03:04:05.123Z" }"""); + + act.Should() + .Throw() + .WithMessage("UtcMillisecondDateTimeOffsetJsonConverter only supports writing.*"); + } + [Fact] public void Serialize_WhenBeforeOrAfterIsNotABsonDocument_ShouldThrow() { From e0d967e7dbde52c9636c9c1569833ef8d4c868b0 Mon Sep 17 00:00:00 2001 From: Tim Peel Date: Fri, 11 Sep 2026 17:38:48 +0100 Subject: [PATCH 3/3] MO-523 distinguish user and system event actors --- ...analytics-compliance-declaration-events.md | 10 +++++----- ...iance-declaration-end-to-end-event-flow.md | 2 +- .../Services/ComplianceDeclarationService.cs | 11 +++++++---- .../Analytics/SnsAnalyticsEventSenderTests.cs | 3 +++ ...nConcurrent_SecondShouldFail.verified.json | 4 ++-- ...lSequenceAndPerEntityVersion.verified.json | 8 ++++---- .../ComplianceDeclarationServiceTests.cs | 19 ++++++++++++++++++- .../Analytics/AnalyticsEventMappersTests.cs | 6 ++++-- 8 files changed, 44 insertions(+), 19 deletions(-) diff --git a/docs/analytics-compliance-declaration-events.md b/docs/analytics-compliance-declaration-events.md index 28be44b8..4cabebe3 100644 --- a/docs/analytics-compliance-declaration-events.md +++ b/docs/analytics-compliance-declaration-events.md @@ -28,7 +28,7 @@ All analytics events use this generic envelope: "piiKeyRef": null, "occurredAt": "2026-01-02T03:04:05.000Z", "recordedAt": "2026-01-02T03:04:06.000Z", - "actor": "service:waste-obligations", + "actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "correlationId": "cdp-request-id", "version": 1, "before": null, @@ -49,7 +49,7 @@ All analytics events use this generic envelope: | `piiKeyRef` | Always `null`; PII classification and protection are outside the scope of this change. | | `occurredAt` | Time the entity change occurred, as a UTC ISO 8601 timestamp with millisecond precision. | | `recordedAt` | Time the analytics event was recorded, as a UTC ISO 8601 timestamp with millisecond precision. | -| `actor` | Service actor that wrote the event. | +| `actor` | `user:` for user submissions and status amendments; `service:waste-obligations` for system-driven deletions. | | `correlationId` | The incoming `x-cdp-request-id` when present; omitted when no request ID is available. | | `version` | Entity version after the operation. | | `before` | Previous entity state. This is `null` for creates. | @@ -66,7 +66,7 @@ All analytics events use this generic envelope: ## Controlled vocabulary -The publisher validates the current compliance declaration entity, envelope operation, event type, actor prefix, deletion reason, and schema version before serialising a message. The supported operations are `create`, `update`, and `delete`; actor prefixes are `service`, `user`, `system`, and `integration`; and the supported declaration event types are `submission.created`, `submission.amended`, and `submission.removed`. +The publisher validates the current compliance declaration entity, envelope operation, event type, actor prefix, deletion reason, and schema version before serialising a message. The supported operations are `create`, `update`, and `delete`; actor prefixes are `service`, `user`, `system`, and `integration`; and the supported declaration event types are `submission.created`, `submission.amended`, and `submission.removed`. Create and update events use the responsible audit user as `user:`; the system-driven delete flow retains `service:waste-obligations`. The only current deletion reason is `elevated_system_allowed_removal`. This replaces the legacy free-text value `elevated system allowed removal`, which remains normalised for undispatched historical outbox events. The governed list maintained with the shared event schema must include `elevated_system_allowed_removal` before additional deletion paths are introduced. @@ -100,7 +100,7 @@ The `before` value is `null`. The `after` value is the created compliance declar "piiKeyRef": null, "occurredAt": "2026-01-02T03:04:05.000Z", "recordedAt": "2026-01-02T03:04:06.000Z", - "actor": "service:waste-obligations", + "actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "correlationId": "cdp-request-id", "version": 1, "before": null, @@ -183,7 +183,7 @@ The `before` value is the declaration state before the update. The `after` value "piiKeyRef": null, "occurredAt": "2026-01-02T03:05:05.000Z", "recordedAt": "2026-01-02T03:05:06.000Z", - "actor": "service:waste-obligations", + "actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "correlationId": "cdp-request-id", "version": 2, "before": { diff --git a/docs/compliance-declaration-end-to-end-event-flow.md b/docs/compliance-declaration-end-to-end-event-flow.md index 3593d734..23975a13 100644 --- a/docs/compliance-declaration-end-to-end-event-flow.md +++ b/docs/compliance-declaration-end-to-end-event-flow.md @@ -184,7 +184,7 @@ sequenceDiagram The processor reads audit events where `dispatches.analytics` does not exist, or where it is `Failed` and `nextAttemptAt` is due. Each event is mapped to the analytics envelope: -- `eventId`, `sequence`, `entity`, `eventType`, timestamps, actor, version, `before`, and `after` are copied from the audit event. +- `eventId`, `sequence`, `entity`, `eventType`, timestamps, actor, version, `before`, and `after` are copied from the audit event. Submission and status-amendment events record their responsible user as `user:`; the system-driven deletion flow records `service:waste-obligations`. - `entityId` is changed from the raw ObjectId string to `compliance_declaration_{objectId}`. - `insert` is mapped to the envelope operation `create`; `update` and `delete` are unchanged. - `schemaVersion` is changed from `v1.3` to `compliance_declaration_v1.3`. diff --git a/src/Api/Services/ComplianceDeclarationService.cs b/src/Api/Services/ComplianceDeclarationService.cs index 03aa68af..5f823fad 100644 --- a/src/Api/Services/ComplianceDeclarationService.cs +++ b/src/Api/Services/ComplianceDeclarationService.cs @@ -20,7 +20,7 @@ public class ComplianceDeclarationService( IUnsubmittedEligibilityVisibilityService unsubmittedEligibilityVisibilityService ) : IComplianceDeclarationService { - private const string Actor = "service:waste-obligations"; + private const string SystemActor = "service:waste-obligations"; private const string ComplianceDeclarationEntity = "compliance_declaration"; public async Task Create( @@ -50,7 +50,7 @@ await unsubmittedEligibilityVisibilityService.Refresh( await auditEventService.RecordEvent( transactionSession, new AuditEventRequest( - Actor, + GetActor(complianceDeclaration), ComplianceDeclarationEntity, AuditEventOperation.Insert, "submission.created", @@ -152,7 +152,7 @@ await unsubmittedEligibilityVisibilityService.Refresh( await auditEventService.RecordEvent( transactionSession, new AuditEventRequest( - Actor, + SystemActor, ComplianceDeclarationEntity, AuditEventOperation.Delete, "submission.removed", @@ -280,7 +280,7 @@ await unsubmittedEligibilityVisibilityService.Refresh( await auditEventService.RecordEvent( transactionSession, new AuditEventRequest( - Actor, + GetActor(updated), ComplianceDeclarationEntity, AuditEventOperation.Update, "submission.amended", @@ -413,4 +413,7 @@ private static SortDefinition SortByReversedDirection( SortDefinition ascending, SortDefinition descending ) => direction is ComplianceDeclarationSortDirection.Ascending ? descending : ascending; + + private static string GetActor(ComplianceDeclaration complianceDeclaration) => + complianceDeclaration.Audit.LastOrDefault()?.User is { } user ? $"user:{user.Id}" : SystemActor; } diff --git a/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs b/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs index 31f69f01..50621d14 100644 --- a/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs +++ b/tests/Api.IntegrationTests/AuditEvents/Analytics/SnsAnalyticsEventSenderTests.cs @@ -30,6 +30,7 @@ public async Task WhenAuditEventCreated_ShouldPublishJsonToSubscribedQueue() root.GetProperty("entityId").GetString().Should().Be($"compliance_declaration_{complianceDeclaration.Id}"); root.GetProperty("operation").GetString().Should().Be("create"); root.GetProperty("eventType").GetString().Should().Be("submission.created"); + root.GetProperty("actor").GetString().Should().Be("user:e72be574-8b5b-4836-af47-dd7e0c0d1d87"); root.GetProperty("deletedReason").ValueKind.Should().Be(JsonValueKind.Null); root.GetProperty("piiKeyRef").ValueKind.Should().Be(JsonValueKind.Null); root.TryGetProperty("correlationId", out _).Should().BeFalse(); @@ -72,6 +73,7 @@ await ReceiveAnalyticsEventsQueueJsonMessage( root.GetProperty("entityId").GetString().Should().Be($"compliance_declaration_{complianceDeclaration.Id}"); root.GetProperty("operation").GetString().Should().Be("update"); root.GetProperty("eventType").GetString().Should().Be("submission.amended"); + root.GetProperty("actor").GetString().Should().Be("user:7e91f2ac-5b44-4c8d-ae73-1d9f62b8e0f4"); root.GetProperty("deletedReason").ValueKind.Should().Be(JsonValueKind.Null); root.GetProperty("piiKeyRef").ValueKind.Should().Be(JsonValueKind.Null); root.GetProperty("correlationId").GetString().Should().Be(TraceId); @@ -106,6 +108,7 @@ await ReceiveAnalyticsEventsQueueJsonMessage( root.GetProperty("entityId").GetString().Should().Be($"compliance_declaration_{complianceDeclaration.Id}"); root.GetProperty("operation").GetString().Should().Be("delete"); root.GetProperty("eventType").GetString().Should().Be("submission.removed"); + root.GetProperty("actor").GetString().Should().Be("service:waste-obligations"); root.GetProperty("deletedReason").GetString().Should().Be("elevated_system_allowed_removal"); root.GetProperty("piiKeyRef").ValueKind.Should().Be(JsonValueKind.Null); root.TryGetProperty("correlationId", out _).Should().BeFalse(); diff --git a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Update_WhenConcurrent_SecondShouldFail.verified.json b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Update_WhenConcurrent_SecondShouldFail.verified.json index a6431526..91edf47c 100644 --- a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Update_WhenConcurrent_SecondShouldFail.verified.json +++ b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Update_WhenConcurrent_SecondShouldFail.verified.json @@ -8,7 +8,7 @@ "EventType": "submission.created", "OccurredAt": "DateTime_1", "RecordedAt": "DateTime_2", - "Actor": "service:waste-obligations", + "Actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "Version": 1, "After": { "audit": [ @@ -79,7 +79,7 @@ "EventType": "submission.amended", "OccurredAt": "DateTime_4", "RecordedAt": "DateTime_5", - "Actor": "service:waste-obligations", + "Actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "Version": 2, "Before": { "audit": [ diff --git a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Write_WhenMultipleDeclarations_ShouldUseGlobalSequenceAndPerEntityVersion.verified.json b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Write_WhenMultipleDeclarations_ShouldUseGlobalSequenceAndPerEntityVersion.verified.json index 2dd1eedc..e1f3d62b 100644 --- a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Write_WhenMultipleDeclarations_ShouldUseGlobalSequenceAndPerEntityVersion.verified.json +++ b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.Write_WhenMultipleDeclarations_ShouldUseGlobalSequenceAndPerEntityVersion.verified.json @@ -8,7 +8,7 @@ "EventType": "submission.created", "OccurredAt": "{Scrubbed}", "RecordedAt": "{Scrubbed}", - "Actor": "service:waste-obligations", + "Actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "Version": 1, "After": { "audit": [ @@ -79,7 +79,7 @@ "EventType": "submission.created", "OccurredAt": "{Scrubbed}", "RecordedAt": "{Scrubbed}", - "Actor": "service:waste-obligations", + "Actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "Version": 1, "After": { "audit": [ @@ -150,7 +150,7 @@ "EventType": "submission.amended", "OccurredAt": "{Scrubbed}", "RecordedAt": "{Scrubbed}", - "Actor": "service:waste-obligations", + "Actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "Version": 2, "Before": { "audit": [ @@ -279,7 +279,7 @@ "EventType": "submission.amended", "OccurredAt": "{Scrubbed}", "RecordedAt": "{Scrubbed}", - "Actor": "service:waste-obligations", + "Actor": "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87", "Version": 2, "Before": { "audit": [ diff --git a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs index 12a54ce4..be20d95c 100644 --- a/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs +++ b/tests/Api.IntegrationTests/Services/ComplianceDeclarationServiceTests.cs @@ -87,7 +87,7 @@ public async Task Create_WhenInserted_ShouldBeFound() auditEvent.Operation.Should().Be("insert"); auditEvent.EventType.Should().Be("submission.created"); auditEvent.DeletedReason.Should().BeNull(); - auditEvent.Actor.Should().Be("service:waste-obligations"); + auditEvent.Actor.Should().Be("user:e72be574-8b5b-4836-af47-dd7e0c0d1d87"); auditEvent.Version.Should().Be(1); auditEvent.SchemaVersion.Should().Be(ComplianceDeclaration.SchemaVersionValue); auditEvent.TraceId.Should().Be(TraceId); @@ -98,6 +98,21 @@ public async Task Create_WhenInserted_ShouldBeFound() ComplianceDeclarationMetrics.Received(1).Created(); } + [Fact] + public async Task Create_WhenNoAuditUser_ShouldUseSystemActor() + { + var initial = await Subject.Create( + ComplianceDeclarationFixture.Default().With(x => x.Audit, []).Create(), + TestContext.Current.CancellationToken + ); + + var auditEvent = await AuditEvents + .Find(x => x.EntityId == initial.Id.ToString()) + .SingleAsync(TestContext.Current.CancellationToken); + + auditEvent.Actor.Should().Be("service:waste-obligations"); + } + [Fact] public async Task DeclarationMutation_ShouldOnlyUpdateActiveEligibilityGenerationVisibility() { @@ -398,6 +413,7 @@ public async Task Delete_WhenDeleted_ShouldRemove() auditEvents[1].Operation.Should().Be("delete"); auditEvents[1].EventType.Should().Be("submission.removed"); auditEvents[1].DeletedReason.Should().Be("elevated_system_allowed_removal"); + auditEvents[1].Actor.Should().Be("service:waste-obligations"); auditEvents[1].Version.Should().Be(2); auditEvents[1].TraceId.Should().Be(TraceId); auditEvents[1].Before.Should().NotBeNull(); @@ -516,6 +532,7 @@ public async Task Update_WhenUpdated_ShouldChange() auditEvents[1].Operation.Should().Be("update"); auditEvents[1].EventType.Should().Be("submission.amended"); auditEvents[1].DeletedReason.Should().BeNull(); + auditEvents[1].Actor.Should().Be("user:e72be574-8b5b-4836-af47-dd7e0c0d1d87"); auditEvents[1].Version.Should().Be(2); auditEvents[1].TraceId.Should().Be(TraceId); auditEvents[1].Before.Should().NotBeNull(); diff --git a/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs b/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs index c6b58021..97ee32a0 100644 --- a/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs +++ b/tests/Api.Tests/AuditEvents/Analytics/AnalyticsEventMappersTests.cs @@ -21,7 +21,7 @@ public void ToAnalyticsEvent_ShouldMapTheOutboxEventToTheGovernedEnvelope() .With(x => x.Entity, entity) .With(x => x.EntityId, entityId) .With(x => x.SchemaVersion, ComplianceDeclaration.SchemaVersionValue) - .With(x => x.Actor, "service:waste-obligations") + .With(x => x.Actor, "user:e72be574-8b5b-4836-af47-dd7e0c0d1d87") .With(x => x.TraceId, traceId) .Create(); @@ -32,6 +32,7 @@ public void ToAnalyticsEvent_ShouldMapTheOutboxEventToTheGovernedEnvelope() result.EntityId.Should().Be($"{entity}_{entityId}"); result.Operation.Should().Be("create"); result.EventType.Should().Be(auditEvent.EventType); + result.Actor.Should().Be(auditEvent.Actor); result.DeletedReason.Should().Be(auditEvent.DeletedReason); result.PiiKeyRef.Should().BeNull(); result.CorrelationId.Should().Be(traceId); @@ -45,12 +46,13 @@ public void ToAnalyticsEvent_WhenOutboxOperationIsUpdate_ShouldRetainUpdate() .ComplianceDeclaration() .With(x => x.Operation, "update") .With(x => x.EventType, "submission.amended") - .With(x => x.Actor, "service:waste-obligations") + .With(x => x.Actor, "user:7e91f2ac-5b44-4c8d-ae73-1d9f62b8e0f4") .Create(); var result = auditEvent.ToAnalyticsEvent(); result.Operation.Should().Be("update"); + result.Actor.Should().Be(auditEvent.Actor); } [Fact]