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="xunit" Version="2.9.3" />
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ScadaLink.AuditLog/ScadaLink.AuditLog.csproj" />
|
||||
<Project Path="src/ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<Project Path="src/ScadaLink.Host/ScadaLink.Host.csproj" />
|
||||
<Project Path="src/ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
|
||||
@@ -22,6 +23,7 @@
|
||||
<Project Path="src/ScadaLink.CLI/ScadaLink.CLI.csproj" />
|
||||
</Folder>
|
||||
<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.Host.Tests/ScadaLink.Host.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.). |
|
||||
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
||||
| `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. |
|
||||
| `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). |
|
||||
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `DurationMs` | `int` NULL | Call/attempt duration. |
|
||||
| `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."
|
||||
- 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` |
|
||||
| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
|
||||
| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
|
||||
| `ApiInbound` | `Completed` (one row per request, written at request end with final status) |
|
||||
| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
|
||||
| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
|
||||
| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
|
||||
| `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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **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.
|
||||
- **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.)
|
||||
- **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`/`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.
|
||||
|
||||
### 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**:
|
||||
|
||||
@@ -243,13 +249,13 @@ Worked examples — what each `Channel`/`Kind` row actually looks like. (Other c
|
||||
```
|
||||
EventId = <new guid>
|
||||
Channel = ApiOutbound
|
||||
Kind = SyncCall
|
||||
Kind = ApiCall
|
||||
CorrelationId = NULL -- one-shot, no operation to correlate
|
||||
SourceSiteId = "site-01"
|
||||
SourceInstance = "Plant1.Boiler"
|
||||
SourceScript = "OnHourly"
|
||||
Target = "Weather/GetForecast"
|
||||
Status = Success
|
||||
Status = Delivered
|
||||
HttpStatus = 200
|
||||
DurationMs = 142
|
||||
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):
|
||||
|
||||
```
|
||||
1. Kind=CachedEnqueued Status=Enqueued CorrelationId=<tracked-op-id>
|
||||
2. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=<same>
|
||||
3. Kind=CachedAttempt Status=TransientFailure HttpStatus=500 CorrelationId=<same>
|
||||
4. Kind=CachedAttempt Status=Success HttpStatus=200 CorrelationId=<same>
|
||||
5. Kind=CachedTerminal Status=Delivered CorrelationId=<same>
|
||||
1. Kind=CachedSubmit Status=Submitted CorrelationId=<tracked-op-id>
|
||||
2. Kind=ApiCallCached Status=Forwarded CorrelationId=<same>
|
||||
3. Kind=ApiCallCached Status=Attempted HttpStatus=500 CorrelationId=<same>
|
||||
4. Kind=ApiCallCached Status=Attempted HttpStatus=500 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.
|
||||
@@ -274,10 +281,10 @@ The shadow of the `SiteCalls` row's lifecycle, but immutable and time-ordered.
|
||||
|
||||
```
|
||||
Channel = DbOutbound
|
||||
Kind = SyncWrite
|
||||
Kind = DbWrite
|
||||
Target = "PlantDB" -- connection name only, not server
|
||||
CorrelationId = NULL
|
||||
Status = Success
|
||||
Status = Delivered
|
||||
DurationMs = 9
|
||||
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
|
||||
@@ -288,23 +295,25 @@ Extra = '{"rowsAffected":1,"params":{"p0":"2026-05-20T14:00Z","p1":42.7
|
||||
|
||||
```
|
||||
Channel = DbOutbound
|
||||
Kind = SyncRead
|
||||
Status = Success
|
||||
Kind = DbWrite
|
||||
Status = Delivered
|
||||
DurationMs = 31
|
||||
RequestSummary = "SELECT id, value FROM Readings WHERE ts > @p0"
|
||||
Extra = '{"rowsReturned":42}'
|
||||
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
|
||||
|
||||
```
|
||||
1. Kind=Enqueued Status=Enqueued CorrelationId=<NotificationId> SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
|
||||
2. Kind=Attempt Status=TransientFailure ErrorMessage="SMTP 451 ..." CorrelationId=<same> SourceSiteId=NULL (dispatch is central)
|
||||
3. Kind=Attempt Status=Success CorrelationId=<same>
|
||||
4. Kind=Terminal Status=Delivered CorrelationId=<same>
|
||||
1. Kind=NotifySend Status=Submitted CorrelationId=<NotificationId> SourceSiteId="site-01" SourceInstance="Plant1.Boiler"
|
||||
2. Kind=NotifyDeliver Status=Attempted ErrorMessage="SMTP 451 ..." CorrelationId=<same> SourceSiteId=NULL (dispatch is central)
|
||||
3. Kind=NotifyDeliver Status=Attempted CorrelationId=<same>
|
||||
4. Kind=NotifyDeliver Status=Delivered CorrelationId=<same>
|
||||
Target = "OpsTeamEmail" -- notification list name
|
||||
Extra = '{"resolvedTargets":["a@x.com","b@x.com"], "subject":"Boiler high temp"}'
|
||||
RequestSummary = '...body, truncated...'
|
||||
@@ -318,20 +327,20 @@ One row per request, written at request completion:
|
||||
|
||||
```
|
||||
Channel = ApiInbound
|
||||
Kind = Completed
|
||||
Kind = InboundRequest
|
||||
CorrelationId = <request-id> -- the request's correlation header (or generated)
|
||||
SourceSiteId = NULL -- central-originated event
|
||||
Actor = "AcmeSCADA" -- API key name (NOT the key itself)
|
||||
Target = "RecordReading" -- inbound method name
|
||||
Status = Success | PermanentFailure -- mapped from final HTTP outcome
|
||||
HttpStatus = 200 | 400 | 401 | 500
|
||||
Status = Delivered | Failed -- mapped from final HTTP outcome
|
||||
HttpStatus = 200 | 400 | 500
|
||||
DurationMs = 73
|
||||
RequestSummary = '{"siteId":"...","value":12.4}' -- truncated; secrets/PII per redaction policy
|
||||
ResponseSummary= '{"ok":true}' -- full body on 5xx
|
||||
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
|
||||
- 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).
|
||||
- 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).
|
||||
- Actor (text search — inbound API key name).
|
||||
- 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:**
|
||||
- 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
|
||||
Three new tiles in an "Audit" KPI group:
|
||||
- **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.
|
||||
|
||||
### 10.4 Export
|
||||
@@ -516,12 +525,12 @@ Rough back-of-envelope; load testing will confirm.
|
||||
### 13.1 Per-site event rate (assumed nominal site)
|
||||
| Channel/Kind | Typ events/min | Peak events/min |
|
||||
|---|---:|---:|
|
||||
| `ApiOutbound.SyncCall` | 10 | 100 |
|
||||
| `ApiOutbound.Cached*` (~4 rows/op) | 4 | 20 |
|
||||
| `DbOutbound.SyncWrite` | 30 | 300 |
|
||||
| `DbOutbound.SyncRead` | 60 | 600 |
|
||||
| `DbOutbound.Cached*` (~4 rows/op) | 4 | 20 |
|
||||
| `Notification.Enqueued` (site-emit) | 1 | 10 |
|
||||
| `ApiOutbound.ApiCall` | 10 | 100 |
|
||||
| `ApiOutbound.ApiCallCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
|
||||
| `DbOutbound.DbWrite` (writes) | 30 | 300 |
|
||||
| `DbOutbound.DbWrite` (reads) | 60 | 600 |
|
||||
| `DbOutbound.DbWriteCached` (~4 rows/op incl. `CachedSubmit`/`CachedResolve`) | 4 | 20 |
|
||||
| `Notification.NotifySend` (site-emit) | 1 | 10 |
|
||||
| **Per-site total** | **~110** | **~1,050** |
|
||||
|
||||
### 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
|
||||
- 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).
|
||||
|
||||
---
|
||||
@@ -554,7 +563,7 @@ MS SQL handles this with batched ingest and the time-aligned indexes.
|
||||
|
||||
### 14.1 New Audit Log KPIs
|
||||
- **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.
|
||||
- **Top inbound callers** — top-10 `Actor` by request 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. |
|
||||
| 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`. |
|
||||
| 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.
|
||||
|
||||
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.). |
|
||||
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
||||
| `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. |
|
||||
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
||||
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
||||
| `SourceScript` | `varchar(128)` NULL | Script name within the instance. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `DurationMs` | `int` NULL | Call / attempt duration. |
|
||||
| `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".
|
||||
- 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` |
|
||||
| `DbOutbound` | `SyncWrite`, `SyncRead`, `CachedEnqueued`, `CachedAttempt`, `CachedTerminal` |
|
||||
| `Notification` | `Enqueued`, `Attempt`, `Terminal` |
|
||||
| `ApiInbound` | `Completed` — one row per request, written at request end with final status |
|
||||
| `ApiCall` | Sync `ExternalSystem.Call(...)` returns (success or permanent failure). One row per call. |
|
||||
| `ApiCallCached` | A cached outbound-API attempt records its forward-ack (`Forwarded`) or each retry (`Attempted`). |
|
||||
| `DbWrite` | Sync `Database.Connection().Execute*(...)` / `ExecuteReader(...)` completes. One row per call. |
|
||||
| `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
|
||||
rather than a multi-event lifecycle.
|
||||
Inbound API is intentionally collapsed to a single `InboundRequest` (or
|
||||
`InboundAuthFailure` for auth rejections) row per request rather than a
|
||||
multi-event lifecycle.
|
||||
|
||||
## The Site-Local `AuditLog` (SQLite)
|
||||
|
||||
@@ -178,18 +185,24 @@ pattern as Site Call Audit's reconciliation of `SiteCalls`.
|
||||
### Central direct-write (central-originated events)
|
||||
|
||||
Events originating at central never touch site SQLite. Inbound API writes one
|
||||
`ApiInbound.Completed` row via `ICentralAuditWriter` synchronously inside the
|
||||
request-handler middleware, before the HTTP response is flushed. The
|
||||
Notification Outbox dispatcher writes `Notification.Attempt` per delivery
|
||||
attempt and `Notification.Terminal` on terminal status. Central direct-writes
|
||||
use the same insert-if-not-exists semantics keyed on `EventId`.
|
||||
`ApiInbound.InboundRequest` row via `ICentralAuditWriter` synchronously inside
|
||||
the request-handler middleware, before the HTTP response is flushed; auth-layer
|
||||
rejections emit `ApiInbound.InboundAuthFailure` (`Status=Failed`, HTTP 401)
|
||||
instead. The Notification Outbox dispatcher writes
|
||||
`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
|
||||
|
||||
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
|
||||
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. 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.
|
||||
@@ -207,7 +220,7 @@ operational `SiteCalls` shape for the dispatcher and UI.
|
||||
## Payload Capture Policy
|
||||
|
||||
- **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
|
||||
bodies are never stored.
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
|
||||
[Notification Outbox](Component-NotificationOutbox.md) and
|
||||
@@ -350,19 +363,22 @@ global value in v1; per-channel overrides are deferred to v1.x.
|
||||
## Interactions
|
||||
|
||||
- **[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)
|
||||
per Cached Operations — Combined Telemetry.
|
||||
- **[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.
|
||||
per Cached Operations — Combined Telemetry, using kinds
|
||||
`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
|
||||
`ApiInbound.Completed` row per request from request-handler middleware,
|
||||
written directly to central via `ICentralAuditWriter` before the response is
|
||||
flushed.
|
||||
`ApiInbound.InboundRequest` row per successful request from request-handler
|
||||
middleware, written directly to central via `ICentralAuditWriter` before the
|
||||
response is flushed. Auth-layer rejections emit
|
||||
`ApiInbound.InboundAuthFailure` instead (`Status=Failed`, HTTP 401).
|
||||
- **[Notification Outbox (#21)](Component-NotificationOutbox.md)** — the
|
||||
site-emitted `Notification.Enqueued` row flows via audit telemetry; the
|
||||
central dispatcher writes `Notification.Attempt` (per delivery attempt) and
|
||||
`Notification.Terminal` (on terminal status) directly via
|
||||
`ICentralAuditWriter`. The operational `Notifications` table is unchanged.
|
||||
site-emitted `Notification.NotifySend` row (`Status=Submitted`) flows via
|
||||
audit telemetry; the central dispatcher writes `Notification.NotifyDeliver`
|
||||
rows directly via `ICentralAuditWriter` — `Status=Attempted` per delivery
|
||||
attempt, `Status=Delivered`/`Parked`/`Discarded` on terminal status. The
|
||||
operational `Notifications` table is unchanged.
|
||||
- **[Site Call Audit (#22)](Component-SiteCallAudit.md)** — shares the
|
||||
cached-call telemetry packet. Central ingest of that packet performs both the
|
||||
`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 |
|
||||
| HealthMonitoring | Yes | Yes | Yes | Yes | No |
|
||||
| ExternalSystemGateway | Yes | Yes | Yes | Yes | No |
|
||||
| AuditLog | Yes | Yes | Yes | Yes | No |
|
||||
| NotificationService | Yes | No | Yes | Yes | No |
|
||||
| NotificationOutbox | 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
|
||||
|
||||
- **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.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.
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
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
|
||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
|
||||
|
||||
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
@@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
|
||||
services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
||||
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
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>
|
||||
<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.SqlServer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<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>
|
||||
|
||||
@@ -18,6 +18,7 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(ITemplateEngineRepository));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IAuditService));
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user