Merge branch 'feature/audit-log-m1-foundation': Audit Log #23 M1 Foundation
M1 lands the schema + types every later milestone depends on. After M1 the
database is ready, the ScadaLink.AuditLog project is wired into the solution,
and dotnet test ScadaLink.slnx is green (2503 tests passed, 0 failed).
Shipped (15 commits):
- Commons audit types: AuditEvent record + Audit{Channel,Kind,Status,ForwardState}
enums + IAuditWriter/ICentralAuditWriter interfaces + AuditTelemetryEnvelope +
PullAuditEventsRequest/Response message DTOs.
- EF mapping: AuditEvent -> AuditLog table with composite PK (EventId, OccurredAtUtc),
five named indexes (IX_AuditLog_*) + UX_AuditLog_EventId unique index for
idempotency lookups. AuditLogEntry (config-audit) coexists, untouched.
- Migration AddAuditLogTable: monthly partition function pf_AuditLog_Month
(24 boundaries Jan 2026-Dec 2027) + partition scheme ps_AuditLog_Month on PRIMARY,
table aligned ON ps_AuditLog_Month(OccurredAtUtc); scadalink_audit_writer (INSERT/SELECT
with explicit DENY UPDATE/DELETE) and scadalink_audit_purger (SELECT + ALTER on
SCHEMA::dbo) DB roles created idempotently.
- IAuditLogRepository + EF impl: append-only surface (InsertIfNotExistsAsync,
QueryAsync with keyset paging, SwitchOutPartitionAsync). M1 honest contract:
SwitchOutPartitionAsync throws NotSupportedException pointing to M6 because
UX_AuditLog_EventId is non-partition-aligned (SQL Server requires partition
column in unique-index key); M6's purge actor will drop-and-rebuild around switches.
- New src/ScadaLink.AuditLog/ project: AuditLogOptions + validator (DefaultCapBytes
8KB, ErrorCapBytes 64KB, RetentionDays 365 [30..3650], header redact defaults
Authorization/X-Api-Key/Cookie/Set-Cookie).
- Spec corrections (#23): alog.md + Component-AuditLog.md vocabulary reconciled
to match M1 enums per user-authorized resolution of the cross-bundle review
finding (CLAUDE.md cached-call lifecycle vocabulary supersedes alog.md's earlier
Success/TransientFailure naming).
MSSQL integration tests gated by Xunit.SkippableFact + Connect Timeout=3 fast-fail;
when the infra/mssql container is up, all 8 migration tests + 8 repository tests
pass; when down, they Skip cleanly in ~3s.
Append-only invariant enforced at three layers:
1. DB writer role: DENY UPDATE, DENY DELETE on dbo.AuditLog.
2. Repo interface: no UpdateAsync, no row-DeleteAsync.
3. Repo impl: raw IF NOT EXISTS INSERT only.
infra/* working-tree mods are pre-existing and untouched throughout M1.
This commit is contained in:
@@ -64,6 +64,14 @@
|
|||||||
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.11.0" />
|
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.11.0" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
<!--
|
||||||
|
Xunit.SkippableFact provides [SkippableFact] + Skip.IfNot/Skip.If for
|
||||||
|
xunit v2. The native Skip API (Assert.Skip / Assert.SkipUnless /
|
||||||
|
Assert.SkipWhen) only exists in xunit v3; xunit 2.9.x lacks it. Used by
|
||||||
|
Bundle C MSSQL integration tests in ScadaLink.ConfigurationDatabase.Tests
|
||||||
|
to mark tests as Skipped (not silently Passed) when MSSQL is unreachable.
|
||||||
|
-->
|
||||||
|
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
|
||||||
<Project Path="src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
<Project Path="src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||||
<Project Path="src/ScadaLink.Host/ScadaLink.Host.csproj" />
|
<Project Path="src/ScadaLink.Host/ScadaLink.Host.csproj" />
|
||||||
<Project Path="src/ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
|
<Project Path="src/ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
<Project Path="src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
|
<Project Path="src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj" />
|
||||||
<Project Path="tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj" />
|
<Project Path="tests/ScadaLink.Commons.Tests/ScadaLink.Commons.Tests.csproj" />
|
||||||
<Project Path="tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj" />
|
<Project Path="tests/ScadaLink.Host.Tests/ScadaLink.Host.Tests.csproj" />
|
||||||
<Project Path="tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj" />
|
<Project Path="tests/ScadaLink.TemplateEngine.Tests/ScadaLink.TemplateEngine.Tests.csproj" />
|
||||||
|
|||||||
95
alog.md
95
alog.md
@@ -109,14 +109,14 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
|
|||||||
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
||||||
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
||||||
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
|
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
|
||||||
| `Kind` | `varchar(32)` | Channel-specific event kind (see table below). |
|
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
||||||
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
||||||
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events (inbound API, central notification dispatch). |
|
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events (inbound API, central notification dispatch). |
|
||||||
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
||||||
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
||||||
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
|
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
|
||||||
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
|
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
|
||||||
| `Status` | `varchar(32)` | Outcome of *this event*: `Success`, `TransientFailure`, `PermanentFailure`, `Enqueued`, `Retrying`, `Delivered`, `Parked`, `Discarded`. |
|
| `Status` | `varchar(32)` | Outcome of *this event*: `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. |
|
||||||
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
|
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
|
||||||
| `DurationMs` | `int` NULL | Call/attempt duration. |
|
| `DurationMs` | `int` NULL | Call/attempt duration. |
|
||||||
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
|
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
|
||||||
@@ -135,14 +135,20 @@ Single wide table, polymorphic by `Channel` + `Kind` discriminators, JSON payloa
|
|||||||
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X."
|
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X."
|
||||||
- Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm).
|
- Partitioning by month on `OccurredAtUtc` from day one (purge becomes a partition switch instead of a delete storm).
|
||||||
|
|
||||||
**`Kind` values by channel:**
|
**`Kind` values (flat — 10 discriminators across all channels):**
|
||||||
|
|
||||||
| Channel | Kinds |
|
| Kind | Fires when |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ApiOutbound` | `SyncCall`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
|
| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
|
||||||
| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
|
| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
|
||||||
| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
|
| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
|
||||||
| `ApiInbound` | `Completed` (one row per request, written at request end with final status) |
|
| `DbWriteCached` | A cached outbound-DB attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
|
||||||
|
| `NotifySend` | Script's `Notify.Send(...)` is enqueued on the site — first row in a notification's lifecycle (`Status=Submitted`). |
|
||||||
|
| `NotifyDeliver` | Central Notification Outbox dispatcher records a delivery attempt (`Attempted`) or terminal outcome (`Delivered`/`Parked`/`Discarded`). |
|
||||||
|
| `InboundRequest` | An inbound API request completes — one row per request, written at request end with final status. |
|
||||||
|
| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
|
||||||
|
| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
|
||||||
|
| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
|
||||||
|
|
||||||
### Site: `AuditLog` (SQLite)
|
### Site: `AuditLog` (SQLite)
|
||||||
|
|
||||||
@@ -215,13 +221,13 @@ This is the same self-healing pattern Site Call Audit uses for `SiteCalls`.
|
|||||||
|
|
||||||
Events that originate at central never touch site SQLite:
|
Events that originate at central never touch site SQLite:
|
||||||
|
|
||||||
- **Inbound API** — request completed at central; one `ApiInbound`/`Completed` row written via `ICentralAuditWriter` synchronously inside the request handler middleware before the HTTP response is flushed.
|
- **Inbound API** — request completed at central; one `ApiInbound`/`InboundRequest` row written via `ICentralAuditWriter` synchronously inside the request handler middleware before the HTTP response is flushed. Auth failures emit `ApiInbound`/`InboundAuthFailure` instead.
|
||||||
- **Notification Outbox dispatcher** — each delivery attempt writes a `Notification`/`Attempt` row; terminal status writes a `Notification`/`Terminal` row. (The site-originated `Notification`/`Enqueued` row arrives via §6.2.)
|
- **Notification Outbox dispatcher** — each delivery attempt writes a `Notification`/`NotifyDeliver` row with `Status=Attempted`; terminal status writes a `Notification`/`NotifyDeliver` row with `Status=Delivered`/`Parked`/`Discarded`. (The site-originated `Notification`/`NotifySend` row, `Status=Submitted`, arrives via §6.2.)
|
||||||
Central direct-writes use the same insert-if-not-exists semantics keyed on `EventId`, so a retried request handler can't produce duplicates.
|
Central direct-writes use the same insert-if-not-exists semantics keyed on `EventId`, so a retried request handler can't produce duplicates.
|
||||||
|
|
||||||
### 6.5 Cached operations — site emits, central writes twice
|
### 6.5 Cached operations — site emits, central writes twice
|
||||||
|
|
||||||
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the source of truth for every audit row. The site writes each lifecycle event (`CachedEnqueued`, `CachedAttempt`, `CachedTerminal`) to its local SQLite `AuditLog` on the hot path (or on the retry tick for `CachedAttempt`), then forwards via the same telemetry channel described in §6.2. The telemetry message format gains the audit-row fields additively — one packet per lifecycle transition carries both the operational state update AND the audit row content.
|
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the source of truth for every audit row. The site writes each lifecycle event — `CachedSubmit` (`Status=Submitted`), then `ApiCallCached`/`DbWriteCached` rows for the forward-ack (`Status=Forwarded`) and each retry (`Status=Attempted`), then a terminal `CachedResolve` row (`Status=Delivered`/`Failed`/`Parked`/`Discarded`) — to its local SQLite `AuditLog` on the hot path (or on the retry tick for `Attempted` rows), then forwards via the same telemetry channel described in §6.2. The telemetry message format gains the audit-row fields additively — one packet per lifecycle transition carries both the operational state update AND the audit row content.
|
||||||
|
|
||||||
On receipt, central does two things in **one transaction**:
|
On receipt, central does two things in **one transaction**:
|
||||||
|
|
||||||
@@ -243,13 +249,13 @@ Worked examples — what each `Channel`/`Kind` row actually looks like. (Other c
|
|||||||
```
|
```
|
||||||
EventId = <new guid>
|
EventId = <new guid>
|
||||||
Channel = ApiOutbound
|
Channel = ApiOutbound
|
||||||
Kind = SyncCall
|
Kind = ApiCall
|
||||||
CorrelationId = NULL -- one-shot, no operation to correlate
|
CorrelationId = NULL -- one-shot, no operation to correlate
|
||||||
SourceSiteId = "site-01"
|
SourceSiteId = "site-01"
|
||||||
SourceInstance = "Plant1.Boiler"
|
SourceInstance = "Plant1.Boiler"
|
||||||
SourceScript = "OnHourly"
|
SourceScript = "OnHourly"
|
||||||
Target = "Weather/GetForecast"
|
Target = "Weather/GetForecast"
|
||||||
Status = Success
|
Status = Delivered
|
||||||
HttpStatus = 200
|
HttpStatus = 200
|
||||||
DurationMs = 142
|
DurationMs = 142
|
||||||
RequestSummary = '{"city":"Dublin"}' -- truncated to cap
|
RequestSummary = '{"city":"Dublin"}' -- truncated to cap
|
||||||
@@ -259,11 +265,12 @@ ResponseSummary= '{"tempC":11.4,...}' -- truncated to cap
|
|||||||
**Cached call** (`ExternalSystem.CachedCall(...)`, hits a 500, retries, succeeds on attempt 3):
|
**Cached call** (`ExternalSystem.CachedCall(...)`, hits a 500, retries, succeeds on attempt 3):
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Kind=CachedEnqueued Status=Enqueued CorrelationId=<tracked-op-id>
|
1. Kind=CachedSubmit Status=Submitted CorrelationId=<tracked-op-id>
|
||||||
2. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=<same>
|
2. Kind=ApiCallCached Status=Forwarded CorrelationId=<same>
|
||||||
3. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=<same>
|
3. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId=<same>
|
||||||
4. Kind=CachedAttempt Status=Success HttpStatus=200 CorrelationId=<same>
|
4. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId=<same>
|
||||||
5. Kind=CachedTerminal Status=Delivered CorrelationId=<same>
|
5. Kind=ApiCallCached Status=Attempted HttpStatus=200 CorrelationId=<same>
|
||||||
|
6. Kind=CachedResolve Status=Delivered CorrelationId=<same>
|
||||||
```
|
```
|
||||||
|
|
||||||
The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
|
The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
|
||||||
@@ -274,10 +281,10 @@ The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
|
|||||||
|
|
||||||
```
|
```
|
||||||
Channel = DbOutbound
|
Channel = DbOutbound
|
||||||
Kind = SyncWrite
|
Kind = DbWrite
|
||||||
Target = "PlantDB" -- connection name only, not server
|
Target = "PlantDB" -- connection name only, not server
|
||||||
CorrelationId = NULL
|
CorrelationId = NULL
|
||||||
Status = Success
|
Status = Delivered
|
||||||
DurationMs = 9
|
DurationMs = 9
|
||||||
RequestSummary = "INSERT INTO Readings(ts,val) VALUES (@p0,@p1)" -- SQL text
|
RequestSummary = "INSERT INTO Readings(ts,val) VALUES (@p0,@p1)" -- SQL text
|
||||||
Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7}}' -- values captured by default
|
Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7}}' -- values captured by default
|
||||||
@@ -288,23 +295,25 @@ Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7
|
|||||||
|
|
||||||
```
|
```
|
||||||
Channel = DbOutbound
|
Channel = DbOutbound
|
||||||
Kind = SyncRead
|
Kind = DbWrite
|
||||||
Status = Success
|
Status = Delivered
|
||||||
DurationMs = 31
|
DurationMs = 31
|
||||||
RequestSummary = "SELECT id, value FROM Readings WHERE ts > @p0"
|
RequestSummary = "SELECT id, value FROM Readings WHERE ts > @p0"
|
||||||
Extra = '{"rowsReturned":42}'
|
Extra = '{"rowsReturned":42}'
|
||||||
ResponseSummary= NULL -- rows not captured by default; opt-in per connection
|
ResponseSummary= NULL -- rows not captured by default; opt-in per connection
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cached write** — same five-row lifecycle as the cached API example.
|
(Reads and writes share the `DbWrite` kind — the kind distinguishes the trust-boundary call shape, not the SQL verb. Distinguish by `RequestSummary` / `Extra.rowsAffected` vs `Extra.rowsReturned` when needed.)
|
||||||
|
|
||||||
|
**Cached write** — same multi-row lifecycle as the cached API example, using `Kind=DbWriteCached` for the `Forwarded` / `Attempted` rows in place of `ApiCallCached`.
|
||||||
|
|
||||||
### 7.3 `Notification` — outbound notifications
|
### 7.3 `Notification` — outbound notifications
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Kind=Enqueued Status=Enqueued CorrelationId=<NotificationId> SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
|
1. Kind=NotifySend Status=Submitted CorrelationId=<NotificationId> SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
|
||||||
2. Kind=Attempt Status=TransientFailure ErrorMessage="SMTP 451 ..." CorrelationId=<same> SourceSiteId=NULL (dispatch is central)
|
2. Kind=NotifyDeliver Status=Attempted ErrorMessage="SMTP 451 ..." CorrelationId=<same> SourceSiteId=NULL (dispatch is central)
|
||||||
3. Kind=Attempt Status=Success CorrelationId=<same>
|
3. Kind=NotifyDeliver Status=Attempted CorrelationId=<same>
|
||||||
4. Kind=Terminal Status=Delivered CorrelationId=<same>
|
4. Kind=NotifyDeliver Status=Delivered CorrelationId=<same>
|
||||||
Target = "OpsTeamEmail" -- notification list name
|
Target = "OpsTeamEmail" -- notification list name
|
||||||
Extra = '{"resolvedTargets":["a@x.com","b@x.com"], "subject":"Boiler high temp"}'
|
Extra = '{"resolvedTargets":["a@x.com","b@x.com"], "subject":"Boiler high temp"}'
|
||||||
RequestSummary = '...body, truncated...'
|
RequestSummary = '...body, truncated...'
|
||||||
@@ -318,20 +327,20 @@ One row per request, written at request completion:
|
|||||||
|
|
||||||
```
|
```
|
||||||
Channel = ApiInbound
|
Channel = ApiInbound
|
||||||
Kind = Completed
|
Kind = InboundRequest
|
||||||
CorrelationId = <request-id> -- the request's correlation header (or generated)
|
CorrelationId = <request-id> -- the request's correlation header (or generated)
|
||||||
SourceSiteId = NULL -- central-originated event
|
SourceSiteId = NULL -- central-originated event
|
||||||
Actor = "AcmeSCADA" -- API key name (NOT the key itself)
|
Actor = "AcmeSCADA" -- API key name (NOT the key itself)
|
||||||
Target = "RecordReading" -- inbound method name
|
Target = "RecordReading" -- inbound method name
|
||||||
Status = Success | PermanentFailure -- mapped from final HTTP outcome
|
Status = Delivered | Failed -- mapped from final HTTP outcome
|
||||||
HttpStatus = 200 | 400 | 401 | 500
|
HttpStatus = 200 | 400 | 500
|
||||||
DurationMs = 73
|
DurationMs = 73
|
||||||
RequestSummary = '{"siteId":"...","value":12.4}' -- truncated; secrets/PII per redaction policy
|
RequestSummary = '{"siteId":"...","value":12.4}' -- truncated; secrets/PII per redaction policy
|
||||||
ResponseSummary= '{"ok":true}' -- full body on 5xx
|
ResponseSummary= '{"ok":true}' -- full body on 5xx
|
||||||
Extra = '{"remoteIp":"203.0.113.42","userAgent":"...","scriptInvoked":"RecordReading.Handle"}'
|
Extra = '{"remoteIp":"203.0.113.42","userAgent":"...","scriptInvoked":"RecordReading.Handle"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
A bad API key → row with `Status=PermanentFailure`, `HttpStatus=401`, `Actor=NULL`, `Extra` carries `remoteIp` for abuse triage.
|
A bad API key → separate kind: `Kind=InboundAuthFailure`, `Status=Failed`, `HttpStatus=401`, `Actor=NULL`, `Extra` carries `remoteIp` for abuse triage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -339,7 +348,7 @@ A bad API key → row with `Status=PermanentFailure`, `HttpStatus=401`, `Actor=N
|
|||||||
|
|
||||||
### 8.1 Truncation
|
### 8.1 Truncation
|
||||||
- Default cap: **8 KB** for each of `RequestSummary` and `ResponseSummary`. Configurable globally; per-target overrides allowed (§8.4).
|
- Default cap: **8 KB** for each of `RequestSummary` and `ResponseSummary`. Configurable globally; per-target overrides allowed (§8.4).
|
||||||
- On any non-`Success` row, the cap is raised to **64 KB** for that row — error context is precious.
|
- On any error row (`Status IN ('Failed', 'Parked', 'Discarded')`), the cap is raised to **64 KB** for that row — error context is precious.
|
||||||
- When a body is truncated, `PayloadTruncated = 1` and the captured prefix is preserved verbatim (UTF-8 byte-safe truncation, no mid-character cuts).
|
- When a body is truncated, `PayloadTruncated = 1` and the captured prefix is preserved verbatim (UTF-8 byte-safe truncation, no mid-character cuts).
|
||||||
- Bodies exceeding the larger cap are still truncated; full bodies are never stored.
|
- Bodies exceeding the larger cap are still truncated; full bodies are never stored.
|
||||||
|
|
||||||
@@ -426,7 +435,7 @@ Lives under a new **Audit** nav group in Central UI (sibling to **Notifications*
|
|||||||
- Target (text search — system+method, DB connection, list name).
|
- Target (text search — system+method, DB connection, list name).
|
||||||
- Actor (text search — inbound API key name).
|
- Actor (text search — inbound API key name).
|
||||||
- CorrelationId (paste a `TrackedOperationId` / `NotificationId` / request-id to see its full event sequence).
|
- CorrelationId (paste a `TrackedOperationId` / `NotificationId` / request-id to see its full event sequence).
|
||||||
- "Errors only" toggle (`Status NOT IN (Success, Delivered, Enqueued)`).
|
- "Errors only" toggle (`Status IN ('Failed', 'Parked', 'Discarded')`).
|
||||||
|
|
||||||
**Results grid:**
|
**Results grid:**
|
||||||
- Columns (resizable, reorderable, persisted per user): `OccurredAtUtc`, `Site`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`, `HttpStatus`, `ErrorMessage`.
|
- Columns (resizable, reorderable, persisted per user): `OccurredAtUtc`, `Site`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`, `HttpStatus`, `ErrorMessage`.
|
||||||
@@ -450,7 +459,7 @@ Lives under a new **Audit** nav group in Central UI (sibling to **Notifications*
|
|||||||
### 10.3 Health dashboard tiles
|
### 10.3 Health dashboard tiles
|
||||||
Three new tiles in an "Audit" KPI group:
|
Three new tiles in an "Audit" KPI group:
|
||||||
- **Audit volume** — events/min global + per-site sparkline.
|
- **Audit volume** — events/min global + per-site sparkline.
|
||||||
- **Audit error rate** — % non-`Success` rows, rolling 5 min.
|
- **Audit error rate** — % rows where `Status IN ('Failed', 'Parked', 'Discarded')`, rolling 5 min.
|
||||||
- **Audit backlog** — sum of `Pending` site rows; click → per-site breakdown.
|
- **Audit backlog** — sum of `Pending` site rows; click → per-site breakdown.
|
||||||
|
|
||||||
### 10.4 Export
|
### 10.4 Export
|
||||||
@@ -516,12 +525,12 @@ Rough back-of-envelope; load testing will confirm.
|
|||||||
### 13.1 Per-site event rate (assumed nominal site)
|
### 13.1 Per-site event rate (assumed nominal site)
|
||||||
| Channel/Kind | Typ events/min | Peak events/min |
|
| Channel/Kind | Typ events/min | Peak events/min |
|
||||||
|---|---:|---:|
|
|---|---:|---:|
|
||||||
| `ApiOutbound.SyncCall` | 10 | 100 |
|
| `ApiOutbound.ApiCall` | 10 | 100 |
|
||||||
| `ApiOutbound.Cached*` (~4 rows/op) | 4 | 20 |
|
| `ApiOutbound.ApiCallCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
|
||||||
| `DbOutbound.SyncWrite` | 30 | 300 |
|
| `DbOutbound.DbWrite` (writes) | 30 | 300 |
|
||||||
| `DbOutbound.SyncRead` | 60 | 600 |
|
| `DbOutbound.DbWrite` (reads) | 60 | 600 |
|
||||||
| `DbOutbound.Cached*` (~4 rows/op) | 4 | 20 |
|
| `DbOutbound.DbWriteCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
|
||||||
| `Notification.Enqueued` (site-emit) | 1 | 10 |
|
| `Notification.NotifySend` (site-emit) | 1 | 10 |
|
||||||
| **Per-site total** | **~110** | **~1,050** |
|
| **Per-site total** | **~110** | **~1,050** |
|
||||||
|
|
||||||
### 13.2 Central total (50-site deployment)
|
### 13.2 Central total (50-site deployment)
|
||||||
@@ -545,7 +554,7 @@ MS SQL handles this with batched ingest and the time-aligned indexes.
|
|||||||
|
|
||||||
### 13.6 Levers
|
### 13.6 Levers
|
||||||
- Reduce `DefaultCapBytes` per §8.1.
|
- Reduce `DefaultCapBytes` per §8.1.
|
||||||
- Tighten per-channel retention per §12.1 (especially `DbOutbound.SyncRead`).
|
- Tighten per-channel retention per §12.1 (especially `DbOutbound.DbWrite` read traffic).
|
||||||
- Defer to v1.x: Parquet archival to object storage before purge (§15.2).
|
- Defer to v1.x: Parquet archival to object storage before purge (§15.2).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -554,7 +563,7 @@ MS SQL handles this with batched ingest and the time-aligned indexes.
|
|||||||
|
|
||||||
### 14.1 New Audit Log KPIs
|
### 14.1 New Audit Log KPIs
|
||||||
- **Volume** — events/min, global + per-site.
|
- **Volume** — events/min, global + per-site.
|
||||||
- **Error rate** — % non-`Success` rows, rolling 5 min.
|
- **Error rate** — % rows where `Status IN ('Failed', 'Parked', 'Discarded')`, rolling 5 min.
|
||||||
- **Backlog** — sum of `Pending` site rows.
|
- **Backlog** — sum of `Pending` site rows.
|
||||||
- **Top inbound callers** — top-10 `Actor` by request count, last 1h.
|
- **Top inbound callers** — top-10 `Actor` by request count, last 1h.
|
||||||
- **Top outbound 5xx** — top-10 `Target` by 5xx-status count, last 1h.
|
- **Top outbound 5xx** — top-10 `Target` by 5xx-status count, last 1h.
|
||||||
@@ -590,6 +599,6 @@ A monthly job dumps the closing partition to Parquet on operator-configured obje
|
|||||||
| 3 | Hash-chain tamper evidence (§11.4) | Deferred to v1.x. v1 enforces append-only via DB grants only. |
|
| 3 | Hash-chain tamper evidence (§11.4) | Deferred to v1.x. v1 enforces append-only via DB grants only. |
|
||||||
| 4 | Parquet archival to object storage (§15.2) | Deferred to v1.x. |
|
| 4 | Parquet archival to object storage (§15.2) | Deferred to v1.x. |
|
||||||
| 5 | Per-channel retention overrides (§12.1) | Deferred to v1.x. v1 uses a single global `RetentionDays`. |
|
| 5 | Per-channel retention overrides (§12.1) | Deferred to v1.x. v1 uses a single global `RetentionDays`. |
|
||||||
| 6 | Default payload cap | **8 KB** for `RequestSummary` / `ResponseSummary`; **64 KB** on non-`Success` rows. |
|
| 6 | Default payload cap | **8 KB** for `RequestSummary` / `ResponseSummary`; **64 KB** on error rows (`Status IN ('Failed', 'Parked', 'Discarded')`). |
|
||||||
|
|
||||||
All earlier design decisions (purpose, topology, scope, payload depth, lifecycle granularity, retention default, site→central path, UI shape, cached-call audit emission, SQL parameter capture, never-fail-on-audit-failure) are also locked. See §1–§15.
|
All earlier design decisions (purpose, topology, scope, payload depth, lifecycle granularity, retention default, site→central path, UI shape, cached-call audit emission, SQL parameter capture, never-fail-on-audit-failure) are also locked. See §1–§15.
|
||||||
|
|||||||
324
docs/plans/2026-05-20-auditlog-m1-foundation.md
Normal file
324
docs/plans/2026-05-20-auditlog-m1-foundation.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# Audit Log #23 — M1 Foundation Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task (bundled cadence per `feedback_subagent_cadence`).
|
||||||
|
|
||||||
|
**Goal:** Land the `AuditLog` table (monthly-partitioned) plus DB roles in MS SQL, and add the Commons types + EF repo + new `ScadaLink.AuditLog` project skeleton that every later milestone depends on. After M1 the database is ready, the new project is wired into the solution, and `dotnet build && dotnet test` are both green.
|
||||||
|
|
||||||
|
**Architecture:** New `AuditEvent` record + audit enums + writer interfaces in Commons. New EF entity configuration + EF Core migration creating `AuditLog` table aligned to `ps_AuditLog_Month` partition scheme on `OccurredAtUtc`, plus `scadalink_audit_writer` and `scadalink_audit_purger` SQL roles. New `IAuditLogRepository` with append-only surface (no Update, no row-delete). New `src/ScadaLink.AuditLog/` project skeleton + `AuditLogOptions`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 10 / EF Core 10.0.7 / Microsoft.Data.SqlClient 6.0.2 / xUnit 2.9.3 / running `infra/mssql` container for integration tests.
|
||||||
|
|
||||||
|
**Brainstorm decisions (locked):**
|
||||||
|
- **MSSQL test harness:** integration tests hit the existing `infra/mssql` container (require `cd infra && docker compose up -d`).
|
||||||
|
- **AuditEvent shape:** one record with nullable `IngestedAtUtc` (set centrally) and nullable `ForwardState` (set site-locally).
|
||||||
|
- **Filegroup:** PRIMARY, hard-coded.
|
||||||
|
- **Indexes:** five named explicitly via `.HasDatabaseName("IX_AuditLog_…")`.
|
||||||
|
|
||||||
|
**Pre-existing reality:**
|
||||||
|
- `Entities/Audit/AuditLogEntry.cs` (config-audit, 9 cols) **coexists with** new `AuditEvent` — no rename, no removal.
|
||||||
|
- `IAuditService` (config-audit) is distinct from new `IAuditWriter` / `ICentralAuditWriter`.
|
||||||
|
- `tests/ScadaLink.IntegrationTests/` uses EF in-memory — NOT usable for partition/role tests.
|
||||||
|
- Roadmap M1-T10 (project skeleton) must run before M1-T9 (options class). **Swapped in this plan.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundles (cadence-aligned)
|
||||||
|
|
||||||
|
Tasks 1–11 from the roadmap are grouped into 6 bundles. Each bundle = one implementer dispatch + one combined spec+quality reviewer. The final cross-bundle reviewer runs over the whole branch.
|
||||||
|
|
||||||
|
- **Bundle A — Commons types** (roadmap T1+T2+T3+T4): enums, AuditEvent record + ForwardState enum, IAuditWriter / ICentralAuditWriter, telemetry message DTOs.
|
||||||
|
- **Bundle B — EF entity mapping** (T5): DbSet + IEntityTypeConfiguration<AuditEvent> + indexes.
|
||||||
|
- **Bundle C — Migration with partitioning + DB roles** (T6+T7 merged — one migration file).
|
||||||
|
- **Bundle D — Repository** (T8): IAuditLogRepository + EF implementation + DI registration.
|
||||||
|
- **Bundle E — AuditLog project skeleton + options** (T10 then T9): new `src/ScadaLink.AuditLog/` project + `AuditLogOptions`.
|
||||||
|
- **Bundle F — Docs paper trail** (T11): controller-direct edit; no subagent needed for a 1–3 line update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle A — Commons types
|
||||||
|
|
||||||
|
### Task 1: Add audit enums to Commons
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Types/Enums/AuditChannel.cs`
|
||||||
|
- Create: `src/ScadaLink.Commons/Types/Enums/AuditKind.cs`
|
||||||
|
- Create: `src/ScadaLink.Commons/Types/Enums/AuditStatus.cs`
|
||||||
|
- Create: `src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs`
|
||||||
|
- Create: `tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs`
|
||||||
|
|
||||||
|
**AuditChannel members** (4): `ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`.
|
||||||
|
|
||||||
|
**AuditKind members** (10, per alog.md §4): `ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`.
|
||||||
|
|
||||||
|
**AuditStatus members** (8, per alog.md §4): `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`.
|
||||||
|
|
||||||
|
**AuditForwardState members** (3): `Pending`, `Forwarded`, `Reconciled`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Failing tests assert each enum's exact member set via `Enum.GetValues(typeof(T)).Cast<T>().Select(x => x.ToString())` against a string-array literal.
|
||||||
|
2. Run: fail (enums don't exist).
|
||||||
|
3. Implement the four enums (no `[Flags]`).
|
||||||
|
4. Run: pass.
|
||||||
|
5. Commit: `feat(commons): add Audit{Channel,Kind,Status,ForwardState} enums for #23`.
|
||||||
|
|
||||||
|
### Task 2: Add AuditEvent record
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs` — `public sealed record AuditEvent` with the 20 central columns per alog.md §4, plus nullable `AuditForwardState? ForwardState` and nullable `DateTime? IngestedAtUtc`.
|
||||||
|
- Create: `tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs`.
|
||||||
|
|
||||||
|
Properties (in alog.md §4 order):
|
||||||
|
`Guid EventId`, `DateTime OccurredAtUtc`, `DateTime? IngestedAtUtc`, `AuditChannel Channel`, `AuditKind Kind`, `Guid? CorrelationId`, `string? SourceSiteId`, `string? SourceInstanceId`, `string? SourceScript`, `string? Actor`, `string? Target`, `AuditStatus Status`, `int? HttpStatus`, `int? DurationMs`, `string? ErrorMessage`, `string? ErrorDetail`, `string? RequestSummary`, `string? ResponseSummary`, `bool PayloadTruncated`, `string? Extra`, `AuditForwardState? ForwardState`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Failing test constructs an `AuditEvent`, asserts each property reads back as set, asserts `with` expression produces a new instance with one field changed.
|
||||||
|
2. Run: fail.
|
||||||
|
3. Implement record with all properties as `init`-only.
|
||||||
|
4. Run: pass.
|
||||||
|
5. Commit: `feat(commons): add AuditEvent record (#23)`.
|
||||||
|
|
||||||
|
### Task 3: Add IAuditWriter and ICentralAuditWriter
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs`
|
||||||
|
- Create: `src/ScadaLink.Commons/Interfaces/Services/ICentralAuditWriter.cs`
|
||||||
|
- Create: `tests/ScadaLink.Commons.Tests/Interfaces/Services/AuditWriterContractTests.cs`
|
||||||
|
|
||||||
|
Both interfaces expose `Task WriteAsync(AuditEvent evt, CancellationToken ct = default)`. XML doc comments name Audit Log #23 as the owner; `IAuditWriter` is the abstraction the boundary code calls, `ICentralAuditWriter` is the central-only flavor (used by direct-write paths in M2+).
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Failing reflection test: `typeof(IAuditWriter).GetMethod("WriteAsync")` returns a method whose parameters are `(AuditEvent, CancellationToken)` and return type is `Task`. Same for `ICentralAuditWriter`.
|
||||||
|
2. Run: fail.
|
||||||
|
3. Implement both interfaces with XML docs.
|
||||||
|
4. Run: pass.
|
||||||
|
5. Commit: `feat(commons): add IAuditWriter and ICentralAuditWriter (#23)`.
|
||||||
|
|
||||||
|
### Task 4: Add audit telemetry + pull message DTOs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Messages/Integration/AuditTelemetryEnvelope.cs` — `public sealed record AuditTelemetryEnvelope(Guid EnvelopeId, string SourceSiteId, IReadOnlyList<AuditEvent> Events)`.
|
||||||
|
- Create: `src/ScadaLink.Commons/Messages/Integration/PullAuditEventsRequest.cs` — `public sealed record PullAuditEventsRequest(string SourceSiteId, DateTime SinceUtc, int BatchSize)`.
|
||||||
|
- Create: `src/ScadaLink.Commons/Messages/Integration/PullAuditEventsResponse.cs` — `public sealed record PullAuditEventsResponse(IReadOnlyList<AuditEvent> Events, bool MoreAvailable)`.
|
||||||
|
- Create: `tests/ScadaLink.Commons.Tests/Messages/Integration/AuditTelemetryMessagesTests.cs`.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Failing test constructs envelope with 3 events and asserts immutability and enumerability.
|
||||||
|
2. Failing test constructs `PullAuditEventsRequest` + `PullAuditEventsResponse` with `MoreAvailable=true`.
|
||||||
|
3. Run: fail.
|
||||||
|
4. Implement records.
|
||||||
|
5. Run: pass.
|
||||||
|
6. Commit: `feat(commons): add audit telemetry + pull message DTOs (#23)`.
|
||||||
|
|
||||||
|
**Bundle A acceptance:** Commons project compiles. Four enum tests, AuditEvent test, two interface contract tests, two telemetry-message tests all green. No existing tests regress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle B — EF entity mapping
|
||||||
|
|
||||||
|
### Task 5: Extend ScadaLinkDbContext + add IEntityTypeConfiguration<AuditEvent>
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` — add `public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();` in the existing `// Audit` section, **directly after** the existing `AuditLogEntries` DbSet. Do not remove or modify `AuditLogEntries`.
|
||||||
|
- Create: `src/ScadaLink.ConfigurationDatabase/Configurations/AuditLogEntityTypeConfiguration.cs` — `IEntityTypeConfiguration<AuditEvent>` mapping to table `AuditLog`, columns per alog.md §4 (with max lengths), PK on `EventId`, enum columns stored as `varchar(32)` via `HasConversion<string>().HasMaxLength(32)`. **No partition function declared here** — that goes in the migration's raw SQL.
|
||||||
|
|
||||||
|
Five indexes with explicit names:
|
||||||
|
- `IX_AuditLog_OccurredAtUtc` on (`OccurredAtUtc` desc)
|
||||||
|
- `IX_AuditLog_Site_Occurred` on (`SourceSiteId`, `OccurredAtUtc` desc)
|
||||||
|
- `IX_AuditLog_CorrelationId` on (`CorrelationId`) where `CorrelationId IS NOT NULL`
|
||||||
|
- `IX_AuditLog_Channel_Status_Occurred` on (`Channel`, `Status`, `OccurredAtUtc` desc)
|
||||||
|
- `IX_AuditLog_Target_Occurred` on (`Target`, `OccurredAtUtc` desc) where `Target IS NOT NULL`
|
||||||
|
|
||||||
|
- Modify: `OnModelCreating` — apply via `modelBuilder.ApplyConfiguration(new AuditLogEntityTypeConfiguration())`.
|
||||||
|
- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/AuditLogEntityTypeConfigurationTests.cs` — use a `ModelBuilder` directly (no DbContext required) and assert:
|
||||||
|
- mapped table name is `AuditLog`,
|
||||||
|
- PK is `EventId`,
|
||||||
|
- exactly 21 properties are mapped (20 + ForwardState; IngestedAtUtc is one of the 20 per spec; but ForwardState is the +1),
|
||||||
|
- the five indexes exist with the documented names.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Failing test: model asserts on table name + PK + property count.
|
||||||
|
2. Implement config + apply in `OnModelCreating`; add the DbSet.
|
||||||
|
3. Failing test: model asserts five named indexes.
|
||||||
|
4. Add `HasIndex(...).HasDatabaseName(...)` for each.
|
||||||
|
5. Run: pass.
|
||||||
|
6. Commit: `feat(configdb): map AuditEvent to AuditLog table with PK and five named indexes (#23)`.
|
||||||
|
|
||||||
|
**Bundle B acceptance:** ConfigurationDatabase project compiles. Mapping test passes. No existing ConfigurationDatabase.Tests regress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle C — Migration with partitioning + DB roles
|
||||||
|
|
||||||
|
### Task 6+7 (merged): Create migration with partition function/scheme/table + DB roles
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Generate: `src/ScadaLink.ConfigurationDatabase/Migrations/<yyyyMMddHHmmss>_AddAuditLogTable.cs` via:
|
||||||
|
```
|
||||||
|
dotnet ef migrations add AddAuditLogTable --project src/ScadaLink.ConfigurationDatabase \
|
||||||
|
--startup-project src/ScadaLink.Host --output-dir Migrations
|
||||||
|
```
|
||||||
|
- Customize the migration's `Up()`:
|
||||||
|
1. Raw SQL: create partition function `pf_AuditLog_Month` (RANGE RIGHT FOR VALUES with month-boundaries from `2026-01-01` through `2027-12-01` UTC), and partition scheme `ps_AuditLog_Month` ALL TO ([PRIMARY]).
|
||||||
|
2. Drop EF's auto-generated `CREATE TABLE` and replace with raw SQL that creates `AuditLog` ON `ps_AuditLog_Month(OccurredAtUtc)`. (Or: let EF generate the table, then `ALTER TABLE … ADD CONSTRAINT … PK … ON ps_AuditLog_Month(OccurredAtUtc)` — whichever EF 10 supports cleanly.)
|
||||||
|
3. Create the five named indexes via `migrationBuilder.CreateIndex(...)`, partition-aligned on `ps_AuditLog_Month(OccurredAtUtc)` where appropriate.
|
||||||
|
4. Raw SQL roles, idempotent (`IF NOT EXISTS … CREATE ROLE`):
|
||||||
|
- `scadalink_audit_writer`: GRANT INSERT ON AuditLog; GRANT SELECT ON AuditLog. (No UPDATE, no DELETE.)
|
||||||
|
- `scadalink_audit_purger`: GRANT ALTER ON SCHEMA::dbo; GRANT SELECT ON AuditLog. (Enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION.)
|
||||||
|
- `Down()` drops indexes, table, scheme, function, then both roles.
|
||||||
|
- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs` — uses a fixture connecting to the running `infra/mssql` container via the connection string in `infra/mssql/.env` (or skips with `Skip.If` when the env var `SCADALINK_MSSQL_TEST_CONN` is unset, so CI without the container still passes).
|
||||||
|
|
||||||
|
Integration test assertions:
|
||||||
|
- `sys.partition_functions` contains `pf_AuditLog_Month`.
|
||||||
|
- `sys.partition_schemes` contains `ps_AuditLog_Month`.
|
||||||
|
- `INFORMATION_SCHEMA.TABLES` contains `AuditLog` aligned to the partition scheme.
|
||||||
|
- `sys.indexes` contains the five expected named indexes.
|
||||||
|
- `sys.database_principals` contains both roles.
|
||||||
|
- Smoke test: log in as a user mapped to `scadalink_audit_writer`, attempt `UPDATE AuditLog …`, expect `SqlException` with permission error.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Generate the migration; let EF auto-fill the body.
|
||||||
|
2. Failing integration test: assert partition function exists.
|
||||||
|
3. Edit migration to add the partition function + scheme + table alignment.
|
||||||
|
4. Re-run: pass.
|
||||||
|
5. Failing integration test: assert five indexes exist.
|
||||||
|
6. Add named indexes to migration.
|
||||||
|
7. Failing integration test: assert both roles exist with documented grants.
|
||||||
|
8. Add roles to migration.
|
||||||
|
9. Failing integration test: smoke `UPDATE AuditLog` as writer expects permission error.
|
||||||
|
10. Verify role grants exclude UPDATE.
|
||||||
|
11. Run: pass.
|
||||||
|
12. Commit: `feat(configdb): add AuditLog migration with monthly partitioning and DB roles (#23)`.
|
||||||
|
|
||||||
|
**Notes for the implementer:**
|
||||||
|
- Use `Microsoft.Data.SqlClient` directly in the test fixture (not EF) to issue raw SQL for grant assertions.
|
||||||
|
- `Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADALINK_MSSQL_TEST_CONN")), "MSSQL not available")` — keeps tests CI-safe.
|
||||||
|
- Test database name: `ScadaLinkAuditMigrationTest_<guid>` (created per fixture, dropped on dispose).
|
||||||
|
|
||||||
|
**Bundle C acceptance:** Migration applied to a fresh test DB on the `infra/mssql` container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when `SCADALINK_MSSQL_TEST_CONN` is set; skip cleanly when unset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle D — Repository
|
||||||
|
|
||||||
|
### Task 8: IAuditLogRepository + EF implementation + DI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.Commons/Interfaces/Repositories/IAuditLogRepository.cs` — three methods:
|
||||||
|
- `Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);`
|
||||||
|
- `Task<IReadOnlyList<AuditEvent>> QueryAsync(AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default);`
|
||||||
|
- `Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);`
|
||||||
|
|
||||||
|
Plus two small DTOs in the same file (or co-located `Types/Audit/`):
|
||||||
|
- `AuditLogQueryFilter` record: nullable `AuditChannel?`, `AuditKind?`, `AuditStatus?`, `string? SourceSiteId`, `string? Target`, `string? Actor`, `Guid? CorrelationId`, `DateTime? FromUtc`, `DateTime? ToUtc`.
|
||||||
|
- `AuditLogPaging` record: `int PageSize`, `Guid? AfterEventId`, `DateTime? AfterOccurredAtUtc` (keyset).
|
||||||
|
|
||||||
|
- Create: `src/ScadaLink.ConfigurationDatabase/Repositories/AuditLogRepository.cs` — implements all three methods:
|
||||||
|
- `InsertIfNotExistsAsync` uses raw SQL `IF NOT EXISTS (SELECT 1 FROM AuditLog WHERE EventId = @id) INSERT INTO AuditLog …` via `DbContext.Database.ExecuteSqlInterpolatedAsync` (bypasses change tracker).
|
||||||
|
- `QueryAsync` builds an `IQueryable<AuditEvent>`, applies filters, projects, paged by keyset on `(OccurredAtUtc desc, EventId desc)`.
|
||||||
|
- `SwitchOutPartitionAsync` builds a unique staging table name, runs `CREATE TABLE … <staging>` with identical schema and ON `[PRIMARY]`, runs `ALTER TABLE AuditLog SWITCH PARTITION <n> TO <staging>`, then `DROP TABLE <staging>`. All inside a single transaction. Computes partition number from `monthBoundary` via `$partition.pf_AuditLog_Month(@boundary)`.
|
||||||
|
|
||||||
|
- Modify: `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` — add `services.AddScoped<IAuditLogRepository, AuditLogRepository>();` after `INotificationOutboxRepository` line.
|
||||||
|
|
||||||
|
- Create: `tests/ScadaLink.ConfigurationDatabase.Tests/Repositories/AuditLogRepositoryTests.cs` — uses the same MSSQL fixture from Bundle C (skipped when env var unset) since `InsertIfNotExistsAsync` uses raw SQL that won't run on EF in-memory.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Insert for fresh `EventId` writes one row.
|
||||||
|
- Calling `InsertIfNotExistsAsync` again with the same `EventId` is a no-op (no exception, row count unchanged).
|
||||||
|
- `QueryAsync` returns rows in `(OccurredAtUtc desc, EventId desc)` order honoring all filter predicates.
|
||||||
|
- `QueryAsync` with non-null `AfterEventId`/`AfterOccurredAtUtc` keysets correctly to the next page.
|
||||||
|
- `SwitchOutPartitionAsync` for an old boundary removes the rows belonging to that partition from the live table.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Failing test: insert + duplicate insert.
|
||||||
|
2. Implement using raw SQL.
|
||||||
|
3. Failing test: query order + filters.
|
||||||
|
4. Implement.
|
||||||
|
5. Failing test: keyset paging.
|
||||||
|
6. Implement.
|
||||||
|
7. Failing test: switch-out partition.
|
||||||
|
8. Implement.
|
||||||
|
9. Run all: pass.
|
||||||
|
10. Commit: `feat(configdb): IAuditLogRepository + EF implementation, append-only with partition-switch purge (#23)`.
|
||||||
|
|
||||||
|
**Bundle D acceptance:** Repository tests green. DI smoke test from existing ConfigurationDatabase.Tests still passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle E — AuditLog project skeleton + options
|
||||||
|
|
||||||
|
### Task 10 (first): Scaffold `src/ScadaLink.AuditLog/` project
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj` — TargetFramework `net10.0` (matches solution), references `ScadaLink.Commons` + `ScadaLink.ConfigurationDatabase`.
|
||||||
|
- Create: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs` — `public static class ServiceCollectionExtensions { public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config) { … } }` registering `AuditLogOptions` (from Task 9) and forwarding to `services.AddScoped<IAuditLogRepository, AuditLogRepository>()` (already registered by ConfigurationDatabase, so this is a no-op but documents the dependency).
|
||||||
|
- Create: `tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj` with one smoke test.
|
||||||
|
- Create: `tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs` — smoke test: `services.AddAuditLog(config); var p = services.BuildServiceProvider(); Assert.NotNull(p.GetService<IOptions<AuditLogOptions>>());`.
|
||||||
|
- Modify: `ScadaLink.slnx` — add both projects.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. `dotnet new classlib -n ScadaLink.AuditLog -o src/ScadaLink.AuditLog --framework net10.0` (then delete the default `Class1.cs`).
|
||||||
|
2. `dotnet new xunit -n ScadaLink.AuditLog.Tests -o tests/ScadaLink.AuditLog.Tests --framework net10.0`.
|
||||||
|
3. Add `<ProjectReference>` to Commons + ConfigurationDatabase in the src csproj; add reference to ScadaLink.AuditLog in the test csproj.
|
||||||
|
4. Add both projects to `ScadaLink.slnx` (inside the existing `/src/` and `/tests/` folders).
|
||||||
|
5. Add `<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />` to the src csproj (already in `Directory.Packages.props`).
|
||||||
|
6. Create stub `ServiceCollectionExtensions.AddAuditLog` (just registers options; writer impl comes in M2).
|
||||||
|
7. Commit: `feat(auditlog): scaffold ScadaLink.AuditLog project (#23)`.
|
||||||
|
|
||||||
|
### Task 9: AuditLogOptions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs` — class with:
|
||||||
|
- `int DefaultCapBytes` (default 8192)
|
||||||
|
- `int ErrorCapBytes` (default 65536)
|
||||||
|
- `List<string> HeaderRedactList` (default: `[ "Authorization", "X-Api-Key", "Cookie", "Set-Cookie" ]`)
|
||||||
|
- `List<string> GlobalBodyRedactors` (default: empty)
|
||||||
|
- `Dictionary<string, PerTargetRedactionOverride> PerTargetOverrides` (default empty)
|
||||||
|
- `int RetentionDays` (default 365; range [30, 3650])
|
||||||
|
- Create: `src/ScadaLink.AuditLog/Configuration/PerTargetRedactionOverride.cs` — minimal: `int? CapBytes`, `List<string>? AdditionalBodyRedactors`.
|
||||||
|
- Create: `src/ScadaLink.AuditLog/Configuration/AuditLogOptionsValidator.cs` — `IValidateOptions<AuditLogOptions>` checking `DefaultCapBytes > 0`, `ErrorCapBytes >= DefaultCapBytes`, `RetentionDays` in `[30, 3650]`.
|
||||||
|
- Modify: `src/ScadaLink.AuditLog/ServiceCollectionExtensions.AddAuditLog` to `services.AddOptions<AuditLogOptions>().Bind(config.GetSection("AuditLog")).ValidateOnStart(); services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();`.
|
||||||
|
- Add: `tests/ScadaLink.AuditLog.Tests/Configuration/AuditLogOptionsTests.cs`:
|
||||||
|
- Bind valid section → values present.
|
||||||
|
- Bind invalid `RetentionDays = 0` → validator rejects.
|
||||||
|
- Bind invalid `ErrorCapBytes < DefaultCapBytes` → validator rejects.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Failing test: valid bind round-trip.
|
||||||
|
2. Implement options class.
|
||||||
|
3. Failing test: invalid `RetentionDays`.
|
||||||
|
4. Implement validator.
|
||||||
|
5. Failing test: invalid `ErrorCapBytes`.
|
||||||
|
6. Validator covers it.
|
||||||
|
7. Run: pass.
|
||||||
|
8. Commit: `feat(auditlog): add AuditLogOptions + validator (#23)`.
|
||||||
|
|
||||||
|
**Bundle E acceptance:** New `src/ScadaLink.AuditLog/` project builds. Solution still builds. Smoke + options tests green. `ScadaLink.slnx` includes both new entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bundle F — Docs paper trail (controller-direct)
|
||||||
|
|
||||||
|
### Task 11: Register AuditLog project in Component-Host.md and confirm README
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/requirements/Component-Host.md` — list `ScadaLink.AuditLog` in the central role's registration set.
|
||||||
|
- Modify: `README.md` — confirm row #23 link reflects the new project (no functional change unless missing).
|
||||||
|
|
||||||
|
This is a 1–3 line edit. Per the cadence memory, controller does it directly without a subagent.
|
||||||
|
|
||||||
|
**Commit:** `docs(audit): register ScadaLink.AuditLog project in Host role (#23)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final cross-bundle review
|
||||||
|
|
||||||
|
After all bundles ship:
|
||||||
|
|
||||||
|
- Dispatch a final code-reviewer subagent over the whole M1 branch.
|
||||||
|
- Acceptance gate (from goal prompt step E):
|
||||||
|
- `dotnet test ScadaLink.slnx` green (full solution).
|
||||||
|
- All M1 roadmap acceptance criteria met; each cited by name to the proving test.
|
||||||
|
- If green, merge to main `--no-ff` with summary message (step F).
|
||||||
|
- Update M2–M8 sections of the roadmap with realities learned (step G), commit.
|
||||||
|
- Status paragraph (step H).
|
||||||
|
- Proceed to M2 (step I).
|
||||||
24
docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json
Normal file
24
docs/plans/2026-05-20-auditlog-m1-foundation.md.tasks.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"planPath": "docs/plans/2026-05-20-auditlog-m1-foundation.md",
|
||||||
|
"tasks": [
|
||||||
|
{"id": "A1", "subject": "Bundle A T1: Add audit enums (Channel, Kind, Status, ForwardState)", "status": "pending"},
|
||||||
|
{"id": "A2", "subject": "Bundle A T2: Add AuditEvent record", "status": "pending", "blockedBy": ["A1"]},
|
||||||
|
{"id": "A3", "subject": "Bundle A T3: Add IAuditWriter + ICentralAuditWriter", "status": "pending", "blockedBy": ["A2"]},
|
||||||
|
{"id": "A4", "subject": "Bundle A T4: Add audit telemetry + pull message DTOs", "status": "pending", "blockedBy": ["A2"]},
|
||||||
|
{"id": "A-rev", "subject": "Bundle A combined spec+quality review", "status": "pending", "blockedBy": ["A1", "A2", "A3", "A4"]},
|
||||||
|
{"id": "B5", "subject": "Bundle B T5: ScadaLinkDbContext.AuditLogs + IEntityTypeConfiguration<AuditEvent> with five named indexes", "status": "pending", "blockedBy": ["A-rev"]},
|
||||||
|
{"id": "B-rev", "subject": "Bundle B review", "status": "pending", "blockedBy": ["B5"]},
|
||||||
|
{"id": "C67", "subject": "Bundle C T6+T7: AddAuditLogTable migration (partition fn/scheme/table/indexes) + DB roles, with infra/mssql integration tests", "status": "pending", "blockedBy": ["B-rev"]},
|
||||||
|
{"id": "C-rev", "subject": "Bundle C review", "status": "pending", "blockedBy": ["C67"]},
|
||||||
|
{"id": "D8", "subject": "Bundle D T8: IAuditLogRepository + EF implementation + DI registration", "status": "pending", "blockedBy": ["C-rev"]},
|
||||||
|
{"id": "D-rev", "subject": "Bundle D review", "status": "pending", "blockedBy": ["D8"]},
|
||||||
|
{"id": "E10", "subject": "Bundle E T10: Scaffold src/ScadaLink.AuditLog/ project + slnx entries", "status": "pending", "blockedBy": ["D-rev"]},
|
||||||
|
{"id": "E9", "subject": "Bundle E T9: AuditLogOptions + validator", "status": "pending", "blockedBy": ["E10"]},
|
||||||
|
{"id": "E-rev", "subject": "Bundle E review", "status": "pending", "blockedBy": ["E10", "E9"]},
|
||||||
|
{"id": "F11", "subject": "Bundle F T11 (controller-direct): Register ScadaLink.AuditLog in Component-Host.md + README confirm", "status": "pending", "blockedBy": ["E-rev"]},
|
||||||
|
{"id": "FINAL-rev", "subject": "Final cross-bundle review over the whole M1 branch", "status": "pending", "blockedBy": ["F11"]},
|
||||||
|
{"id": "MERGE", "subject": "Verify gate: full solution dotnet test green, then merge --no-ff to main", "status": "pending", "blockedBy": ["FINAL-rev"]},
|
||||||
|
{"id": "ROADMAP", "subject": "Update downstream M2-M8 sections of roadmap with realities learned in M1", "status": "pending", "blockedBy": ["MERGE"]}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-05-20T00:00:00Z"
|
||||||
|
}
|
||||||
@@ -81,14 +81,14 @@ row per lifecycle event across all channels.
|
|||||||
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
||||||
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
||||||
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
|
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound`. |
|
||||||
| `Kind` | `varchar(32)` | Channel-specific event kind (see below). |
|
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
||||||
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
||||||
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
||||||
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
||||||
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
||||||
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
|
| `Actor` | `varchar(128)` NULL | Inbound API: API key name. Outbound: script identity. Central: system user. |
|
||||||
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
|
| `Target` | `varchar(256)` NULL | Outbound API: external system + method. DB: connection name. Notification: list name. Inbound API: method name. |
|
||||||
| `Status` | `varchar(32)` | Outcome of *this event* — `Success`, `TransientFailure`, `PermanentFailure`, `Enqueued`, `Retrying`, `Delivered`, `Parked`, `Discarded`. |
|
| `Status` | `varchar(32)` | Outcome of *this event* — `Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`. |
|
||||||
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
|
| `HttpStatus` | `int` NULL | HTTP-bearing events only. |
|
||||||
| `DurationMs` | `int` NULL | Call / attempt duration. |
|
| `DurationMs` | `int` NULL | Call / attempt duration. |
|
||||||
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
|
| `ErrorMessage` | `nvarchar(1024)` NULL | Truncated; `ErrorDetail` for full text. |
|
||||||
@@ -107,17 +107,24 @@ row per lifecycle event across all channels.
|
|||||||
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
|
- `IX_AuditLog_Target_Occurred (Target, OccurredAtUtc)` — "what did we send to system X".
|
||||||
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
|
- Monthly partitioning on `OccurredAtUtc` from day one; purge is a partition switch (see Retention & Purge).
|
||||||
|
|
||||||
**`Kind` values by channel:**
|
**`Kind` values (flat — 10 discriminators across all channels):**
|
||||||
|
|
||||||
| Channel | Kinds |
|
| Kind | Fires when |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ApiOutbound` | `SyncCall`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
|
| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
|
||||||
| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
|
| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
|
||||||
| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
|
| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
|
||||||
| `ApiInbound` | `Completed` — one row per request, written at request end with final status |
|
| `DbWriteCached` | A cached outbound-DB attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
|
||||||
|
| `NotifySend` | Script's `Notify.Send(...)` is enqueued on the site — first row in a notification's lifecycle (`Status=Submitted`). |
|
||||||
|
| `NotifyDeliver` | Central Notification Outbox dispatcher records a delivery attempt (`Attempted`) or terminal outcome (`Delivered`/`Parked`/`Discarded`). |
|
||||||
|
| `InboundRequest` | An inbound API request completes — one row per request, written at request end with final status. |
|
||||||
|
| `InboundAuthFailure` | An inbound API request was rejected at the auth boundary (bad/missing key). One row, `Status=Failed`, `HttpStatus=401`. |
|
||||||
|
| `CachedSubmit` | Script-side enqueue of a cached call (`ExternalSystem.CachedCall` / `Database.CachedWrite`); first row in the cached-call lifecycle, written to site SQLite before any forward attempt. |
|
||||||
|
| `CachedResolve` | Terminal row for a cached operation — `Status` = `Delivered` / `Failed` / `Parked` / `Discarded`. |
|
||||||
|
|
||||||
Inbound API is intentionally collapsed to a single `Completed` row per request
|
Inbound API is intentionally collapsed to a single `InboundRequest` (or
|
||||||
rather than a multi-event lifecycle.
|
`InboundAuthFailure` for auth rejections) row per request rather than a
|
||||||
|
multi-event lifecycle.
|
||||||
|
|
||||||
## The Site-Local `AuditLog` (SQLite)
|
## The Site-Local `AuditLog` (SQLite)
|
||||||
|
|
||||||
@@ -178,18 +185,24 @@ pattern as Site Call Audit's reconciliation of `SiteCalls`.
|
|||||||
### Central direct-write (central-originated events)
|
### Central direct-write (central-originated events)
|
||||||
|
|
||||||
Events originating at central never touch site SQLite. Inbound API writes one
|
Events originating at central never touch site SQLite. Inbound API writes one
|
||||||
`ApiInbound.Completed` row via `ICentralAuditWriter` synchronously inside the
|
`ApiInbound.InboundRequest` row via `ICentralAuditWriter` synchronously inside
|
||||||
request-handler middleware, before the HTTP response is flushed. The
|
the request-handler middleware, before the HTTP response is flushed; auth-layer
|
||||||
Notification Outbox dispatcher writes `Notification.Attempt` per delivery
|
rejections emit `ApiInbound.InboundAuthFailure` (`Status=Failed`, HTTP 401)
|
||||||
attempt and `Notification.Terminal` on terminal status. Central direct-writes
|
instead. The Notification Outbox dispatcher writes
|
||||||
use the same insert-if-not-exists semantics keyed on `EventId`.
|
`Notification.NotifyDeliver` with `Status=Attempted` per delivery attempt and
|
||||||
|
`Notification.NotifyDeliver` with `Status=Delivered`/`Parked`/`Discarded` on
|
||||||
|
terminal status. Central direct-writes use the same insert-if-not-exists
|
||||||
|
semantics keyed on `EventId`.
|
||||||
|
|
||||||
## Cached Operations — Combined Telemetry
|
## Cached Operations — Combined Telemetry
|
||||||
|
|
||||||
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the
|
For `ExternalSystem.CachedCall` and `Database.CachedWrite`, the **site** is the
|
||||||
source of truth for every audit row. The site writes each lifecycle event
|
source of truth for every audit row. The site writes each lifecycle event —
|
||||||
(`CachedEnqueued`, `CachedAttempt`, `CachedTerminal`) to its local SQLite
|
`CachedSubmit` (`Status=Submitted`), then `ApiCallCached`/`DbWriteCached` rows
|
||||||
`AuditLog` on the hot path (or on the retry tick for `CachedAttempt`), then
|
for the forward-ack (`Status=Forwarded`) and each retry (`Status=Attempted`),
|
||||||
|
then a terminal `CachedResolve` row
|
||||||
|
(`Status=Delivered`/`Failed`/`Parked`/`Discarded`) — to its local SQLite
|
||||||
|
`AuditLog` on the hot path (or on the retry tick for `Attempted` rows), then
|
||||||
forwards via the same telemetry channel. The telemetry message format gains the
|
forwards via the same telemetry channel. The telemetry message format gains the
|
||||||
audit-row fields additively — one packet per lifecycle transition carries both
|
audit-row fields additively — one packet per lifecycle transition carries both
|
||||||
the operational state update AND the audit row content.
|
the operational state update AND the audit row content.
|
||||||
@@ -207,7 +220,7 @@ operational `SiteCalls` shape for the dispatcher and UI.
|
|||||||
## Payload Capture Policy
|
## Payload Capture Policy
|
||||||
|
|
||||||
- **Default cap** — 8 KB for each of `RequestSummary` and `ResponseSummary`;
|
- **Default cap** — 8 KB for each of `RequestSummary` and `ResponseSummary`;
|
||||||
raised to 64 KB on any non-`Success` row.
|
raised to 64 KB on any error row (`Status IN ('Failed', 'Parked', 'Discarded')`).
|
||||||
- **Truncation** — UTF-8 byte-safe; `PayloadTruncated = 1` when applied. Full
|
- **Truncation** — UTF-8 byte-safe; `PayloadTruncated = 1` when applied. Full
|
||||||
bodies are never stored.
|
bodies are never stored.
|
||||||
- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`, and
|
- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-API-Key`, and
|
||||||
@@ -292,7 +305,7 @@ MS SQL for direct-write events). Unredacted secrets never persist.
|
|||||||
Point-in-time, computed from the central `AuditLog` table; global and per-site.
|
Point-in-time, computed from the central `AuditLog` table; global and per-site.
|
||||||
|
|
||||||
- **Audit volume** — events/min landing in the central `AuditLog`; global plus per-site sparkline.
|
- **Audit volume** — events/min landing in the central `AuditLog`; global plus per-site sparkline.
|
||||||
- **Audit error rate** — % of central `AuditLog` rows with `Status` NOT IN (`Success`, `Delivered`, `Enqueued`) over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, transient failures, parked deliveries) — NOT audit-writer health, which surfaces separately via `CentralAuditWriteFailures` and `AuditRedactionFailure`.
|
- **Audit error rate** — % of central `AuditLog` rows with `Status IN ('Failed', 'Parked', 'Discarded')` over a rolling 5-minute window. This is the operational error rate of audited operations (HTTP 5xx, permanent failures, parked deliveries) — NOT audit-writer health, which surfaces separately via `CentralAuditWriteFailures` and `AuditRedactionFailure`.
|
||||||
- **Audit backlog** — sum of `Pending` site rows across sites; click drills into a per-site breakdown.
|
- **Audit backlog** — sum of `Pending` site rows across sites; click drills into a per-site breakdown.
|
||||||
|
|
||||||
[Notification Outbox](Component-NotificationOutbox.md) and
|
[Notification Outbox](Component-NotificationOutbox.md) and
|
||||||
@@ -350,19 +363,22 @@ global value in v1; per-channel overrides are deferred to v1.x.
|
|||||||
## Interactions
|
## Interactions
|
||||||
|
|
||||||
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md)** —
|
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md)** —
|
||||||
emits `ApiOutbound.SyncCall` rows on every sync `Call()`. For `CachedCall`,
|
emits `ApiOutbound.ApiCall` rows on every sync `Call()`. For `CachedCall`,
|
||||||
emits the combined cached telemetry packet (audit row + operational update)
|
emits the combined cached telemetry packet (audit row + operational update)
|
||||||
per Cached Operations — Combined Telemetry.
|
per Cached Operations — Combined Telemetry, using kinds
|
||||||
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md) — Database layer** — the database access modes inside ESG emit `DbOutbound.SyncWrite` and `DbOutbound.SyncRead` on script-initiated `Connection()` calls; `Database.CachedWrite` emits the cached-write lifecycle rows via the combined-telemetry packet (same path as `ApiOutbound.Cached*`). Site Runtime is the API surface that exposes the `Database.*` calls to scripts; the audit emission itself lives in ESG.
|
`CachedSubmit` / `ApiCallCached` / `CachedResolve`.
|
||||||
|
- **[External System Gateway (#7)](Component-ExternalSystemGateway.md) — Database layer** — the database access modes inside ESG emit `DbOutbound.DbWrite` rows on script-initiated `Connection()` calls (writes and reads share the kind; distinguish via `Extra.rowsAffected` vs `Extra.rowsReturned`); `Database.CachedWrite` emits the cached-write lifecycle rows via the combined-telemetry packet using kinds `CachedSubmit` / `DbWriteCached` / `CachedResolve` (same shape as `ApiOutbound`). Site Runtime is the API surface that exposes the `Database.*` calls to scripts; the audit emission itself lives in ESG.
|
||||||
- **[Inbound API (#14)](Component-InboundAPI.md)** — emits one
|
- **[Inbound API (#14)](Component-InboundAPI.md)** — emits one
|
||||||
`ApiInbound.Completed` row per request from request-handler middleware,
|
`ApiInbound.InboundRequest` row per successful request from request-handler
|
||||||
written directly to central via `ICentralAuditWriter` before the response is
|
middleware, written directly to central via `ICentralAuditWriter` before the
|
||||||
flushed.
|
response is flushed. Auth-layer rejections emit
|
||||||
|
`ApiInbound.InboundAuthFailure` instead (`Status=Failed`, HTTP 401).
|
||||||
- **[Notification Outbox (#21)](Component-NotificationOutbox.md)** — the
|
- **[Notification Outbox (#21)](Component-NotificationOutbox.md)** — the
|
||||||
site-emitted `Notification.Enqueued` row flows via audit telemetry; the
|
site-emitted `Notification.NotifySend` row (`Status=Submitted`) flows via
|
||||||
central dispatcher writes `Notification.Attempt` (per delivery attempt) and
|
audit telemetry; the central dispatcher writes `Notification.NotifyDeliver`
|
||||||
`Notification.Terminal` (on terminal status) directly via
|
rows directly via `ICentralAuditWriter` — `Status=Attempted` per delivery
|
||||||
`ICentralAuditWriter`. The operational `Notifications` table is unchanged.
|
attempt, `Status=Delivered`/`Parked`/`Discarded` on terminal status. The
|
||||||
|
operational `Notifications` table is unchanged.
|
||||||
- **[Site Call Audit (#22)](Component-SiteCallAudit.md)** — shares the
|
- **[Site Call Audit (#22)](Component-SiteCallAudit.md)** — shares the
|
||||||
cached-call telemetry packet. Central ingest of that packet performs both the
|
cached-call telemetry packet. Central ingest of that packet performs both the
|
||||||
`AuditLog` insert and the `SiteCalls` upsert in one transaction. `SiteCalls`
|
`AuditLog` insert and the `SiteCalls` upsert in one transaction. `SiteCalls`
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
|
|||||||
| Communication | Yes | Yes | Yes | Yes | No |
|
| Communication | Yes | Yes | Yes | Yes | No |
|
||||||
| HealthMonitoring | Yes | Yes | Yes | Yes | No |
|
| HealthMonitoring | Yes | Yes | Yes | Yes | No |
|
||||||
| ExternalSystemGateway | Yes | Yes | Yes | Yes | No |
|
| ExternalSystemGateway | Yes | Yes | Yes | Yes | No |
|
||||||
|
| AuditLog | Yes | Yes | Yes | Yes | No |
|
||||||
| NotificationService | Yes | No | Yes | Yes | No |
|
| NotificationService | Yes | No | Yes | Yes | No |
|
||||||
| NotificationOutbox | Yes | No | Yes | Yes | No |
|
| NotificationOutbox | Yes | No | Yes | Yes | No |
|
||||||
| SiteCallAudit | Yes | No | Yes | Yes | No |
|
| SiteCallAudit | Yes | No | Yes | Yes | No |
|
||||||
@@ -197,7 +198,7 @@ The Host's `Program.cs` calls these extension methods; the component libraries o
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- **All 18 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable).
|
- **All 19 component libraries**: The Host references every component project to call their extension methods (excludes CLI, which is a separate executable). Audit Log (#23) ships its central+site code in `ScadaLink.AuditLog`; the Host calls `AddAuditLog()` on both roles, M2+ will add `AddAuditLogActors()`.
|
||||||
- **Akka.Hosting**: For `AddAkka()` and the hosting configuration builder.
|
- **Akka.Hosting**: For `AddAkka()` and the hosting configuration builder.
|
||||||
- **Akka.Remote.Hosting, Akka.Cluster.Hosting**: For Akka subsystem configuration. (No Akka.Persistence plugin — see the Persistence note under REQ-HOST-6.)
|
- **Akka.Remote.Hosting, Akka.Cluster.Hosting**: For Akka subsystem configuration. (No Akka.Persistence plugin — see the Persistence note under REQ-HOST-6.)
|
||||||
- **Serilog.AspNetCore**: For structured logging integration.
|
- **Serilog.AspNetCore**: For structured logging integration.
|
||||||
|
|||||||
36
src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs
Normal file
36
src/ScadaLink.AuditLog/Configuration/AuditLogOptions.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
namespace ScadaLink.AuditLog.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for Audit Log (#23). Bound from the <c>AuditLog</c> section of
|
||||||
|
/// <c>appsettings.json</c>. Defaults reflect the design (alog.md §6, §10): an
|
||||||
|
/// 8 KiB payload-summary cap, a 64 KiB cap on error rows, and a 365-day central
|
||||||
|
/// retention window with monthly partition-switch purge. The default
|
||||||
|
/// header-redact list covers HTTP auth headers; per-target overrides extend
|
||||||
|
/// (never replace) the global redactor set.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuditLogOptions
|
||||||
|
{
|
||||||
|
/// <summary>Default payload-summary cap in bytes (default 8 KiB).</summary>
|
||||||
|
public int DefaultCapBytes { get; set; } = 8192;
|
||||||
|
|
||||||
|
/// <summary>Payload-summary cap on error rows in bytes (default 64 KiB).</summary>
|
||||||
|
public int ErrorCapBytes { get; set; } = 65536;
|
||||||
|
|
||||||
|
/// <summary>HTTP headers redacted by default before persistence.</summary>
|
||||||
|
public List<string> HeaderRedactList { get; set; } = new()
|
||||||
|
{
|
||||||
|
"Authorization",
|
||||||
|
"X-Api-Key",
|
||||||
|
"Cookie",
|
||||||
|
"Set-Cookie",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Body-content redactors applied globally (regex patterns).</summary>
|
||||||
|
public List<string> GlobalBodyRedactors { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Per-target redaction overrides keyed by target identifier.</summary>
|
||||||
|
public Dictionary<string, PerTargetRedactionOverride> PerTargetOverrides { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Central retention window in days (default 365, range [30, 3650]).</summary>
|
||||||
|
public int RetentionDays { get; set; } = 365;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates <see cref="AuditLogOptions"/> on startup. The caps drive payload
|
||||||
|
/// truncation in the M2+ writers, so an unset/zero cap would let arbitrarily
|
||||||
|
/// large blobs into the central <c>AuditLog</c> table. <see cref="AuditLogOptions.ErrorCapBytes"/>
|
||||||
|
/// must be at least as large as <see cref="AuditLogOptions.DefaultCapBytes"/>
|
||||||
|
/// because the error cap is meant to capture <em>more</em> detail than the
|
||||||
|
/// happy-path summary, not less. <see cref="AuditLogOptions.RetentionDays"/> is
|
||||||
|
/// bounded to <c>[30, 3650]</c> to keep purge windows sane: too short would
|
||||||
|
/// drop in-flight investigations, too long would defeat the partition-switch
|
||||||
|
/// purge's purpose.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuditLogOptionsValidator : IValidateOptions<AuditLogOptions>
|
||||||
|
{
|
||||||
|
/// <summary>Inclusive lower bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
|
||||||
|
public const int MinRetentionDays = 30;
|
||||||
|
|
||||||
|
/// <summary>Inclusive upper bound for <see cref="AuditLogOptions.RetentionDays"/>.</summary>
|
||||||
|
public const int MaxRetentionDays = 3650;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValidateOptionsResult Validate(string? name, AuditLogOptions options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
if (options.DefaultCapBytes <= 0)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"AuditLog:{nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}) " +
|
||||||
|
"must be > 0; it drives payload-summary truncation in audit writers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.ErrorCapBytes < options.DefaultCapBytes)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"AuditLog:{nameof(AuditLogOptions.ErrorCapBytes)} ({options.ErrorCapBytes}) " +
|
||||||
|
$"must be >= {nameof(AuditLogOptions.DefaultCapBytes)} ({options.DefaultCapBytes}); " +
|
||||||
|
"the error-row cap is intended to capture more detail than the happy-path summary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.RetentionDays < MinRetentionDays || options.RetentionDays > MaxRetentionDays)
|
||||||
|
{
|
||||||
|
failures.Add(
|
||||||
|
$"AuditLog:{nameof(AuditLogOptions.RetentionDays)} ({options.RetentionDays}) " +
|
||||||
|
$"must be in [{MinRetentionDays}, {MaxRetentionDays}] days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return failures.Count == 0
|
||||||
|
? ValidateOptionsResult.Success
|
||||||
|
: ValidateOptionsResult.Fail(failures);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ScadaLink.AuditLog.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-target redaction override applied additively on top of
|
||||||
|
/// <see cref="AuditLogOptions.GlobalBodyRedactors"/> and the
|
||||||
|
/// <see cref="AuditLogOptions.DefaultCapBytes"/> / <see cref="AuditLogOptions.ErrorCapBytes"/>
|
||||||
|
/// caps. Targets are identified by the script-facing external-system /
|
||||||
|
/// database / notification-list / inbound-API-key name.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PerTargetRedactionOverride
|
||||||
|
{
|
||||||
|
/// <summary>Optional payload cap override (bytes); null inherits the global cap.</summary>
|
||||||
|
public int? CapBytes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Additional body redactor regex patterns (appended to the global list).</summary>
|
||||||
|
public List<string>? AdditionalBodyRedactors { get; set; }
|
||||||
|
}
|
||||||
28
src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
Normal file
28
src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||||
|
<!-- Audit Log (#23) sits alongside Notification Outbox (#21) and Site Call Audit (#22).
|
||||||
|
IAuditLogRepository is registered by ScadaLink.ConfigurationDatabase; the project
|
||||||
|
reference is documented here so M2 writers + telemetry actors can depend on it. -->
|
||||||
|
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ScadaLink.AuditLog.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
44
src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
Normal file
44
src/ScadaLink.AuditLog/ServiceCollectionExtensions.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Configuration;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composition root for the Audit Log (#23) component. M1 registers
|
||||||
|
/// <see cref="AuditLogOptions"/> and its validator; later milestones extend
|
||||||
|
/// this method to wire up writers, telemetry actors, and the central ingest
|
||||||
|
/// pipeline. Audit Log (#23) sits alongside Notification Outbox (#21) and
|
||||||
|
/// Site Call Audit (#22).
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Configuration section bound to <see cref="AuditLogOptions"/>.</summary>
|
||||||
|
public const string ConfigSectionName = "AuditLog";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Binds <see cref="AuditLogOptions"/> from the
|
||||||
|
/// <see cref="ConfigSectionName"/> section of <paramref name="config"/>
|
||||||
|
/// and registers <see cref="AuditLogOptionsValidator"/> so a misconfigured
|
||||||
|
/// <c>AuditLog</c> section is rejected with a key-naming message when the
|
||||||
|
/// options are first resolved (or at startup when consumers wire in
|
||||||
|
/// <c>ValidateOnStart()</c>). M2+ will register writers, telemetry actors,
|
||||||
|
/// and the central ingest pipeline here. <c>IAuditLogRepository</c> is
|
||||||
|
/// registered by
|
||||||
|
/// <c>ScadaLink.ConfigurationDatabase.ServiceCollectionExtensions.AddConfigurationDatabase</c>,
|
||||||
|
/// so the caller (the Host on the central node) must also call that.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddAuditLog(this IServiceCollection services, IConfiguration config)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
|
||||||
|
services.AddOptions<AuditLogOptions>()
|
||||||
|
.Bind(config.GetSection(ConfigSectionName))
|
||||||
|
.ValidateOnStart();
|
||||||
|
services.AddSingleton<IValidateOptions<AuditLogOptions>, AuditLogOptionsValidator>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
Normal file
73
src/ScadaLink.Commons/Entities/Audit/AuditEvent.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Entities.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for AuditLog (#23) rows. Central rows leave ForwardState null;
|
||||||
|
/// site rows leave IngestedAtUtc null until ingest. Append-only.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditEvent
|
||||||
|
{
|
||||||
|
/// <summary>Idempotency key; uniquely identifies one audit lifecycle event.</summary>
|
||||||
|
public Guid EventId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when the audited action occurred at its source.</summary>
|
||||||
|
public DateTime OccurredAtUtc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when the row was ingested at central; null on the site hot-path.</summary>
|
||||||
|
public DateTime? IngestedAtUtc { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Trust-boundary channel the audited action crossed.</summary>
|
||||||
|
public AuditChannel Channel { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Specific event kind within the channel (see alog.md §4).</summary>
|
||||||
|
public AuditKind Kind { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Correlation id linking related audit rows (e.g. the cached-op lifecycle).</summary>
|
||||||
|
public Guid? CorrelationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Site id where the action originated; null for central-direct events.</summary>
|
||||||
|
public string? SourceSiteId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Instance id where the action originated, when applicable.</summary>
|
||||||
|
public string? SourceInstanceId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Script that initiated the action (script trust boundary), when applicable.</summary>
|
||||||
|
public string? SourceScript { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Authenticated actor for inbound paths (API key name, user, etc.).</summary>
|
||||||
|
public string? Actor { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Target of the action: external system name, db connection name, list name, or inbound method.</summary>
|
||||||
|
public string? Target { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Lifecycle status of this row.</summary>
|
||||||
|
public AuditStatus Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>HTTP status code where applicable (outbound API + inbound API).</summary>
|
||||||
|
public int? HttpStatus { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Duration of the audited action in milliseconds, when measurable.</summary>
|
||||||
|
public int? DurationMs { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable error summary on failure rows.</summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Verbose error detail (stack/exception) on failure rows.</summary>
|
||||||
|
public string? ErrorDetail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Truncated/redacted request summary; capped per AuditLogOptions.</summary>
|
||||||
|
public string? RequestSummary { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Truncated/redacted response summary; capped per AuditLogOptions.</summary>
|
||||||
|
public string? ResponseSummary { get; init; }
|
||||||
|
|
||||||
|
/// <summary>True when Request/Response summaries were truncated to the payload cap.</summary>
|
||||||
|
public bool PayloadTruncated { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Free-form JSON extension column for channel-specific extras.</summary>
|
||||||
|
public string? Extra { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Site-local forwarding state; null on central rows.</summary>
|
||||||
|
public AuditForwardState? ForwardState { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Append-only data access for the central <c>AuditLog</c> table (Audit Log #23).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The append-only invariant is enforced both at the SQL level (the
|
||||||
|
/// <c>scadalink_audit_writer</c> role has only INSERT + SELECT — UPDATE and DELETE
|
||||||
|
/// are not granted) and at the API level: this interface deliberately exposes no
|
||||||
|
/// Update and no single-row Delete. Bulk purge is performed exclusively via
|
||||||
|
/// monthly partition switch-out (<see cref="SwitchOutPartitionAsync"/>).
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ingest is idempotent on <c>EventId</c>: <see cref="InsertIfNotExistsAsync"/> is
|
||||||
|
/// first-write-wins, so retrying telemetry and reconciliation pulls can both feed
|
||||||
|
/// the same writer without producing duplicates.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAuditLogRepository
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts <paramref name="evt"/> if no row with the same
|
||||||
|
/// <see cref="AuditEvent.EventId"/> exists; otherwise silently leaves the
|
||||||
|
/// stored row untouched (first-write-wins). Bypasses the EF change tracker
|
||||||
|
/// so the row never enters a tracked state.
|
||||||
|
/// </summary>
|
||||||
|
Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns up to <see cref="AuditLogPaging.PageSize"/> rows matching
|
||||||
|
/// <paramref name="filter"/>, ordered by <c>(OccurredAtUtc DESC, EventId DESC)</c>.
|
||||||
|
/// Use keyset paging by passing the last returned row's
|
||||||
|
/// <c>OccurredAtUtc</c> + <c>EventId</c> back via
|
||||||
|
/// <see cref="AuditLogPaging.AfterOccurredAtUtc"/> +
|
||||||
|
/// <see cref="AuditLogPaging.AfterEventId"/> to fetch the next page.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
|
AuditLogQueryFilter filter,
|
||||||
|
AuditLogPaging paging,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Switches out (purges) the monthly partition whose lower bound is
|
||||||
|
/// <paramref name="monthBoundary"/>. The honest M1 implementation throws
|
||||||
|
/// <see cref="NotSupportedException"/>: the <c>UX_AuditLog_EventId</c> unique
|
||||||
|
/// index is non-partition-aligned (lives on <c>[PRIMARY]</c>, not on
|
||||||
|
/// <c>ps_AuditLog_Month</c>), so SQL Server rejects
|
||||||
|
/// <c>ALTER TABLE … SWITCH PARTITION</c> until the drop-and-rebuild dance
|
||||||
|
/// shipped by the M6 purge actor is in place.
|
||||||
|
/// </summary>
|
||||||
|
Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default);
|
||||||
|
}
|
||||||
17
src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs
Normal file
17
src/ScadaLink.Commons/Interfaces/Services/IAuditWriter.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Boundary-side abstraction for emitting Audit Log (#23) events.
|
||||||
|
/// Implementations on the site write to local SQLite hot-path; on central they write to MS SQL directly.
|
||||||
|
/// Failures must NEVER abort the user-facing action.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuditWriter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Persist an audit event. Best-effort: implementations must swallow/log internal failures
|
||||||
|
/// rather than propagating them to the calling boundary code.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central-only audit writer for the direct-write path (Notification Outbox dispatch, Inbound API).
|
||||||
|
/// Distinct from <see cref="IAuditWriter"/> so DI binding can differ between site and central hosts.
|
||||||
|
/// </summary>
|
||||||
|
public interface ICentralAuditWriter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Persist an audit event into the central AuditLog table directly (bypassing site telemetry).
|
||||||
|
/// Best-effort: implementations must swallow/log internal failures rather than propagating them.
|
||||||
|
/// </summary>
|
||||||
|
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Messages.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) telemetry envelope sent from a site to central over gRPC.
|
||||||
|
/// At-least-once delivery; central is idempotent on <see cref="AuditEvent.EventId"/>.
|
||||||
|
/// See Component-AuditLog.md "Ingestion" for the handoff contract.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditTelemetryEnvelope(
|
||||||
|
Guid EnvelopeId,
|
||||||
|
string SourceSiteId,
|
||||||
|
IReadOnlyList<AuditEvent> Events);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ScadaLink.Commons.Messages.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) periodic reconciliation pull request: central asks a site for
|
||||||
|
/// audit events since the given UTC watermark, up to <paramref name="BatchSize"/>.
|
||||||
|
/// Acts as the fallback when streaming telemetry is lost. See Component-AuditLog.md "Ingestion".
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PullAuditEventsRequest(
|
||||||
|
string SourceSiteId,
|
||||||
|
DateTime SinceUtc,
|
||||||
|
int BatchSize);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Messages.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) periodic reconciliation pull response: the next batch of site
|
||||||
|
/// audit events plus a <paramref name="MoreAvailable"/> flag signalling the caller
|
||||||
|
/// to advance the watermark and pull again. See Component-AuditLog.md "Ingestion".
|
||||||
|
/// </summary>
|
||||||
|
public sealed record PullAuditEventsResponse(
|
||||||
|
IReadOnlyList<AuditEvent> Events,
|
||||||
|
bool MoreAvailable);
|
||||||
13
src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs
Normal file
13
src/ScadaLink.Commons/Types/Audit/AuditLogPaging.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keyset paging cursor for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||||
|
/// The repository orders by <c>(OccurredAtUtc DESC, EventId DESC)</c>; callers pass
|
||||||
|
/// the last row of the previous page back as <see cref="AfterOccurredAtUtc"/> +
|
||||||
|
/// <see cref="AfterEventId"/> to fetch the next page. Both must be non-null together,
|
||||||
|
/// or both null (first page).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditLogPaging(
|
||||||
|
int PageSize,
|
||||||
|
DateTime? AfterOccurredAtUtc = null,
|
||||||
|
Guid? AfterEventId = null);
|
||||||
21
src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
Normal file
21
src/ScadaLink.Commons/Types/Audit/AuditLogQueryFilter.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Filter predicate for <see cref="ScadaLink.Commons.Interfaces.Repositories.IAuditLogRepository.QueryAsync"/>.
|
||||||
|
/// Any field left <c>null</c> means "do not constrain on that column". Time bounds
|
||||||
|
/// are half-open in the spec sense — <see cref="FromUtc"/> is inclusive and
|
||||||
|
/// <see cref="ToUtc"/> is inclusive of the upper bound; the repository SQL uses
|
||||||
|
/// <c>>=</c> / <c><=</c> respectively. All filter fields are AND-combined.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AuditLogQueryFilter(
|
||||||
|
AuditChannel? Channel = null,
|
||||||
|
AuditKind? Kind = null,
|
||||||
|
AuditStatus? Status = null,
|
||||||
|
string? SourceSiteId = null,
|
||||||
|
string? Target = null,
|
||||||
|
string? Actor = null,
|
||||||
|
Guid? CorrelationId = null,
|
||||||
|
DateTime? FromUtc = null,
|
||||||
|
DateTime? ToUtc = null);
|
||||||
13
src/ScadaLink.Commons/Types/Enums/AuditChannel.cs
Normal file
13
src/ScadaLink.Commons/Types/Enums/AuditChannel.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level Audit Log (#23) channel — the trust boundary the audited action crosses.
|
||||||
|
/// One of: outbound API call, outbound DB write, notification send/deliver, or inbound API request.
|
||||||
|
/// </summary>
|
||||||
|
public enum AuditChannel
|
||||||
|
{
|
||||||
|
ApiOutbound,
|
||||||
|
DbOutbound,
|
||||||
|
Notification,
|
||||||
|
ApiInbound
|
||||||
|
}
|
||||||
14
src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs
Normal file
14
src/ScadaLink.Commons/Types/Enums/AuditForwardState.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-local Audit Log (#23) forwarding state, tracked only in the site SQLite hot-path.
|
||||||
|
/// Central rows leave this null. <c>Pending</c> = not yet sent; <c>Forwarded</c> = telemetry sent
|
||||||
|
/// and acked; <c>Reconciled</c> = confirmed present centrally via the periodic pull fallback.
|
||||||
|
/// The site retention purge MUST NOT drop a row whose state is still <c>Pending</c>.
|
||||||
|
/// </summary>
|
||||||
|
public enum AuditForwardState
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Forwarded,
|
||||||
|
Reconciled
|
||||||
|
}
|
||||||
20
src/ScadaLink.Commons/Types/Enums/AuditKind.cs
Normal file
20
src/ScadaLink.Commons/Types/Enums/AuditKind.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specific Audit Log (#23) event kind within a channel — what action produced the row.
|
||||||
|
/// Cached variants emit multiple rows per operation (submit → forward → attempt → resolve).
|
||||||
|
/// See alog.md §4 for the full taxonomy.
|
||||||
|
/// </summary>
|
||||||
|
public enum AuditKind
|
||||||
|
{
|
||||||
|
ApiCall,
|
||||||
|
ApiCallCached,
|
||||||
|
DbWrite,
|
||||||
|
DbWriteCached,
|
||||||
|
NotifySend,
|
||||||
|
NotifyDeliver,
|
||||||
|
InboundRequest,
|
||||||
|
InboundAuthFailure,
|
||||||
|
CachedSubmit,
|
||||||
|
CachedResolve
|
||||||
|
}
|
||||||
18
src/ScadaLink.Commons/Types/Enums/AuditStatus.cs
Normal file
18
src/ScadaLink.Commons/Types/Enums/AuditStatus.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lifecycle status of an Audit Log (#23) event row.
|
||||||
|
/// Cached operations produce multiple rows tracking <c>Submitted → Forwarded → Attempted → Delivered/Parked/Discarded</c>.
|
||||||
|
/// <c>Skipped</c> is used when an action was short-circuited (e.g. dry-run) but should still be audited.
|
||||||
|
/// </summary>
|
||||||
|
public enum AuditStatus
|
||||||
|
{
|
||||||
|
Submitted,
|
||||||
|
Forwarded,
|
||||||
|
Attempted,
|
||||||
|
Delivered,
|
||||||
|
Failed,
|
||||||
|
Parked,
|
||||||
|
Discarded,
|
||||||
|
Skipped
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the <see cref="AuditEvent"/> record to the central <c>AuditLog</c> table
|
||||||
|
/// described in alog.md §4. Column lengths/types and the five named indexes are
|
||||||
|
/// fixed by that specification — keep this in sync with the doc.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogEntityTypeConfiguration : IEntityTypeConfiguration<AuditEvent>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<AuditEvent> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("AuditLog");
|
||||||
|
|
||||||
|
// Composite PK includes OccurredAtUtc — required by the monthly partition scheme
|
||||||
|
// (ps_AuditLog_Month) so the clustered key is partition-aligned. EventId still
|
||||||
|
// needs to be globally unique for InsertIfNotExistsAsync idempotency, so a
|
||||||
|
// separate unique index is declared on EventId alone.
|
||||||
|
builder.HasKey(e => new { e.EventId, e.OccurredAtUtc });
|
||||||
|
|
||||||
|
builder.HasIndex(e => e.EventId)
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_AuditLog_EventId");
|
||||||
|
|
||||||
|
// Enum-as-string columns: bounded varchar(32) ASCII.
|
||||||
|
builder.Property(e => e.Channel)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.Kind)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.Status)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
builder.Property(e => e.ForwardState)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false);
|
||||||
|
|
||||||
|
// Ascii identifier columns — never carry user-supplied unicode.
|
||||||
|
builder.Property(e => e.SourceSiteId)
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.IsUnicode(false);
|
||||||
|
|
||||||
|
builder.Property(e => e.SourceInstanceId)
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false);
|
||||||
|
|
||||||
|
builder.Property(e => e.SourceScript)
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false);
|
||||||
|
|
||||||
|
builder.Property(e => e.Actor)
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false);
|
||||||
|
|
||||||
|
builder.Property(e => e.Target)
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.IsUnicode(false);
|
||||||
|
|
||||||
|
// Bounded unicode message column.
|
||||||
|
builder.Property(e => e.ErrorMessage)
|
||||||
|
.HasMaxLength(1024);
|
||||||
|
|
||||||
|
// ErrorDetail, RequestSummary, ResponseSummary, Extra: leave as nvarchar(max).
|
||||||
|
|
||||||
|
// Indexes — names locked to alog.md §4 for reconciliation/migration discoverability.
|
||||||
|
builder.HasIndex(e => e.OccurredAtUtc)
|
||||||
|
.IsDescending(true)
|
||||||
|
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||||
|
|
||||||
|
builder.HasIndex(e => new { e.SourceSiteId, e.OccurredAtUtc })
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_AuditLog_Site_Occurred");
|
||||||
|
|
||||||
|
builder.HasIndex(e => e.CorrelationId)
|
||||||
|
.HasFilter("[CorrelationId] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_AuditLog_CorrelationId");
|
||||||
|
|
||||||
|
builder.HasIndex(e => new { e.Channel, e.Status, e.OccurredAtUtc })
|
||||||
|
.IsDescending(false, false, true)
|
||||||
|
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||||
|
|
||||||
|
builder.HasIndex(e => new { e.Target, e.OccurredAtUtc })
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasFilter("[Target] IS NOT NULL")
|
||||||
|
.HasDatabaseName("IX_AuditLog_Target_Occurred");
|
||||||
|
}
|
||||||
|
}
|
||||||
1553
src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
generated
Normal file
1553
src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle C (#23 M1): creates the centralized AuditLog table with monthly
|
||||||
|
/// partitioning and the two access-control roles documented in alog.md §4.
|
||||||
|
///
|
||||||
|
/// Structure:
|
||||||
|
/// 1. Partition function <c>pf_AuditLog_Month</c> (RANGE RIGHT) with 24
|
||||||
|
/// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC.
|
||||||
|
/// 2. Partition scheme <c>ps_AuditLog_Month</c> mapping every partition to
|
||||||
|
/// [PRIMARY] (dev/test parity; production may relocate via filegroups).
|
||||||
|
/// 3. <c>AuditLog</c> table created via raw SQL so it is created directly
|
||||||
|
/// on the partition scheme. The clustered PK is composite
|
||||||
|
/// {EventId, OccurredAtUtc} — required because partition-aligned PKs
|
||||||
|
/// must include the partition column.
|
||||||
|
/// 4. Five reconciliation/query indexes from alog.md §4, plus the
|
||||||
|
/// UX_AuditLog_EventId unique index that preserves single-column
|
||||||
|
/// EventId uniqueness for InsertIfNotExistsAsync (M1-T8). All
|
||||||
|
/// non-clustered indexes are partition-aligned on
|
||||||
|
/// <c>ps_AuditLog_Month(OccurredAtUtc)</c>.
|
||||||
|
/// 5. Two database roles:
|
||||||
|
/// - <c>scadalink_audit_writer</c>: INSERT + SELECT on AuditLog, with
|
||||||
|
/// explicit DENY on UPDATE and DELETE so additive role membership
|
||||||
|
/// (e.g. later db_datawriter) cannot accidentally re-enable mutation.
|
||||||
|
/// - <c>scadalink_audit_purger</c>: SELECT on AuditLog and ALTER on
|
||||||
|
/// SCHEMA::dbo so the purger can run ALTER PARTITION FUNCTION SWITCH
|
||||||
|
/// and SWITCH PARTITION when sliding the retention window.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddAuditLogTable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// 1) Partition function (monthly boundaries Jan 2026 – Dec 2027 UTC).
|
||||||
|
// RANGE RIGHT — the boundary value belongs to the right-hand partition,
|
||||||
|
// matching the convention used by SQL Server partition-switch tooling.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE PARTITION FUNCTION pf_AuditLog_Month (datetime2(7))
|
||||||
|
AS RANGE RIGHT FOR VALUES (
|
||||||
|
'2026-01-01T00:00:00', '2026-02-01T00:00:00', '2026-03-01T00:00:00', '2026-04-01T00:00:00',
|
||||||
|
'2026-05-01T00:00:00', '2026-06-01T00:00:00', '2026-07-01T00:00:00', '2026-08-01T00:00:00',
|
||||||
|
'2026-09-01T00:00:00', '2026-10-01T00:00:00', '2026-11-01T00:00:00', '2026-12-01T00:00:00',
|
||||||
|
'2027-01-01T00:00:00', '2027-02-01T00:00:00', '2027-03-01T00:00:00', '2027-04-01T00:00:00',
|
||||||
|
'2027-05-01T00:00:00', '2027-06-01T00:00:00', '2027-07-01T00:00:00', '2027-08-01T00:00:00',
|
||||||
|
'2027-09-01T00:00:00', '2027-10-01T00:00:00', '2027-11-01T00:00:00', '2027-12-01T00:00:00'
|
||||||
|
);");
|
||||||
|
|
||||||
|
// 2) Partition scheme mapping every partition to [PRIMARY].
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE PARTITION SCHEME ps_AuditLog_Month
|
||||||
|
AS PARTITION pf_AuditLog_Month ALL TO ([PRIMARY]);");
|
||||||
|
|
||||||
|
// 3) Create the table directly on the partition scheme. Column shapes
|
||||||
|
// are copied from AuditLogEntityTypeConfiguration so the live schema
|
||||||
|
// matches the EF model exactly. The clustered PK is composite to
|
||||||
|
// satisfy SQL Server's rule that partition-aligned clustered indexes
|
||||||
|
// must include the partition column.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE TABLE dbo.AuditLog (
|
||||||
|
EventId uniqueidentifier NOT NULL,
|
||||||
|
OccurredAtUtc datetime2(7) NOT NULL,
|
||||||
|
IngestedAtUtc datetime2(7) NULL,
|
||||||
|
Channel varchar(32) NOT NULL,
|
||||||
|
Kind varchar(32) NOT NULL,
|
||||||
|
CorrelationId uniqueidentifier NULL,
|
||||||
|
SourceSiteId varchar(64) NULL,
|
||||||
|
SourceInstanceId varchar(128) NULL,
|
||||||
|
SourceScript varchar(128) NULL,
|
||||||
|
Actor varchar(128) NULL,
|
||||||
|
Target varchar(256) NULL,
|
||||||
|
Status varchar(32) NOT NULL,
|
||||||
|
HttpStatus int NULL,
|
||||||
|
DurationMs int NULL,
|
||||||
|
ErrorMessage nvarchar(1024) NULL,
|
||||||
|
ErrorDetail nvarchar(max) NULL,
|
||||||
|
RequestSummary nvarchar(max) NULL,
|
||||||
|
ResponseSummary nvarchar(max) NULL,
|
||||||
|
PayloadTruncated bit NOT NULL,
|
||||||
|
Extra nvarchar(max) NULL,
|
||||||
|
ForwardState varchar(32) NULL,
|
||||||
|
CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc)
|
||||||
|
) ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
|
||||||
|
// 4) Reconciliation/query indexes from alog.md §4. All non-clustered
|
||||||
|
// indexes are partition-aligned on ps_AuditLog_Month(OccurredAtUtc)
|
||||||
|
// so partition-switch operations only touch a single partition. The
|
||||||
|
// filtered indexes carry their NOT NULL predicates as documented.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
|
||||||
|
ON dbo.AuditLog (OccurredAtUtc DESC)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
|
||||||
|
ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
|
||||||
|
ON dbo.AuditLog (CorrelationId)
|
||||||
|
WHERE CorrelationId IS NOT NULL
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
|
||||||
|
ON dbo.AuditLog (Channel ASC, Status ASC, OccurredAtUtc DESC)
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
|
||||||
|
ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
|
||||||
|
WHERE Target IS NOT NULL
|
||||||
|
ON ps_AuditLog_Month(OccurredAtUtc);");
|
||||||
|
|
||||||
|
// The EventId uniqueness index supports InsertIfNotExistsAsync
|
||||||
|
// (M1-T8). It is INTENTIONALLY non-aligned (placed on [PRIMARY]
|
||||||
|
// rather than ps_AuditLog_Month).
|
||||||
|
//
|
||||||
|
// SQL Server's rule for unique partition-aligned indexes is that the
|
||||||
|
// partition column must be a SUBSET of the index key. Including
|
||||||
|
// OccurredAtUtc in the key would change the uniqueness semantics
|
||||||
|
// from "EventId is globally unique" to "(EventId, OccurredAtUtc)
|
||||||
|
// is unique", which is the same guarantee the composite PK already
|
||||||
|
// provides — it would not give us single-column EventId uniqueness.
|
||||||
|
//
|
||||||
|
// Trade-off: a non-aligned index disables ALTER TABLE … SWITCH
|
||||||
|
// PARTITION on AuditLog. The M1 purge story (M2/M3) uses an
|
||||||
|
// explicit rebuild path that drops and re-creates this index
|
||||||
|
// around the switch, so the aligned-indexes pattern is preserved
|
||||||
|
// for partition switching at purge time.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
|
||||||
|
ON dbo.AuditLog (EventId)
|
||||||
|
ON [PRIMARY];");
|
||||||
|
|
||||||
|
// 5) DB roles. Both definitions are idempotent so the migration is
|
||||||
|
// safe to re-apply against a database that already has the role.
|
||||||
|
// The DENY UPDATE / DENY DELETE on the writer role is deliberate —
|
||||||
|
// a future db_datawriter membership cannot quietly re-enable
|
||||||
|
// mutation because DENY outranks GRANT.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NULL
|
||||||
|
EXEC sp_executesql N'CREATE ROLE scadalink_audit_writer';
|
||||||
|
GRANT INSERT ON dbo.AuditLog TO scadalink_audit_writer;
|
||||||
|
GRANT SELECT ON dbo.AuditLog TO scadalink_audit_writer;
|
||||||
|
DENY UPDATE ON dbo.AuditLog TO scadalink_audit_writer;
|
||||||
|
DENY DELETE ON dbo.AuditLog TO scadalink_audit_writer;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NULL
|
||||||
|
EXEC sp_executesql N'CREATE ROLE scadalink_audit_purger';
|
||||||
|
GRANT SELECT ON dbo.AuditLog TO scadalink_audit_purger;
|
||||||
|
GRANT ALTER ON SCHEMA::dbo TO scadalink_audit_purger;");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Drop in reverse dependency order so each statement's prerequisites
|
||||||
|
// still exist when it runs. Each DROP is guarded so a partial Up()
|
||||||
|
// (or a re-applied Down()) cannot fail on missing objects.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NOT NULL
|
||||||
|
EXEC sp_executesql N'DROP ROLE scadalink_audit_purger';
|
||||||
|
IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NOT NULL
|
||||||
|
EXEC sp_executesql N'DROP ROLE scadalink_audit_writer';");
|
||||||
|
|
||||||
|
// Indexes are dropped implicitly when the table goes away, but
|
||||||
|
// dropping them explicitly first keeps the Down() statement self-
|
||||||
|
// describing and mirrors the Up() shape.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Target_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Channel_Status_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_CorrelationId' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Site_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_OccurredAtUtc' AND object_id = OBJECT_ID('dbo.AuditLog'))
|
||||||
|
DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF OBJECT_ID('dbo.AuditLog', 'U') IS NOT NULL
|
||||||
|
DROP TABLE dbo.AuditLog;");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month')
|
||||||
|
DROP PARTITION SCHEME ps_AuditLog_Month;
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month')
|
||||||
|
DROP PARTITION FUNCTION pf_AuditLog_Month;");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,123 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
|
|||||||
b.ToTable("DataProtectionKeys");
|
b.ToTable("DataProtectionKeys");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("EventId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OccurredAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Actor")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Channel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CorrelationId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int?>("DurationMs")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorDetail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMessage")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("Extra")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ForwardState")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<int?>("HttpStatus")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("IngestedAtUtc")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<bool>("PayloadTruncated")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("RequestSummary")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseSummary")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceInstanceId")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceScript")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SourceSiteId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Target")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.IsUnicode(false)
|
||||||
|
.HasColumnType("varchar(256)");
|
||||||
|
|
||||||
|
b.HasKey("EventId", "OccurredAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("CorrelationId")
|
||||||
|
.HasDatabaseName("IX_AuditLog_CorrelationId")
|
||||||
|
.HasFilter("[CorrelationId] IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("EventId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_AuditLog_EventId");
|
||||||
|
|
||||||
|
b.HasIndex("OccurredAtUtc")
|
||||||
|
.IsDescending()
|
||||||
|
.HasDatabaseName("IX_AuditLog_OccurredAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("SourceSiteId", "OccurredAtUtc")
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_AuditLog_Site_Occurred");
|
||||||
|
|
||||||
|
b.HasIndex("Target", "OccurredAtUtc")
|
||||||
|
.IsDescending(false, true)
|
||||||
|
.HasDatabaseName("IX_AuditLog_Target_Occurred")
|
||||||
|
.HasFilter("[Target] IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("Channel", "Status", "OccurredAtUtc")
|
||||||
|
.IsDescending(false, false, true)
|
||||||
|
.HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
|
||||||
|
|
||||||
|
b.ToTable("AuditLog", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
|
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
|
||||||
|
/// for the append-only contract; this class only adds notes on the data-access
|
||||||
|
/// strategy used by each method.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogRepository : IAuditLogRepository
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
|
||||||
|
public AuditLogRepository(ScadaLinkDbContext context)
|
||||||
|
{
|
||||||
|
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a single <c>IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)</c>
|
||||||
|
/// via <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>.
|
||||||
|
/// Bypasses the EF change tracker so the row never enters a tracked state and
|
||||||
|
/// the enum-as-string conversion is done explicitly in C# (the columns are
|
||||||
|
/// declared <c>varchar(32)</c> via <c>HasConversion<string>()</c> in
|
||||||
|
/// <see cref="ScadaLink.ConfigurationDatabase.Configurations.AuditLogEntityTypeConfiguration"/>).
|
||||||
|
/// </summary>
|
||||||
|
public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (evt is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(evt));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enum columns are stored as varchar(32) (HasConversion<string>()), so do
|
||||||
|
// the conversion in C# rather than relying on parameter type inference —
|
||||||
|
// SqlClient would otherwise bind enums as int by default.
|
||||||
|
var channel = evt.Channel.ToString();
|
||||||
|
var kind = evt.Kind.ToString();
|
||||||
|
var status = evt.Status.ToString();
|
||||||
|
var forwardState = evt.ForwardState?.ToString();
|
||||||
|
|
||||||
|
// FormattableString interpolation parameterises every value (no concatenation),
|
||||||
|
// so this is safe against injection even for the string columns.
|
||||||
|
await _context.Database.ExecuteSqlInterpolatedAsync(
|
||||||
|
$@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
|
||||||
|
INSERT INTO dbo.AuditLog
|
||||||
|
(EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
|
||||||
|
SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
|
||||||
|
HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
|
||||||
|
ResponseSummary, PayloadTruncated, Extra, ForwardState)
|
||||||
|
VALUES
|
||||||
|
({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
|
||||||
|
{evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
|
||||||
|
{evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
|
||||||
|
{evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an <c>AsNoTracking</c> queryable over <see cref="AuditEvent"/>, applies
|
||||||
|
/// every non-null filter predicate, and pages by keyset on
|
||||||
|
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>. The keyset clause is expressed
|
||||||
|
/// directly (<c>occurred < after || (occurred == after && eventId.CompareTo(afterId) < 0)</c>)
|
||||||
|
/// — EF Core 10 translates <see cref="Guid.CompareTo(Guid)"/> against SQL Server's
|
||||||
|
/// <c>uniqueidentifier</c> sort order.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
|
||||||
|
AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (filter is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paging is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(paging));
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = _context.Set<AuditEvent>().AsNoTracking();
|
||||||
|
|
||||||
|
if (filter.Channel is { } channel)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.Channel == channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.Kind is { } kind)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.Kind == kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.Status is { } status)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.Status == status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.SourceSiteId))
|
||||||
|
{
|
||||||
|
var siteId = filter.SourceSiteId;
|
||||||
|
query = query.Where(e => e.SourceSiteId == siteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.Target))
|
||||||
|
{
|
||||||
|
var target = filter.Target;
|
||||||
|
query = query.Where(e => e.Target == target);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(filter.Actor))
|
||||||
|
{
|
||||||
|
var actor = filter.Actor;
|
||||||
|
query = query.Where(e => e.Actor == actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.CorrelationId is { } correlationId)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.CorrelationId == correlationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.FromUtc is { } fromUtc)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.OccurredAtUtc >= fromUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ToUtc is { } toUtc)
|
||||||
|
{
|
||||||
|
query = query.Where(e => e.OccurredAtUtc <= toUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyset cursor on (OccurredAtUtc desc, EventId desc).
|
||||||
|
if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
|
||||||
|
{
|
||||||
|
query = query.Where(e =>
|
||||||
|
e.OccurredAtUtc < afterOccurred
|
||||||
|
|| (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(e => e.OccurredAtUtc)
|
||||||
|
.ThenByDescending(e => e.EventId)
|
||||||
|
.Take(paging.PageSize)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M1 honest contract: throws <see cref="NotSupportedException"/>. The
|
||||||
|
/// <c>UX_AuditLog_EventId</c> unique index is non-aligned with
|
||||||
|
/// <c>ps_AuditLog_Month</c> (it lives on <c>[PRIMARY]</c> to keep
|
||||||
|
/// <see cref="InsertIfNotExistsAsync"/> cheap), and SQL Server rejects
|
||||||
|
/// <c>ALTER TABLE … SWITCH PARTITION</c> when a non-aligned index is present.
|
||||||
|
/// The drop-and-rebuild dance that makes the switch legal ships with the M6
|
||||||
|
/// purge actor.
|
||||||
|
/// </summary>
|
||||||
|
public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(
|
||||||
|
"AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " +
|
||||||
|
"unique index; the drop-and-rebuild dance ships in M6 (purge actor).");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
|||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||||
|
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
|
||||||
|
|
||||||
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
||||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||||
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
||||||
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||||
services.AddScoped<IAuditService, AuditService>();
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
||||||
|
|||||||
50
tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
Normal file
50
tests/ScadaLink.AuditLog.Tests/AddAuditLogTests.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Configuration;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle E (M1) smoke tests for the Audit Log (#23) DI scaffold. Verifies
|
||||||
|
/// <c>AddAuditLog</c> registers <see cref="AuditLogOptions"/> against the
|
||||||
|
/// <c>AuditLog</c> configuration section. Bundle E ships only the scaffold;
|
||||||
|
/// the validator + full options surface land in Task 9.
|
||||||
|
/// </summary>
|
||||||
|
public class AddAuditLogTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AddAuditLog_RegistersAuditLogOptions()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddAuditLog(config);
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
|
||||||
|
var opts = provider.GetService<IOptions<AuditLogOptions>>();
|
||||||
|
|
||||||
|
Assert.NotNull(opts);
|
||||||
|
Assert.NotNull(opts!.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAuditLog_NullServices_Throws()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder().Build();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => ServiceCollectionExtensions.AddAuditLog(null!, config));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddAuditLog_NullConfig_Throws()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>(
|
||||||
|
() => services.AddAuditLog(null!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.AuditLog.Configuration;
|
||||||
|
|
||||||
|
namespace ScadaLink.AuditLog.Tests.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Task 9 (Bundle E): <see cref="AuditLogOptions"/> binding + validator
|
||||||
|
/// behavior. The validator enforces invariants used by M2+ writers
|
||||||
|
/// (per <c>docs/plans/2026-05-20-auditlog-m1-foundation.md</c>):
|
||||||
|
/// <c>DefaultCapBytes > 0</c>, <c>ErrorCapBytes >= DefaultCapBytes</c>,
|
||||||
|
/// <c>RetentionDays in [30, 3650]</c>. Header-redact defaults match the
|
||||||
|
/// design doc (alog.md §6): Authorization, X-Api-Key, Cookie, Set-Cookie.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogOptionsTests
|
||||||
|
{
|
||||||
|
private static IOptions<AuditLogOptions> BuildOptions(Dictionary<string, string?> config)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(config)
|
||||||
|
.Build();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddAuditLog(configuration);
|
||||||
|
return services.BuildServiceProvider().GetRequiredService<IOptions<AuditLogOptions>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidBinding_PopulatesAllScalarFields()
|
||||||
|
{
|
||||||
|
var opts = BuildOptions(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:DefaultCapBytes"] = "4096",
|
||||||
|
["AuditLog:ErrorCapBytes"] = "32768",
|
||||||
|
["AuditLog:RetentionDays"] = "180",
|
||||||
|
}).Value;
|
||||||
|
|
||||||
|
Assert.Equal(4096, opts.DefaultCapBytes);
|
||||||
|
Assert.Equal(32768, opts.ErrorCapBytes);
|
||||||
|
Assert.Equal(180, opts.RetentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultsAreReasonable_WhenSectionEmpty()
|
||||||
|
{
|
||||||
|
var opts = BuildOptions(new Dictionary<string, string?>()).Value;
|
||||||
|
|
||||||
|
Assert.Equal(8192, opts.DefaultCapBytes);
|
||||||
|
Assert.Equal(65536, opts.ErrorCapBytes);
|
||||||
|
Assert.Equal(365, opts.RetentionDays);
|
||||||
|
Assert.Contains("Authorization", opts.HeaderRedactList);
|
||||||
|
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||||
|
Assert.Contains("Cookie", opts.HeaderRedactList);
|
||||||
|
Assert.Contains("Set-Cookie", opts.HeaderRedactList);
|
||||||
|
Assert.Empty(opts.GlobalBodyRedactors);
|
||||||
|
Assert.Empty(opts.PerTargetOverrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HeaderRedactList_BindsFromConfig_AppendsToDefaults()
|
||||||
|
{
|
||||||
|
// Microsoft.Extensions.Configuration's collection binder appends to a
|
||||||
|
// defaulted list (it does not replace it), so config-supplied entries
|
||||||
|
// augment the built-in redact list rather than overriding it. The
|
||||||
|
// built-in entries are the safety-net defaults documented on
|
||||||
|
// AuditLogOptions; supplying additional headers is the supported
|
||||||
|
// extension point.
|
||||||
|
var opts = BuildOptions(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:HeaderRedactList:0"] = "X-Custom-Auth",
|
||||||
|
["AuditLog:HeaderRedactList:1"] = "X-Tenant-Id",
|
||||||
|
}).Value;
|
||||||
|
|
||||||
|
Assert.Contains("X-Custom-Auth", opts.HeaderRedactList);
|
||||||
|
Assert.Contains("X-Tenant-Id", opts.HeaderRedactList);
|
||||||
|
Assert.Contains("Authorization", opts.HeaderRedactList);
|
||||||
|
Assert.Contains("X-Api-Key", opts.HeaderRedactList);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PerTargetOverrides_BindsFromConfig()
|
||||||
|
{
|
||||||
|
var opts = BuildOptions(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:PerTargetOverrides:CRM:CapBytes"] = "16384",
|
||||||
|
["AuditLog:PerTargetOverrides:CRM:AdditionalBodyRedactors:0"] = @"\d{16}",
|
||||||
|
}).Value;
|
||||||
|
|
||||||
|
Assert.True(opts.PerTargetOverrides.ContainsKey("CRM"));
|
||||||
|
var crm = opts.PerTargetOverrides["CRM"];
|
||||||
|
Assert.Equal(16384, crm.CapBytes);
|
||||||
|
Assert.NotNull(crm.AdditionalBodyRedactors);
|
||||||
|
Assert.Contains(@"\d{16}", crm.AdditionalBodyRedactors!);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidDefaultCapBytes_FailsValidation()
|
||||||
|
{
|
||||||
|
var result = new AuditLogOptionsValidator().Validate(
|
||||||
|
Options.DefaultName,
|
||||||
|
new AuditLogOptions { DefaultCapBytes = 0 });
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains("DefaultCapBytes", result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidErrorCapBytes_FailsValidation()
|
||||||
|
{
|
||||||
|
var result = new AuditLogOptionsValidator().Validate(
|
||||||
|
Options.DefaultName,
|
||||||
|
new AuditLogOptions { DefaultCapBytes = 1000, ErrorCapBytes = 100 });
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains("ErrorCapBytes", result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetentionDaysBelowMinimum_FailsValidation()
|
||||||
|
{
|
||||||
|
var result = new AuditLogOptionsValidator().Validate(
|
||||||
|
Options.DefaultName,
|
||||||
|
new AuditLogOptions { RetentionDays = 0 });
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RetentionDaysAboveMaximum_FailsValidation()
|
||||||
|
{
|
||||||
|
var result = new AuditLogOptionsValidator().Validate(
|
||||||
|
Options.DefaultName,
|
||||||
|
new AuditLogOptions { RetentionDays = 3651 });
|
||||||
|
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains("RetentionDays", result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultOptions_PassValidation()
|
||||||
|
{
|
||||||
|
var result = new AuditLogOptionsValidator().Validate(
|
||||||
|
Options.DefaultName,
|
||||||
|
new AuditLogOptions());
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded, result.FailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InvalidRetention_BoundViaConfig_RejectedOnValueAccess()
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AuditLog:RetentionDays"] = "0",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddAuditLog(configuration);
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var opts = provider.GetRequiredService<IOptions<AuditLogOptions>>();
|
||||||
|
|
||||||
|
var ex = Assert.Throws<OptionsValidationException>(() => _ = opts.Value);
|
||||||
|
Assert.Contains("RetentionDays", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../../src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
135
tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs
Normal file
135
tests/ScadaLink.Commons.Tests/Entities/Audit/AuditEventTests.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Entities.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies <see cref="AuditEvent"/> behaves as an init-only record:
|
||||||
|
/// every property reads back as constructed, and <c>with</c> expressions
|
||||||
|
/// produce a new instance with a single property changed.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditEventTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Construction_AllPropertiesReadBack()
|
||||||
|
{
|
||||||
|
var eventId = Guid.NewGuid();
|
||||||
|
var occurredAt = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
var ingestedAt = new DateTime(2026, 5, 20, 12, 0, 1, DateTimeKind.Utc);
|
||||||
|
var corrId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var evt = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = eventId,
|
||||||
|
OccurredAtUtc = occurredAt,
|
||||||
|
IngestedAtUtc = ingestedAt,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
CorrelationId = corrId,
|
||||||
|
SourceSiteId = "site-01",
|
||||||
|
SourceInstanceId = "inst-7",
|
||||||
|
SourceScript = "OnAlarm",
|
||||||
|
Actor = "system",
|
||||||
|
Target = "WeatherAPI",
|
||||||
|
Status = AuditStatus.Delivered,
|
||||||
|
HttpStatus = 200,
|
||||||
|
DurationMs = 42,
|
||||||
|
ErrorMessage = null,
|
||||||
|
ErrorDetail = null,
|
||||||
|
RequestSummary = "GET /forecast",
|
||||||
|
ResponseSummary = "{\"temp\":21}",
|
||||||
|
PayloadTruncated = false,
|
||||||
|
Extra = "{}",
|
||||||
|
ForwardState = AuditForwardState.Forwarded
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(eventId, evt.EventId);
|
||||||
|
Assert.Equal(occurredAt, evt.OccurredAtUtc);
|
||||||
|
Assert.Equal(ingestedAt, evt.IngestedAtUtc);
|
||||||
|
Assert.Equal(AuditChannel.ApiOutbound, evt.Channel);
|
||||||
|
Assert.Equal(AuditKind.ApiCall, evt.Kind);
|
||||||
|
Assert.Equal(corrId, evt.CorrelationId);
|
||||||
|
Assert.Equal("site-01", evt.SourceSiteId);
|
||||||
|
Assert.Equal("inst-7", evt.SourceInstanceId);
|
||||||
|
Assert.Equal("OnAlarm", evt.SourceScript);
|
||||||
|
Assert.Equal("system", evt.Actor);
|
||||||
|
Assert.Equal("WeatherAPI", evt.Target);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, evt.Status);
|
||||||
|
Assert.Equal(200, evt.HttpStatus);
|
||||||
|
Assert.Equal(42, evt.DurationMs);
|
||||||
|
Assert.Null(evt.ErrorMessage);
|
||||||
|
Assert.Null(evt.ErrorDetail);
|
||||||
|
Assert.Equal("GET /forecast", evt.RequestSummary);
|
||||||
|
Assert.Equal("{\"temp\":21}", evt.ResponseSummary);
|
||||||
|
Assert.False(evt.PayloadTruncated);
|
||||||
|
Assert.Equal("{}", evt.Extra);
|
||||||
|
Assert.Equal(AuditForwardState.Forwarded, evt.ForwardState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NullableProperties_AcceptNull()
|
||||||
|
{
|
||||||
|
var evt = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
IngestedAtUtc = null,
|
||||||
|
Channel = AuditChannel.Notification,
|
||||||
|
Kind = AuditKind.NotifySend,
|
||||||
|
CorrelationId = null,
|
||||||
|
SourceSiteId = null,
|
||||||
|
SourceInstanceId = null,
|
||||||
|
SourceScript = null,
|
||||||
|
Actor = null,
|
||||||
|
Target = null,
|
||||||
|
Status = AuditStatus.Submitted,
|
||||||
|
HttpStatus = null,
|
||||||
|
DurationMs = null,
|
||||||
|
ErrorMessage = null,
|
||||||
|
ErrorDetail = null,
|
||||||
|
RequestSummary = null,
|
||||||
|
ResponseSummary = null,
|
||||||
|
PayloadTruncated = false,
|
||||||
|
Extra = null,
|
||||||
|
ForwardState = null
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(evt.IngestedAtUtc);
|
||||||
|
Assert.Null(evt.CorrelationId);
|
||||||
|
Assert.Null(evt.SourceSiteId);
|
||||||
|
Assert.Null(evt.SourceInstanceId);
|
||||||
|
Assert.Null(evt.SourceScript);
|
||||||
|
Assert.Null(evt.Actor);
|
||||||
|
Assert.Null(evt.Target);
|
||||||
|
Assert.Null(evt.HttpStatus);
|
||||||
|
Assert.Null(evt.DurationMs);
|
||||||
|
Assert.Null(evt.ErrorMessage);
|
||||||
|
Assert.Null(evt.ErrorDetail);
|
||||||
|
Assert.Null(evt.RequestSummary);
|
||||||
|
Assert.Null(evt.ResponseSummary);
|
||||||
|
Assert.Null(evt.Extra);
|
||||||
|
Assert.Null(evt.ForwardState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void With_ProducesNewInstance_WithSingleFieldChanged()
|
||||||
|
{
|
||||||
|
var original = new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
Status = AuditStatus.Submitted,
|
||||||
|
PayloadTruncated = false
|
||||||
|
};
|
||||||
|
|
||||||
|
var updated = original with { Status = AuditStatus.Delivered };
|
||||||
|
|
||||||
|
Assert.NotSame(original, updated);
|
||||||
|
Assert.Equal(AuditStatus.Submitted, original.Status);
|
||||||
|
Assert.Equal(AuditStatus.Delivered, updated.Status);
|
||||||
|
Assert.Equal(original.EventId, updated.EventId);
|
||||||
|
Assert.NotEqual(original, updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Interfaces.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reflection-level contract guards for the Audit Log (#23) writer interfaces.
|
||||||
|
/// Locks in method signature so DI bindings + adapter implementations stay aligned.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditWriterContractTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(IAuditWriter))]
|
||||||
|
[InlineData(typeof(ICentralAuditWriter))]
|
||||||
|
public void WriteAsync_HasExpectedSignature(Type writerType)
|
||||||
|
{
|
||||||
|
var method = writerType.GetMethod("WriteAsync", BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
Assert.NotNull(method);
|
||||||
|
Assert.Equal(typeof(Task), method!.ReturnType);
|
||||||
|
|
||||||
|
var parameters = method.GetParameters();
|
||||||
|
Assert.Equal(2, parameters.Length);
|
||||||
|
Assert.Equal(typeof(AuditEvent), parameters[0].ParameterType);
|
||||||
|
Assert.Equal(typeof(CancellationToken), parameters[1].ParameterType);
|
||||||
|
Assert.True(parameters[1].HasDefaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IAuditWriter_AndICentralAuditWriter_AreDistinctTypes()
|
||||||
|
{
|
||||||
|
Assert.NotEqual(typeof(IAuditWriter), typeof(ICentralAuditWriter));
|
||||||
|
Assert.False(typeof(IAuditWriter).IsAssignableFrom(typeof(ICentralAuditWriter)));
|
||||||
|
Assert.False(typeof(ICentralAuditWriter).IsAssignableFrom(typeof(IAuditWriter)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Messages.Integration;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Messages.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log (#23) telemetry handoff: envelope + pull request/response DTOs.
|
||||||
|
/// At-least-once from sites; idempotent at central on <see cref="AuditEvent.EventId"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditTelemetryMessagesTests
|
||||||
|
{
|
||||||
|
private static AuditEvent MakeEvent(Guid? id = null) => new()
|
||||||
|
{
|
||||||
|
EventId = id ?? Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTime.UtcNow,
|
||||||
|
Channel = AuditChannel.ApiOutbound,
|
||||||
|
Kind = AuditKind.ApiCall,
|
||||||
|
Status = AuditStatus.Delivered,
|
||||||
|
PayloadTruncated = false
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditTelemetryEnvelope_ConstructsWithThreeEvents_AndIsEnumerable()
|
||||||
|
{
|
||||||
|
var envelopeId = Guid.NewGuid();
|
||||||
|
var events = new List<AuditEvent> { MakeEvent(), MakeEvent(), MakeEvent() };
|
||||||
|
|
||||||
|
var envelope = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||||
|
|
||||||
|
Assert.Equal(envelopeId, envelope.EnvelopeId);
|
||||||
|
Assert.Equal("site-01", envelope.SourceSiteId);
|
||||||
|
Assert.Equal(3, envelope.Events.Count);
|
||||||
|
|
||||||
|
// Enumerable round-trip
|
||||||
|
var collected = new List<AuditEvent>();
|
||||||
|
foreach (var e in envelope.Events)
|
||||||
|
{
|
||||||
|
collected.Add(e);
|
||||||
|
}
|
||||||
|
Assert.Equal(3, collected.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditTelemetryEnvelope_IsImmutable_RecordEqualityOnReferenceIdentityOfList()
|
||||||
|
{
|
||||||
|
// The record's value equality compares the IReadOnlyList reference; two envelopes
|
||||||
|
// built with the same list instance + same fields must be equal, but using a
|
||||||
|
// different list instance (even with equal content) must NOT be equal.
|
||||||
|
var events = new List<AuditEvent> { MakeEvent() } as IReadOnlyList<AuditEvent>;
|
||||||
|
var envelopeId = Guid.NewGuid();
|
||||||
|
var a = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||||
|
var b = new AuditTelemetryEnvelope(envelopeId, "site-01", events);
|
||||||
|
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
Assert.Equal(a.GetHashCode(), b.GetHashCode());
|
||||||
|
|
||||||
|
var withDifferentSite = a with { SourceSiteId = "site-02" };
|
||||||
|
Assert.NotEqual(a, withDifferentSite);
|
||||||
|
Assert.Equal("site-02", withDifferentSite.SourceSiteId);
|
||||||
|
Assert.Equal("site-01", a.SourceSiteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PullAuditEventsRequest_ConstructsAndIsImmutable()
|
||||||
|
{
|
||||||
|
var since = new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var request = new PullAuditEventsRequest("site-01", since, 100);
|
||||||
|
|
||||||
|
Assert.Equal("site-01", request.SourceSiteId);
|
||||||
|
Assert.Equal(since, request.SinceUtc);
|
||||||
|
Assert.Equal(100, request.BatchSize);
|
||||||
|
|
||||||
|
var bigger = request with { BatchSize = 500 };
|
||||||
|
Assert.Equal(100, request.BatchSize);
|
||||||
|
Assert.Equal(500, bigger.BatchSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PullAuditEventsResponse_ConstructsWithMoreAvailableTrue_AndIsEnumerable()
|
||||||
|
{
|
||||||
|
var events = new List<AuditEvent> { MakeEvent(), MakeEvent() };
|
||||||
|
var response = new PullAuditEventsResponse(events, MoreAvailable: true);
|
||||||
|
|
||||||
|
Assert.True(response.MoreAvailable);
|
||||||
|
Assert.Equal(2, response.Events.Count);
|
||||||
|
|
||||||
|
var collected = new List<AuditEvent>();
|
||||||
|
foreach (var e in response.Events)
|
||||||
|
{
|
||||||
|
collected.Add(e);
|
||||||
|
}
|
||||||
|
Assert.Equal(2, collected.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PullAuditEventsResponse_WithExpression_ChangesSingleField()
|
||||||
|
{
|
||||||
|
var response = new PullAuditEventsResponse(new List<AuditEvent>(), MoreAvailable: false);
|
||||||
|
var updated = response with { MoreAvailable = true };
|
||||||
|
|
||||||
|
Assert.False(response.MoreAvailable);
|
||||||
|
Assert.True(updated.MoreAvailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs
Normal file
72
tests/ScadaLink.Commons.Tests/Types/Enums/AuditEnumTests.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ScadaLink.Commons.Tests.Types.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asserts the exact member sets of the Audit Log (#23) enums.
|
||||||
|
/// Lock-in tests; any addition/removal/rename is a deliberate design change
|
||||||
|
/// that must come with a corresponding update to alog.md §4.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditEnumTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void AuditChannel_HasExactlyExpectedMembers()
|
||||||
|
{
|
||||||
|
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound" };
|
||||||
|
var actual = Enum.GetValues(typeof(AuditChannel))
|
||||||
|
.Cast<AuditChannel>()
|
||||||
|
.Select(x => x.ToString())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(expected.Length, actual.Length);
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditKind_HasExactlyTenExpectedMembers()
|
||||||
|
{
|
||||||
|
var expected = new[]
|
||||||
|
{
|
||||||
|
"ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached",
|
||||||
|
"NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure",
|
||||||
|
"CachedSubmit", "CachedResolve",
|
||||||
|
};
|
||||||
|
var actual = Enum.GetValues(typeof(AuditKind))
|
||||||
|
.Cast<AuditKind>()
|
||||||
|
.Select(x => x.ToString())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(10, actual.Length);
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditStatus_HasExactlyEightExpectedMembers()
|
||||||
|
{
|
||||||
|
var expected = new[]
|
||||||
|
{
|
||||||
|
"Submitted", "Forwarded", "Attempted", "Delivered",
|
||||||
|
"Failed", "Parked", "Discarded", "Skipped",
|
||||||
|
};
|
||||||
|
var actual = Enum.GetValues(typeof(AuditStatus))
|
||||||
|
.Cast<AuditStatus>()
|
||||||
|
.Select(x => x.ToString())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(8, actual.Length);
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AuditForwardState_HasExactlyExpectedMembers()
|
||||||
|
{
|
||||||
|
var expected = new[] { "Pending", "Forwarded", "Reconciled" };
|
||||||
|
var actual = Enum.GetValues(typeof(AuditForwardState))
|
||||||
|
.Cast<AuditForwardState>()
|
||||||
|
.Select(x => x.ToString())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Assert.Equal(expected.Length, actual.Length);
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Configurations;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests.Configurations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schema-level tests for <see cref="AuditLogEntityTypeConfiguration"/> (#23 M1 Bundle B).
|
||||||
|
/// Verifies that <see cref="AuditEvent"/> maps to the AuditLog table with the
|
||||||
|
/// PK, property set, column types/lengths, and five named indexes specified in alog.md §4.
|
||||||
|
/// Inspects EF model metadata via the existing in-memory SQLite test context — no
|
||||||
|
/// database round-trips required.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogEntityTypeConfigurationTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScadaLinkDbContext _context;
|
||||||
|
|
||||||
|
public AuditLogEntityTypeConfigurationTests()
|
||||||
|
{
|
||||||
|
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_context.Database.CloseConnection();
|
||||||
|
_context.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_MapsToAuditLogTable_WithCompositePrimaryKey()
|
||||||
|
{
|
||||||
|
// Composite PK {EventId, OccurredAtUtc} is required by the partitioned
|
||||||
|
// AuditLog table — the clustered key must include the partition column
|
||||||
|
// (OccurredAtUtc) so each row can be located in its partition (#23 Bundle C).
|
||||||
|
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||||
|
|
||||||
|
Assert.NotNull(entity);
|
||||||
|
Assert.Equal("AuditLog", entity!.GetTableName());
|
||||||
|
|
||||||
|
var pk = entity.FindPrimaryKey();
|
||||||
|
Assert.NotNull(pk);
|
||||||
|
|
||||||
|
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
|
||||||
|
Assert.Equal(new[] { nameof(AuditEvent.EventId), nameof(AuditEvent.OccurredAtUtc) }, pkPropertyNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_DeclaresUniqueIndex_OnEventIdAlone_ForIdempotencyLookups()
|
||||||
|
{
|
||||||
|
// EventId remains globally unique (the idempotency key for
|
||||||
|
// InsertIfNotExistsAsync, per M1-T8) via a dedicated unique index that
|
||||||
|
// is independent of the composite PK.
|
||||||
|
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||||
|
Assert.NotNull(entity);
|
||||||
|
|
||||||
|
var eventIdIndex = entity!.GetIndexes()
|
||||||
|
.SingleOrDefault(i => i.GetDatabaseName() == "UX_AuditLog_EventId");
|
||||||
|
|
||||||
|
Assert.NotNull(eventIdIndex);
|
||||||
|
Assert.True(eventIdIndex!.IsUnique);
|
||||||
|
|
||||||
|
var indexedProperty = Assert.Single(eventIdIndex.Properties);
|
||||||
|
Assert.Equal(nameof(AuditEvent.EventId), indexedProperty.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_HasExpectedPropertyCount()
|
||||||
|
{
|
||||||
|
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||||
|
Assert.NotNull(entity);
|
||||||
|
|
||||||
|
var properties = entity!.GetProperties()
|
||||||
|
.Where(p => !p.IsShadowProperty())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// AuditEvent record exposes 21 init-only properties (alog.md §4).
|
||||||
|
Assert.Equal(21, properties.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_ExpectedIndexes_WithCorrectNames()
|
||||||
|
{
|
||||||
|
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||||
|
Assert.NotNull(entity);
|
||||||
|
|
||||||
|
var indexNames = entity!.GetIndexes()
|
||||||
|
.Select(i => i.GetDatabaseName())
|
||||||
|
.OrderBy(n => n, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Five reconciliation/query indexes from alog.md §4, plus the EventId unique
|
||||||
|
// index introduced alongside the composite PK (Bundle C).
|
||||||
|
var expected = new[]
|
||||||
|
{
|
||||||
|
"IX_AuditLog_Channel_Status_Occurred",
|
||||||
|
"IX_AuditLog_CorrelationId",
|
||||||
|
"IX_AuditLog_OccurredAtUtc",
|
||||||
|
"IX_AuditLog_Site_Occurred",
|
||||||
|
"IX_AuditLog_Target_Occurred",
|
||||||
|
"UX_AuditLog_EventId",
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(expected, indexNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(nameof(AuditEvent.Channel))]
|
||||||
|
[InlineData(nameof(AuditEvent.Kind))]
|
||||||
|
[InlineData(nameof(AuditEvent.Status))]
|
||||||
|
[InlineData(nameof(AuditEvent.ForwardState))]
|
||||||
|
public void Configure_EnumColumns_StoredAsVarchar32(string propertyName)
|
||||||
|
{
|
||||||
|
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||||
|
Assert.NotNull(entity);
|
||||||
|
|
||||||
|
var property = entity!.FindProperty(propertyName);
|
||||||
|
Assert.NotNull(property);
|
||||||
|
|
||||||
|
// Enums are converted to strings (varchar(32) IsUnicode=false on SQL Server).
|
||||||
|
Assert.Equal(typeof(string), property!.GetProviderClrType() ?? property.ClrType);
|
||||||
|
Assert.Equal(32, property.GetMaxLength());
|
||||||
|
Assert.False(property.IsUnicode() ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_FilteredIndexes_HaveExpectedFilters()
|
||||||
|
{
|
||||||
|
var entity = _context.Model.FindEntityType(typeof(AuditEvent));
|
||||||
|
Assert.NotNull(entity);
|
||||||
|
|
||||||
|
var correlationIdx = entity!.GetIndexes()
|
||||||
|
.Single(i => i.GetDatabaseName() == "IX_AuditLog_CorrelationId");
|
||||||
|
Assert.Equal("[CorrelationId] IS NOT NULL", correlationIdx.GetFilter());
|
||||||
|
|
||||||
|
var targetIdx = entity.GetIndexes()
|
||||||
|
.Single(i => i.GetDatabaseName() == "IX_AuditLog_Target_Occurred");
|
||||||
|
Assert.Equal("[Target] IS NOT NULL", targetIdx.GetFilter());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle C (#23 M1) integration tests: applies the EF migrations to a
|
||||||
|
/// freshly-created MSSQL test database on the running infra/mssql container
|
||||||
|
/// and asserts that the AddAuditLogTable migration produced the expected
|
||||||
|
/// partition function, partition scheme, partition-aligned table, named
|
||||||
|
/// indexes, and DB roles.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Tests use <see cref="SkippableFactAttribute"/> + <c>Skip.IfNot(...)</c> from
|
||||||
|
/// the Xunit.SkippableFact package so the runner reports them as Skipped (not
|
||||||
|
/// Passed) when MSSQL is unreachable. xunit 2.9.x does not ship a native
|
||||||
|
/// <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> — those land in xunit v3 — so
|
||||||
|
/// SkippableFact is the canonical equivalent for this project. The fixture
|
||||||
|
/// applies the migration once at construction time.
|
||||||
|
/// </remarks>
|
||||||
|
public class AddAuditLogTableMigrationTests : IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public AddAuditLogTableMigrationTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesAuditLogTable()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var exists = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " +
|
||||||
|
"WHERE TABLE_NAME = 'AuditLog' AND TABLE_SCHEMA = 'dbo';");
|
||||||
|
|
||||||
|
Assert.Equal(1, exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesPartitionFunction_pf_AuditLog_Month()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var functionExists = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month';");
|
||||||
|
Assert.Equal(1, functionExists);
|
||||||
|
|
||||||
|
// Specification (alog.md §4 / Bundle C plan): 24 monthly boundaries
|
||||||
|
// covering 2026-01-01 through 2027-12-01 UTC.
|
||||||
|
var boundaryCount = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.partition_range_values rv " +
|
||||||
|
"INNER JOIN sys.partition_functions pf ON rv.function_id = pf.function_id " +
|
||||||
|
"WHERE pf.name = 'pf_AuditLog_Month';");
|
||||||
|
Assert.True(boundaryCount >= 24,
|
||||||
|
$"Expected at least 24 monthly boundaries on pf_AuditLog_Month; got {boundaryCount}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesPartitionScheme_ps_AuditLog_Month()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var schemeExists = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month';");
|
||||||
|
Assert.Equal(1, schemeExists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_TableIsPartitionAligned()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
// The clustered (PK) index on AuditLog must live on the ps_AuditLog_Month
|
||||||
|
// partition scheme; sys.indexes.data_space_id points at the scheme.
|
||||||
|
var schemeName = await ScalarAsync<string?>(
|
||||||
|
"SELECT ps.name FROM sys.indexes i " +
|
||||||
|
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||||
|
"INNER JOIN sys.partition_schemes ps ON i.data_space_id = ps.data_space_id " +
|
||||||
|
"WHERE o.name = 'AuditLog' AND i.index_id = 1;");
|
||||||
|
|
||||||
|
Assert.Equal("ps_AuditLog_Month", schemeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesFiveNamedIndexes()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var expected = new[]
|
||||||
|
{
|
||||||
|
"IX_AuditLog_OccurredAtUtc",
|
||||||
|
"IX_AuditLog_Site_Occurred",
|
||||||
|
"IX_AuditLog_CorrelationId",
|
||||||
|
"IX_AuditLog_Channel_Status_Occurred",
|
||||||
|
"IX_AuditLog_Target_Occurred",
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var indexName in expected)
|
||||||
|
{
|
||||||
|
var count = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.indexes i " +
|
||||||
|
"INNER JOIN sys.objects o ON i.object_id = o.object_id " +
|
||||||
|
$"WHERE o.name = 'AuditLog' AND i.name = '{indexName}';");
|
||||||
|
Assert.True(count == 1, $"Expected index '{indexName}' to exist on AuditLog; found {count}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesAuditWriterRole_WithExpectedGrants()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var roleExists = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_principals " +
|
||||||
|
"WHERE name = 'scadalink_audit_writer' AND type = 'R';");
|
||||||
|
Assert.Equal(1, roleExists);
|
||||||
|
|
||||||
|
// GRANT INSERT + GRANT SELECT must be present (G state = grant).
|
||||||
|
var insertGranted = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||||
|
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||||
|
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||||
|
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
|
||||||
|
" AND p.permission_name = 'INSERT' AND p.state IN ('G','W');");
|
||||||
|
Assert.Equal(1, insertGranted);
|
||||||
|
|
||||||
|
var selectGranted = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||||
|
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||||
|
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||||
|
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
|
||||||
|
" AND p.permission_name = 'SELECT' AND p.state IN ('G','W');");
|
||||||
|
Assert.Equal(1, selectGranted);
|
||||||
|
|
||||||
|
// UPDATE / DELETE must NOT be granted — and DENY (state = 'D') is even
|
||||||
|
// stronger. Treat presence of GRANT (state 'G' or 'W') as the failure.
|
||||||
|
var updateGranted = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||||
|
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||||
|
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||||
|
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
|
||||||
|
" AND p.permission_name = 'UPDATE' AND p.state IN ('G','W');");
|
||||||
|
Assert.Equal(0, updateGranted);
|
||||||
|
|
||||||
|
var deleteGranted = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||||
|
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||||
|
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||||
|
"WHERE pr.name = 'scadalink_audit_writer' AND o.name = 'AuditLog' " +
|
||||||
|
" AND p.permission_name = 'DELETE' AND p.state IN ('G','W');");
|
||||||
|
Assert.Equal(0, deleteGranted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AppliesMigration_CreatesAuditPurgerRole_WithExpectedGrants()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var roleExists = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_principals " +
|
||||||
|
"WHERE name = 'scadalink_audit_purger' AND type = 'R';");
|
||||||
|
Assert.Equal(1, roleExists);
|
||||||
|
|
||||||
|
// SELECT on AuditLog.
|
||||||
|
var selectGranted = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||||
|
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||||
|
"INNER JOIN sys.objects o ON p.major_id = o.object_id " +
|
||||||
|
"WHERE pr.name = 'scadalink_audit_purger' AND o.name = 'AuditLog' " +
|
||||||
|
" AND p.permission_name = 'SELECT' AND p.state IN ('G','W');");
|
||||||
|
Assert.Equal(1, selectGranted);
|
||||||
|
|
||||||
|
// ALTER on SCHEMA::dbo (class 3 = SCHEMA).
|
||||||
|
var alterSchema = await ScalarAsync<int>(
|
||||||
|
"SELECT COUNT(*) FROM sys.database_permissions p " +
|
||||||
|
"INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id " +
|
||||||
|
"INNER JOIN sys.schemas s ON p.major_id = s.schema_id " +
|
||||||
|
"WHERE pr.name = 'scadalink_audit_purger' AND s.name = 'dbo' " +
|
||||||
|
" AND p.class = 3 AND p.permission_name = 'ALTER' AND p.state IN ('G','W');");
|
||||||
|
Assert.Equal(1, alterSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task AuditWriterRole_CannotUpdateAuditLog()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
// Set up a dedicated user mapped to scadalink_audit_writer, then EXECUTE AS
|
||||||
|
// and attempt UPDATE — DENY UPDATE on the role must reject the statement.
|
||||||
|
// Use a guid-suffixed user name so reruns in the same fixture don't collide.
|
||||||
|
var testUser = $"audit_writer_smoke_{Guid.NewGuid():N}".Substring(0, 32);
|
||||||
|
|
||||||
|
await using (var setup = new SqlConnection(_fixture.ConnectionString))
|
||||||
|
{
|
||||||
|
await setup.OpenAsync();
|
||||||
|
await using var setupCmd = setup.CreateCommand();
|
||||||
|
setupCmd.CommandText =
|
||||||
|
$"CREATE USER [{testUser}] WITHOUT LOGIN; " +
|
||||||
|
$"ALTER ROLE scadalink_audit_writer ADD MEMBER [{testUser}];";
|
||||||
|
await setupCmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<SqlException>(async () =>
|
||||||
|
{
|
||||||
|
await using var conn = new SqlConnection(_fixture.ConnectionString);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
// WHERE 1=0 guarantees no rows are touched even if the permission check
|
||||||
|
// somehow passes — the test asserts the engine rejects the statement
|
||||||
|
// at permission-check time, not via a side effect on data.
|
||||||
|
cmd.CommandText =
|
||||||
|
$"EXECUTE AS USER = '{testUser}'; " +
|
||||||
|
$"UPDATE dbo.AuditLog SET Status = 'X' WHERE 1 = 0; " +
|
||||||
|
$"REVERT;";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
// SQL Server permission-denied errors carry number 229 (e.g. "The UPDATE
|
||||||
|
// permission was denied"). Assert the message mentions permission rather
|
||||||
|
// than pinning to the exact code, in case the engine version drifts.
|
||||||
|
Assert.Contains("permission", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task<T> ScalarAsync<T>(string sql)
|
||||||
|
{
|
||||||
|
await using var conn = _fixture.OpenConnection();
|
||||||
|
await using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
var result = await cmd.ExecuteScalarAsync();
|
||||||
|
if (result is null || result is DBNull)
|
||||||
|
{
|
||||||
|
return default!;
|
||||||
|
}
|
||||||
|
return (T)Convert.ChangeType(result, typeof(T) == typeof(string) ? typeof(string) : Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T))!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-test-class MSSQL fixture for the Bundle C integration tests (#23 M1).
|
||||||
|
///
|
||||||
|
/// Creates a fresh, uniquely-named test database on the running infra/mssql
|
||||||
|
/// container, applies the EF migrations against it, and drops it on dispose.
|
||||||
|
/// When MSSQL is not reachable (CI without the container), <see cref="Available"/>
|
||||||
|
/// is set to false and <see cref="SkipReason"/> describes why — tests pair
|
||||||
|
/// <c>[SkippableFact]</c> with <c>Skip.IfNot(_fixture.Available, _fixture.SkipReason)</c>
|
||||||
|
/// so the runner reports them as Skipped (not silently Passed).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// xunit 2.9.x has no native <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> (those
|
||||||
|
/// are v3); the project uses the Xunit.SkippableFact package as the canonical
|
||||||
|
/// equivalent. The fixture attempts connect + create-db + migrate once at
|
||||||
|
/// construct time. The Connect Timeout=3 in <see cref="DefaultAdminConnectionString"/>
|
||||||
|
/// makes the fixture fail fast in a no-container environment (under ~5s total)
|
||||||
|
/// instead of hanging 30s on SqlClient's default. Only connect-failure exceptions
|
||||||
|
/// (SqlException, plus the InvalidOperationException SqlClient raises from
|
||||||
|
/// OpenAsync) flip Available to false — every other exception bubbles up so a
|
||||||
|
/// real bug is not silently swallowed.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class MsSqlMigrationFixture : IDisposable
|
||||||
|
{
|
||||||
|
// Same credentials infra/mssql/setup.sql + docker-compose use. Not a committed
|
||||||
|
// production secret — this is a local dev container connection string.
|
||||||
|
// Connect Timeout=3 makes the fixture fail fast (~3s) in a no-container
|
||||||
|
// environment rather than hanging on SqlClient's default 30s connect timeout.
|
||||||
|
private const string DefaultAdminConnectionString =
|
||||||
|
"Server=localhost,1433;User Id=sa;Password=ScadaLink_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=3";
|
||||||
|
|
||||||
|
private const string AdminEnvVar = "SCADALINK_MSSQL_TEST_CONN";
|
||||||
|
|
||||||
|
public string DatabaseName { get; }
|
||||||
|
|
||||||
|
public string ConnectionString { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the MSSQL container was reachable at fixture construction
|
||||||
|
/// time AND the per-fixture test database was successfully created. When
|
||||||
|
/// false, the integration tests using this fixture must early-return.
|
||||||
|
/// </summary>
|
||||||
|
public bool Available { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populated when <see cref="Available"/> is false; describes why the
|
||||||
|
/// fixture chose to skip (env var unset, connect failed, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public string SkipReason { get; }
|
||||||
|
|
||||||
|
private readonly string _adminConnectionString;
|
||||||
|
|
||||||
|
public MsSqlMigrationFixture()
|
||||||
|
{
|
||||||
|
// Short, lowercase guid suffix keeps the database identifier under SQL Server's
|
||||||
|
// 128-char limit and safe for raw concatenation (no quoting required).
|
||||||
|
DatabaseName = $"ScadaLinkAuditMigTest_{Guid.NewGuid():N}".Substring(0, 38);
|
||||||
|
|
||||||
|
// Env var lets CI / power users override the admin endpoint; absent
|
||||||
|
// defaults to the local docker dev container's sa connection.
|
||||||
|
var fromEnv = Environment.GetEnvironmentVariable(AdminEnvVar);
|
||||||
|
_adminConnectionString = string.IsNullOrWhiteSpace(fromEnv)
|
||||||
|
? DefaultAdminConnectionString
|
||||||
|
: fromEnv;
|
||||||
|
|
||||||
|
// Step 1: open the admin connection. This is the only step that may
|
||||||
|
// legitimately fail when MSSQL is absent; SqlException + the rare
|
||||||
|
// InvalidOperationException from OpenAsync are the connect-failure
|
||||||
|
// surfaces we tolerate. Everything else (CREATE DATABASE, MigrateAsync)
|
||||||
|
// is treated as a hard fixture failure once we *have* a connection.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = new SqlConnection(_adminConnectionString);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
connection.Open();
|
||||||
|
}
|
||||||
|
catch (SqlException ex)
|
||||||
|
{
|
||||||
|
ConnectionString = string.Empty;
|
||||||
|
Available = false;
|
||||||
|
SkipReason = $"MSSQL unavailable (connect failed: SqlException {ex.Number}: {ex.Message})";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
ConnectionString = string.Empty;
|
||||||
|
Available = false;
|
||||||
|
SkipReason = $"MSSQL unavailable (OpenAsync threw: {ex.Message})";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var createCmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];";
|
||||||
|
createCmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName);
|
||||||
|
|
||||||
|
// Apply the EF migrations once at fixture construction so each test
|
||||||
|
// can read from a fully-migrated database without per-test setup.
|
||||||
|
// Failures here are real bugs — let them bubble.
|
||||||
|
ApplyMigrationsCore(ConnectionString, CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
Available = true;
|
||||||
|
SkipReason = string.Empty;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort cleanup if we created the database but failed before
|
||||||
|
// setting Available — otherwise Dispose() would skip the drop.
|
||||||
|
TryDropOrphanDatabase();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryDropOrphanDatabase()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(ConnectionString))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SqlConnection.ClearAllPools();
|
||||||
|
using var connection = new SqlConnection(_adminConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
$"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
|
||||||
|
$"BEGIN " +
|
||||||
|
$" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
|
||||||
|
$" DROP DATABASE [{DatabaseName}]; " +
|
||||||
|
$"END";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort — orphan databases carry a random guid suffix.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the EF migrations to the per-fixture test database via a freshly
|
||||||
|
/// constructed <see cref="ScadaLinkDbContext"/> pointed at it. Uses the
|
||||||
|
/// schema-only single-argument constructor — the AuditLog migration does
|
||||||
|
/// not write secret-bearing columns at apply time. Called once from the
|
||||||
|
/// constructor; tests do not invoke this directly.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task ApplyMigrationsCore(string connectionString, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(connectionString)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var context = new ScadaLinkDbContext(options);
|
||||||
|
await context.Database.MigrateAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Convenience for opening a fresh <see cref="SqlConnection"/> to the test
|
||||||
|
/// database. Caller is responsible for disposal.
|
||||||
|
/// </summary>
|
||||||
|
public SqlConnection OpenConnection()
|
||||||
|
{
|
||||||
|
ThrowIfUnavailable();
|
||||||
|
|
||||||
|
var connection = new SqlConnection(ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!Available)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort drop — never let a teardown failure pollute later runs.
|
||||||
|
// SINGLE_USER WITH ROLLBACK IMMEDIATE detaches lingering pooled connections
|
||||||
|
// so the DROP DATABASE doesn't fail with "database is in use".
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Connection-pool cleanup is necessary because EF's MigrateAsync leaves
|
||||||
|
// pooled connections behind; SqlConnection.ClearAllPools() forces them
|
||||||
|
// closed so the SINGLE_USER + DROP sequence below can complete.
|
||||||
|
SqlConnection.ClearAllPools();
|
||||||
|
|
||||||
|
using var connection = new SqlConnection(_adminConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText =
|
||||||
|
$"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
|
||||||
|
$"BEGIN " +
|
||||||
|
$" ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
|
||||||
|
$" DROP DATABASE [{DatabaseName}]; " +
|
||||||
|
$"END";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Swallow — the database name carries a random guid suffix so a
|
||||||
|
// stranded test database does not collide with future runs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Throws an <see cref="InvalidOperationException"/> when invoked on an
|
||||||
|
/// unavailable fixture; tests should branch on <see cref="Available"/>
|
||||||
|
/// before reaching this code path.
|
||||||
|
/// </summary>
|
||||||
|
private void ThrowIfUnavailable()
|
||||||
|
{
|
||||||
|
if (!Available)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"MsSqlMigrationFixture is not Available: {SkipReason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPerDbConnectionString(string adminConnectionString, string databaseName)
|
||||||
|
{
|
||||||
|
var builder = new SqlConnectionStringBuilder(adminConnectionString)
|
||||||
|
{
|
||||||
|
InitialCatalog = databaseName,
|
||||||
|
};
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ScadaLink.Commons.Entities.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Audit;
|
||||||
|
using ScadaLink.Commons.Types.Enums;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
||||||
|
using ScadaLink.ConfigurationDatabase.Tests.Migrations;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ScadaLink.ConfigurationDatabase.Tests.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bundle D (#23 M1) integration tests for <see cref="AuditLogRepository"/>. Uses
|
||||||
|
/// the same <see cref="MsSqlMigrationFixture"/> as the Bundle C migration tests so
|
||||||
|
/// raw-SQL paths (the IF NOT EXISTS insert, partition switch) execute against a
|
||||||
|
/// real partitioned schema. Tests scope all queries by a per-test
|
||||||
|
/// <c>SourceSiteId</c> guid suffix so they neither collide with one another nor
|
||||||
|
/// require cleanup.
|
||||||
|
/// </summary>
|
||||||
|
public class AuditLogRepositoryTests : IClassFixture<MsSqlMigrationFixture>
|
||||||
|
{
|
||||||
|
private readonly MsSqlMigrationFixture _fixture;
|
||||||
|
|
||||||
|
public AuditLogRepositoryTests(MsSqlMigrationFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task InsertIfNotExistsAsync_FreshEvent_WritesOneRow()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var evt = NewEvent(siteId, occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc));
|
||||||
|
await repo.InsertIfNotExistsAsync(evt);
|
||||||
|
|
||||||
|
// Re-read in a fresh context so we exercise the persisted row, not the
|
||||||
|
// (already-bypassed) change tracker.
|
||||||
|
await using var readContext = CreateContext();
|
||||||
|
var loaded = await readContext.Set<AuditEvent>()
|
||||||
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.Single(loaded);
|
||||||
|
Assert.Equal(evt.EventId, loaded[0].EventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task InsertIfNotExistsAsync_DuplicateEventId_IsNoOp_NoExceptionNoDuplicate()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var occurredAt = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc);
|
||||||
|
var first = NewEvent(siteId, occurredAtUtc: occurredAt, errorMessage: "first");
|
||||||
|
await repo.InsertIfNotExistsAsync(first);
|
||||||
|
|
||||||
|
// Same EventId, different payload — first-write-wins, the second call is silently a no-op.
|
||||||
|
var second = first with { ErrorMessage = "second-should-be-ignored" };
|
||||||
|
await repo.InsertIfNotExistsAsync(second);
|
||||||
|
|
||||||
|
await using var readContext = CreateContext();
|
||||||
|
var loaded = await readContext.Set<AuditEvent>()
|
||||||
|
.Where(e => e.SourceSiteId == siteId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
Assert.Single(loaded);
|
||||||
|
Assert.Equal("first", loaded[0].ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_ReturnsRowsInOccurredDescOrder()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(10)));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(20)));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(3, rows.Count);
|
||||||
|
Assert.True(rows[0].OccurredAtUtc > rows[1].OccurredAtUtc);
|
||||||
|
Assert.True(rows[1].OccurredAtUtc > rows[2].OccurredAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterByChannel()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0, channel: AuditChannel.ApiOutbound));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1), channel: AuditChannel.Notification));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(2), channel: AuditChannel.Notification));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(Channel: AuditChannel.Notification, SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.All(rows, r => Assert.Equal(AuditChannel.Notification, r.Channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterBySourceSiteId()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
var otherSiteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(1)));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(otherSiteId, occurredAtUtc: t0.AddMinutes(2)));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Equal(2, rows.Count);
|
||||||
|
Assert.All(rows, r => Assert.Equal(siteId, r.SourceSiteId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_FilterByTimeRange()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 4, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(30)));
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddHours(2)));
|
||||||
|
|
||||||
|
var rows = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(
|
||||||
|
SourceSiteId: siteId,
|
||||||
|
FromUtc: t0.AddMinutes(10),
|
||||||
|
ToUtc: t0.AddHours(1)),
|
||||||
|
new AuditLogPaging(PageSize: 10));
|
||||||
|
|
||||||
|
Assert.Single(rows);
|
||||||
|
Assert.Equal(t0.AddMinutes(30), rows[0].OccurredAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task QueryAsync_Keyset_NextPageStartsAfterCursor()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
var siteId = NewSiteId();
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
var t0 = new DateTime(2026, 5, 5, 9, 0, 0, DateTimeKind.Utc);
|
||||||
|
// Five rows at one-minute intervals. Page-size 2 → page 1 returns minutes 4,3.
|
||||||
|
// Cursor (minutes 3) → page 2 returns minutes 2,1. Cursor (minutes 1) → page 3 returns minute 0.
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
await repo.InsertIfNotExistsAsync(NewEvent(siteId, occurredAtUtc: t0.AddMinutes(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var page1 = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(PageSize: 2));
|
||||||
|
|
||||||
|
Assert.Equal(2, page1.Count);
|
||||||
|
Assert.Equal(t0.AddMinutes(4), page1[0].OccurredAtUtc);
|
||||||
|
Assert.Equal(t0.AddMinutes(3), page1[1].OccurredAtUtc);
|
||||||
|
|
||||||
|
var cursor = page1[^1];
|
||||||
|
var page2 = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(
|
||||||
|
PageSize: 2,
|
||||||
|
AfterOccurredAtUtc: cursor.OccurredAtUtc,
|
||||||
|
AfterEventId: cursor.EventId));
|
||||||
|
|
||||||
|
Assert.Equal(2, page2.Count);
|
||||||
|
Assert.Equal(t0.AddMinutes(2), page2[0].OccurredAtUtc);
|
||||||
|
Assert.Equal(t0.AddMinutes(1), page2[1].OccurredAtUtc);
|
||||||
|
|
||||||
|
var cursor2 = page2[^1];
|
||||||
|
var page3 = await repo.QueryAsync(
|
||||||
|
new AuditLogQueryFilter(SourceSiteId: siteId),
|
||||||
|
new AuditLogPaging(
|
||||||
|
PageSize: 2,
|
||||||
|
AfterOccurredAtUtc: cursor2.OccurredAtUtc,
|
||||||
|
AfterEventId: cursor2.EventId));
|
||||||
|
|
||||||
|
Assert.Single(page3);
|
||||||
|
Assert.Equal(t0.AddMinutes(0), page3[0].OccurredAtUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SkippableFact]
|
||||||
|
public async Task SwitchOutPartitionAsync_ThrowsNotSupported_ForM1()
|
||||||
|
{
|
||||||
|
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
|
||||||
|
|
||||||
|
await using var context = CreateContext();
|
||||||
|
var repo = new AuditLogRepository(context);
|
||||||
|
|
||||||
|
// The partition-switch path is intentionally blocked in M1 because
|
||||||
|
// UX_AuditLog_EventId is non-aligned. The drop-and-rebuild dance ships
|
||||||
|
// with the M6 purge actor.
|
||||||
|
var ex = await Assert.ThrowsAsync<NotSupportedException>(
|
||||||
|
() => repo.SwitchOutPartitionAsync(new DateTime(2026, 2, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||||
|
|
||||||
|
Assert.Contains("M6", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ------------------------------------------------------------
|
||||||
|
|
||||||
|
private ScadaLinkDbContext CreateContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
||||||
|
.UseSqlServer(_fixture.ConnectionString)
|
||||||
|
.Options;
|
||||||
|
return new ScadaLinkDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NewSiteId() =>
|
||||||
|
"test-bundle-d-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||||
|
|
||||||
|
private static AuditEvent NewEvent(
|
||||||
|
string siteId,
|
||||||
|
DateTime occurredAtUtc,
|
||||||
|
AuditChannel channel = AuditChannel.ApiOutbound,
|
||||||
|
AuditKind kind = AuditKind.ApiCall,
|
||||||
|
AuditStatus status = AuditStatus.Delivered,
|
||||||
|
string? errorMessage = null) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = occurredAtUtc,
|
||||||
|
Channel = channel,
|
||||||
|
Kind = kind,
|
||||||
|
Status = status,
|
||||||
|
SourceSiteId = siteId,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,13 +10,29 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
|
<!--
|
||||||
|
Bundle C migration integration tests need Microsoft.Data.SqlClient. EF
|
||||||
|
SqlServer 10.0.7 pulls in SqlClient >= 6.1.1, but the central package
|
||||||
|
version is pinned at 6.0.2 (the version the production
|
||||||
|
ExternalSystemGateway uses). Override the version locally for the test
|
||||||
|
project only; production assemblies are unaffected.
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" VersionOverride="6.1.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
|
<!--
|
||||||
|
SkippableFact lets the Bundle C MSSQL integration tests report as Skipped
|
||||||
|
(not Passed) when the dev MSSQL container is not running. xunit 2.9.x does
|
||||||
|
not ship Assert.Skip / SkipUnless — those are v3-only — so we use the
|
||||||
|
canonical community wrapper instead.
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Xunit.SkippableFact" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class ServiceCollectionExtensionsTests
|
|||||||
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
|
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
|
||||||
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
|
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
|
||||||
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
|
Assert.Contains(services, d => d.ServiceType == typeof(IInstanceLocator));
|
||||||
|
Assert.Contains(services, d => d.ServiceType == typeof(IAuditLogRepository));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly
|
// The no-arg overload is [Obsolete(error: true)], so it cannot be referenced directly
|
||||||
|
|||||||
Reference in New Issue
Block a user