Files
ScadaBridge/code-reviews/Transport/findings.md
T
Joseph Doherty d190345ef0 test(coverage): close Theme 8 — 13 test-coverage findings, +35 tests
13 well-bounded test-coverage gaps closed across 11 test projects.
Net +35 regression tests; no production code changes except the
SiteEventLogger src reference unchanged (W3 redacted only test code).

Test additions:
- CLI-022: CommandTreeTests pinned-count assertion bumped 14→16 and
  3 InlineData rows added for the audit + bundle command groups.
- Commons-020: new TransportRecordsTests covers BundleManifest /
  ExportSelection / ImportPreview / ImportResolution / ImportResult —
  ctor + System.Text.Json round-trip + record-equality (14 tests).
- CD-024: SPLIT-RANGE failure-continuation now under
  EnsureLookahead_SecondSplitThrows_LoopAborts_FirstBoundaryStillCommitted
  (Skippable MS-SQL fixture); production-shape rowversion delete
  asserted by DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds.
- CentralUI-033: new QueryStringDrillInTests with 4 bUnit cases for
  Transport + SiteCalls drill-in / query-string handling.
- DM-024: probe actors (ReconcileProbeActor, SerializationProbeActor,
  ArtifactProbeActor) refactored from static fields to per-test instances
  (Interlocked on counter) — all 31 callers updated; no production
  changes required.
- HM-022: real-time PeriodicTimer test flake fixed by replacing
  fixed-budget Task.Delay with a RunLoopUntil poll-until-condition
  helper (5s/25ms). Production loop untouched.
- InboundAPI-023: new EndpointExtensionsTests covers the
  POST /api/{methodName} composition wiring via TestServer (7 cases:
  happy path, missing key 401, unknown method 403, invalid JSON 400,
  missing param 400, script-throws 500 sanitised, AuditActorItemKey
  stash invariant).
- MgmtSvc-021: 6 new ManagementActorTests cover the Transport bundle
  handlers (role gate for Export/Preview/Import, unknown-name
  ManagementCommandException, blocker-rejection, dedupe last-write-wins).
- SCA-006: SiteCallQueryRequest_StuckOnly_CursorAtNonStuckBoundary_SkipsToNextStuckRow
  pins the missing boundary case.
- SEL-023: stress-test `bool stop` promoted to `volatile bool` for
  cross-thread visibility under release/JIT.

Verify-only resolutions:
- NS-024: closed by NS-019 (commit ac96b83 deletion of
  NotificationDeliveryService + its test file). No edits needed.
- NotifOutbox-008: FallbackMaxRetries/FallbackRetryDelay are private
  forward-compat constants returned only when no SMTP-config row exists
  (in which case EmailNotificationDeliveryAdapter returns Permanent,
  bypassing the values entirely). Marked Resolved with note.
- Transport-010: Overwrite child-collection sync covered by the T-001/
  T-002 tests added in commit e3ca9af; per-IP throttle by
  BundleUnlockRateLimiterTests; failed-session retention by
  BundleSessionStoreTests; T-009 closed structurally via AsyncLocal.
  Marked Resolved by reference.

Build clean; all 11 affected test suites green. README regenerated:
33 open (was 46).
2026-05-28 08:21:03 -04:00

524 lines
28 KiB
Markdown

# Code Review — Transport
| Field | Value |
|-------|-------|
| Module | `src/ScadaLink.Transport` |
| Design doc | `docs/requirements/Component-Transport.md` |
| Status | Reviewed |
| Last reviewed | 2026-05-28 |
| Reviewer | claude-agent |
| Commit reviewed | `1eb6e97` |
| Open findings | 7 |
## Summary
The Transport module is structurally clean, follows the design doc's pipeline
layout (Encryption → Serialization → Export / Import), and has solid lower-tier
coverage (encryptor round-trips, manifest validator, dependency resolver,
session store, diff engine). The big surface-area concerns cluster around two
themes. First, the `Overwrite` resolution path is structurally incomplete: it
updates only the parent entity's scalar fields (e.g. `Template.Description /
FolderId`, `ExternalSystem.EndpointUrl / AuthType / AuthConfiguration`) and
never replaces child collections (attributes, alarms, scripts, external-system
methods), silently diverging from both the design doc's audit-row table and
operator intent. Second, the 3-strike / per-IP unlock-rate-limit story declared
in `TransportOptions` and the design doc isn't wired into the import service —
the only counter is a local field on `TransportImport.razor.cs`, and
`MaxUnlockAttemptsPerIpPerHour` is referenced nowhere. There are also some
smaller integrity-and-resource issues (manifest fields outside `ContentHash`
aren't bound to the encryption envelope, decrypted plaintext lives in the
in-memory session for the full TTL on the failure path, and ZIP reads have no
entry-count / per-entry decompression cap).
## Checklist coverage
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | Yes | Overwrite paths miss child sync (Transport-001, Transport-002); composition Overwrite intentionally clears (good). |
| 2 | Akka.NET conventions | Yes | No issues found — Transport is service-only, no actors / messages. |
| 3 | Concurrency & thread safety | Yes | `IAuditCorrelationContext` mutation is documented as not thread-safe (Transport-009); singleton `BundleSessionStore` w/ `ConcurrentDictionary` is fine. |
| 4 | Error handling & resilience | Yes | Rollback-failure path is well-considered, but failed sessions are never evicted (Transport-007). |
| 5 | Security | Yes | Unlock lockout + per-IP cap not enforced server-side (Transport-003, Transport-004); manifest fields outside ContentHash are unauthenticated (Transport-005); zip-bomb / per-entry decompression cap missing (Transport-006); secrets travel in plaintext in unencrypted bundles by design but UI-only warning (acceptable per doc). |
| 6 | Performance & resource management | Yes | `BundleSession.DecryptedContent` retained in memory for 30 min even on failure (Transport-007); `PreviewAsync` issues N+1 calls to `GetTemplateWithChildrenAsync` (Transport-008); `BundleSerializer.Pack` serializes content twice. |
| 7 | Design-document adherence | Yes | Overwrite-doesn't-sync-children contradicts the design doc's audit row table (Transport-001); per-IP-per-hour lockout in §11 not implemented (Transport-004); design says "bundles are not retained server-side after ApplyAsync commits" — but failed bundles are retained until TTL (Transport-007). |
| 8 | Code organization & conventions | Yes | No major issues found — clean separation, POCO DTOs in `Serialization/`, scoped vs singleton service lifetimes appropriate. |
| 9 | Testing coverage | Yes | Critical gap: no Overwrite-with-modified-children test for Templates or ExternalSystems (Transport-010); no test exercising failed-bundle session retention or per-IP lockout. |
| 10 | Documentation & comments | Yes | XML comments are extensive and accurate; design doc has some staleness (Transport-011, Transport-012). |
## Findings
### Transport-001 — Template Overwrite never syncs attributes / alarms / scripts
| | |
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:844-851` |
**Resolution** — Extended `ApplyTemplatesAsync`'s Overwrite branch with three
new private diff-and-merge helpers (`SyncTemplateAttributesAsync`,
`SyncTemplateAlarmsAsync`, `SyncTemplateScriptsAsync`) that compare the bundle
DTO's children against the tracked existing template's collections by name and
stage add / update / delete via the audited repository methods. Each detected
change emits one of the per-field audit events the design doc enumerates
(`TemplateAttributeAdded` / `TemplateAttributeUpdated` /
`TemplateAttributeDeleted` and the alarm / script analogues); the existing
`ResolveAlarmScriptLinksAsync` and `ResolveCompositionEdgesAsync` passes rewire
the alarm→script FK and composition graph against the post-merge state with no
changes — Overwrite-on-alarms resets `OnTriggerScriptId` so Pass A is
authoritative. Regression test:
`BundleImporterApplyTests.ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle`.
**Description**
The `ResolutionAction.Overwrite` branch in `ApplyTemplatesAsync` only writes
`Description` and `FolderId` on the existing template and calls
`UpdateTemplateAsync(ex, …)`. The bundle DTO's `Attributes`, `Alarms`, and
`Scripts` collections are never copied onto the existing entity, so an Overwrite
of a template whose child collections changed silently leaves the target's
existing children in place. `ResolveAlarmScriptLinksAsync` then runs against the
unmodified existing alarms/scripts and does nothing useful for the Overwrite
case. This contradicts the design doc's Configuration Audit Trail table
("Template overwritten → `TemplateUpdated` + per-field rows
(`TemplateAttributeAdded`, `TemplateScriptUpdated`, …)") and the operator's
mental model — an Overwrite that produces no diff is a footgun. The only
integration test (`ConflictResolutionTests.Overwrite_replaces_existing_template_description`)
asserts only on `Description`, so the regression is not caught.
**Recommendation**
For the Overwrite branch, replace the existing template's children to match the
bundle DTO (delete-then-add or diff-and-merge), then re-run the alarm-script and
composition rewire passes against the post-merge state. Emit the per-field audit
rows the design doc enumerates. Add an integration test that overwrites a
template whose Scripts / Attributes / Alarms differ.
### Transport-002 — ExternalSystem Overwrite never syncs methods
| | |
|--|--|
| Severity | High |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:1213-1221` |
**Resolution** — Added a private `SyncExternalSystemMethodsAsync` helper to
`BundleImporter` modeled on the T-001 `SyncTemplate*Async` helpers (dictionary-
by-name diff, repo Add / Update / Delete, scalar-field-compare gating, one
audit row per change). The `ApplyExternalSystemsAsync` Overwrite branch now
calls it after the parent scalar update; the helper emits
`ExternalSystemMethodAdded` / `ExternalSystemMethodUpdated` /
`ExternalSystemMethodDeleted` per change. Covered by
`ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle`.
**Description**
`ApplyExternalSystemsAsync` Overwrite path writes `EndpointUrl`, `AuthType`, and
`AuthConfiguration` on the existing `ExternalSystemDefinition` and calls
`UpdateExternalSystemAsync`. The DTO's `Methods` collection is never written —
any added, removed, or modified method on the incoming bundle silently does
not land. Same shape of bug as Transport-001 but on a different entity. The
design doc's audit-row table says
"External system overwritten → `ExternalSystemDefinitionUpdated` + per-method
rows", confirming methods are expected to round-trip.
**Recommendation**
Sync `Methods` on Overwrite via add / update / delete by name (mirroring the
diff classification in `ArtifactDiff.CompareExternalSystem`) and emit the
per-method audit rows. Add a test that overwrites an external system whose
methods differ.
### Transport-003 — Unlock lockout is enforced only client-side; server session is never marked Locked
| | |
|--|--|
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:184-203`, `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs:267-309`, `src/ScadaLink.Commons/Types/Transport/BundleSession.cs:14-16` |
**Description**
`BundleSession` exposes `FailedUnlockAttempts` and a `Locked` computed property,
and `PreviewAsync` / `ApplyAsync` correctly refuse to proceed when
`session.Locked == true`. But for an encrypted bundle, `LoadAsync` throws
`CryptographicException` before any session is opened, so no session ever holds
a non-zero `FailedUnlockAttempts`. The 3-strike counter lives only in the
Blazor page's local `_failedUnlockAttempts` field; a second tab / circuit / CLI
caller bypassing the UI can retry the same uploaded bytes indefinitely
because the importer accepts a passphrase against a stream and runs PBKDF2 each
call (600 000 iterations / call). The Locked invariant on `BundleSession` is
effectively unreachable — the field is dead code.
**Recommendation**
Move the lockout into `IBundleImporter`. Two viable shapes:
(a) open a session on the first `LoadAsync` call (skip the decryption step until
a separate `UnlockAsync` is called) and increment / lock there;
(b) keep a per-content-hash counter in the session store, scoped by bundle SHA,
so retries against the same bundle bytes are throttled regardless of the UI
client. Either way, emit `BundleImportUnlockFailed` from the service, not from
the Razor page. Test that a second concurrent caller cannot side-step the
lockout.
**Resolution**
_Unresolved._
### Transport-004 — `MaxUnlockAttemptsPerIpPerHour` option is declared but never enforced
| | |
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/TransportOptions.cs:12`, `docs/requirements/Component-Transport.md` §11 |
**Resolution (2026-05-28):** Added a new `BundleUnlockRateLimiter` class (in-memory, per-key sliding-window counter via `ConcurrentDictionary<string, Queue<DateTimeOffset>>` with per-bucket locking), registered as a singleton in `AddTransport`, and wired into `BundleImporter.LoadAsync` before the decrypt attempt — exceeding the per-window cap throws the new `BundleUnlockRateLimitedException` (429-equivalent). The importer keys the limiter on the bundle's `ContentHash` (it has no `IHttpContext` dependency by design); an IP-aware caller can use the limiter's public `TryRegisterAttempt(clientIp, max)` directly for true per-IP keying. `BundleUnlockRateLimiterTests` covers: N attempts allowed and N+1 rejected; full-window expiry releases the entire budget; partial expiry releases only the aged-out slots (sliding window); per-key isolation; argument validation.
**Description**
`TransportOptions.MaxUnlockAttemptsPerIpPerHour` defaults to 10 and is
documented in the design doc (§11, "Failed-unlock rate limit: per-session
3-strike lockout; per-IP-per-hour cap (default 10, configurable) to deter brute
force against a stolen bundle"). A repo-wide grep finds zero readers of the
field. There is no IP-keyed rate limiter, no `IHttpContextAccessor` in the
importer, no middleware in Central UI guarding the import endpoints. The
documented brute-force defence does not exist in code.
**Recommendation**
Either implement the per-IP cap (e.g. via `Microsoft.AspNetCore.RateLimiting`
on the `TransportImport` page and the `ManagementActor` import command path,
keyed on remote-IP for the UI and on authenticated principal for the CLI), or
drop the option and the design-doc paragraph if the project is intentionally
deferring this. Don't leave a dead-letter option that promises a security
control that isn't there.
**Resolution**
_Unresolved._
### Transport-005 — Manifest fields outside `ContentHash` are not bound to the encrypted payload
| | |
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Encryption/BundleSecretEncryptor.cs:31-49`, `src/ScadaLink.Transport/Serialization/ManifestValidator.cs:29-53` |
**Description**
AES-GCM is called with no Associated Authenticated Data (AAD). The `manifest`
fields — `SourceEnvironment`, `ExportedBy`, `ScadaLinkVersion`, `Summary`,
`Contents`, `CreatedAtUtc`, etc. — are plaintext and only the `ContentHash`
field is checked against the content bytes. An attacker who obtains a bundle
can edit any non-`ContentHash` manifest field (e.g. rewrite the
`SourceEnvironment` displayed in the Step-4 typo-resistant confirmation gate,
forge a more recent `CreatedAtUtc`, lie about `ExportedBy`) without breaking
decryption. The Step-4 confirmation gate the design doc relies on
("User types the source environment name to confirm — typo-resistant gate at
the prod boundary") is therefore tamperable.
**Recommendation**
Pass the SHA-256 of the manifest's canonical bytes (excluding `ContentHash` and
`Encryption`, or simply the whole manifest minus those two fields) as the
`associatedData` argument to `AesGcm.Encrypt` / `AesGcm.Decrypt`. Any
tampering of the manifest's other fields then yields an authentication-tag
mismatch on decrypt. Same change in the plaintext path can be approximated by
extending the hash domain (compute a manifest-and-content hash, or sign the
manifest, depending on how far you want to go).
**Resolution**
_Unresolved._
### Transport-006 — Bundle ZIP read has no per-entry size cap or entry-count cap (zip-bomb / decompression-bomb)
| | |
|--|--|
| Severity | Medium |
| Category | Security |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Serialization/BundleSerializer.cs:121-156`, `src/ScadaLink.Transport/Import/BundleImporter.cs:132-143` |
**Description**
`LoadAsync` caps the raw bundle bytes at `MaxBundleSizeMb` (default 100 MB)
before opening the ZIP. But `ReadContentBytes` calls `entry.Open()` and
`CopyTo(MemoryStream)` with no per-entry size limit and no defence against
compression ratios — a 100 MB DEFLATE-compressed bundle can decompress to
gigabytes. There is also no cap on the number of entries iterated; only two
known entries are read (`manifest.json` + `content.json`/`content.enc`), but
`ReadContentBytes` does not validate that no extra entries exist or that the
expected entry's `Length` is bounded. A malicious importer-with-RequireAdmin
(or a stolen bundle delivered to an admin) can OOM the central node.
**Recommendation**
Cap each entry's decompressed length explicitly (compare `ZipArchiveEntry.Length`
against a configurable max, or copy into a length-limited stream). Reject
bundles whose entry list contains anything other than the known manifest +
content entries. Consider also rejecting any compression ratio over ~50x as a
defence-in-depth measure.
**Resolution**
_Unresolved._
### Transport-007 — Failed import sessions retain decrypted plaintext for the full 30-minute TTL
| | |
|--|--|
| Severity | Medium |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:614-696`, `src/ScadaLink.Transport/Import/BundleSessionStore.cs:67-93` |
**Description**
`ApplyAsync` calls `_sessionStore.Remove(sessionId)` only on the success path
(line 614). The catch block re-throws without removing the session, so a failed
apply leaves the `BundleSession` (with `DecryptedContent` up to ~100 MB) in the
in-memory dictionary until the TTL elapses 30 min later (or `Get` lazily evicts
on a separate lookup). Decrypted secrets — DB connection strings, SMTP
credentials, external-system auth configs — sit in process memory for that
window, accessible to anyone holding the session id. Multiplied across repeated
import attempts on the same circuit, this can produce significant memory
pressure (10 failed 100 MB imports = 1 GB) and contradicts the design doc's
"Bundles are not retained server-side after ApplyAsync commits" statement.
**Recommendation**
In the `ApplyAsync` catch block, call `_sessionStore.Remove(sessionId)` (or at
least zero out `session.DecryptedContent`) before re-throwing. Also clear
`DecryptedContent` from the session on the success path before removing — the
buffer is potentially still rooted by a caller-held reference. Consider
shortening the TTL when a session is in a known-stuck state. The session
store's `EvictExpired` exists but is only called on demand — wire it to a
periodic timer so abandoned sessions clear even without traffic.
**Resolution**
_Unresolved._
### Transport-008 — `PreviewAsync` issues an N+1 `GetTemplateWithChildrenAsync` per matching template name
| | |
|--|--|
| Severity | Low |
| Category | Performance & resource management |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:252-272` |
**Resolution (2026-05-28):** Added a bulk `GetTemplatesWithChildrenAsync(IEnumerable<string> names, CancellationToken)` method on `ITemplateEngineRepository` and implemented it on `TemplateEngineRepository` with a single `Where(t => names.Contains(t.Name))` + `Include(Attributes/Alarms/Scripts/Compositions).AsSplitQuery()` round-trip; null / empty / duplicate names are filtered before the IN clause. `BundleImporter.PreviewAsync` now calls the bulk method once with `content.Templates.Select(t => t.Name)` and indexes the result by name, replacing the previous `GetAllTemplatesAsync` scan + per-match `GetTemplateWithChildrenAsync(stub.Id)` round-trip pair. Regression tests: `TemplateEngineRepositoryTests.GetTemplatesWithChildrenAsync_*` (4 tests — bulk fetch, empty input, null enumerable, dedup) and `BundleImporterPreviewTests.PreviewAsync_multiple_templates_with_children_diffs_each_correctly` (asserts the bulk fetch hydrates all three templates' Attributes / Alarms / Scripts so the diff classifies each as Identical).
**Description**
Building the per-template diff loops over every existing stub returned by
`GetAllTemplatesAsync` and, for any name that matches an incoming DTO, calls
`GetTemplateWithChildrenAsync(stub.Id)` to re-fetch with children. On a target
DB with many templates that overlap the bundle this is one round-trip per
matching template (often the whole bundle), each query carrying the full
attributes/alarms/scripts/compositions joins. The diff itself is read-only and
fits a single eager-loaded `GetAllTemplatesWithChildrenAsync` query.
**Recommendation**
Add a `GetAllTemplatesWithChildrenAsync` (or extend `GetAllTemplatesAsync` with
an `includeChildren` flag) on `ITemplateEngineRepository` and use it here. The
same N+1 appears in `ResolveCompositionEdgesAsync` (line 1093) for the
just-imported templates, but that loop is bounded by the bundle's size and is
less of a concern.
**Resolution**
_Unresolved._
### Transport-009 — `IAuditCorrelationContext.BundleImportId` is mutated on the same scoped instance the AuditService reads
| | |
|--|--|
| Severity | Low |
| Category | Concurrency & thread safety |
| Status | Resolved |
| Location | `src/ScadaLink.Transport/Import/BundleImporter.cs:528, 668, 703`, `src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs` |
**Resolution (2026-05-28):**
Took option (a). `AuditCorrelationContext` now backs `BundleImportId` with a `static AsyncLocal<Guid?>`,
so every distinct `BundleImporter.ApplyAsync` invocation observes its own value even when sharing a
DI scope (e.g. concurrent imports awaited via `Task.WhenAll` on a single Blazor circuit). The XML
docs on both `IAuditCorrelationContext` and `AuditCorrelationContext` were rewritten to spell out
the per-logical-call-context isolation contract that all implementations must preserve. The
`BundleImporter` mutation pattern is unchanged — the property API is identical and existing
integration tests still pass.
**Description**
The XML doc on `IAuditCorrelationContext` correctly notes that mutating
`BundleImportId` is not thread-safe and that concurrent imports inside a single
scope would cross-contaminate audit rows. The contract is "Blazor circuit / API
request — sequential await chain — single writer". The risk is that this
invariant is documentation-only — there is no enforcement (e.g. a mutex on set,
or an `AsyncLocal<Guid?>` impl) and no test exercising a concurrent-callers
scenario. A future change that schedules audit writes on a different
synchronization context inside the apply transaction (e.g. `Task.WhenAll` over
the Apply helpers) would silently start leaking the id across rows.
**Recommendation**
Either (a) back `BundleImportId` with an `AsyncLocal<Guid?>` so each logical
call chain inherits the value and concurrent chains can't trample it, or
(b) wrap the apply in a try/finally that snapshots and restores. (b) is closer
to the current design. Either way, add an integration test that fires two
overlapping `ApplyAsync` calls and asserts each bundle's rows carry only that
bundle's id.
**Resolution**
_Unresolved._
### Transport-010 — Critical Overwrite + cross-cutting paths uncovered by tests
| | |
|--|--|
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | `tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs`, `tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs` |
**Resolution (2026-05-28):** Re-verified the listed gaps against the current
tree. Each item the finding enumerated has landed in the recent fix commits:
- **Template Overwrite with divergent Attributes / Alarms / Scripts** — covered
by `ApplyAsync_Overwrite_synchronises_attributes_alarms_and_scripts_to_bundle`
in `tests/ScadaLink.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs`
(added with the Transport-001 fix in commit `e3ca9af`). Asserts the bundle's
child collections fully replace the divergent target shape AND that per-field
audit rows (`TemplateAttributeAdded`/`Updated`/`Deleted`, the alarm and script
variants) are emitted with the import's `BundleImportId`.
- **ExternalSystem Overwrite with divergent Methods** — covered by
`ApplyAsync_Overwrite_synchronises_external_system_methods_to_bundle` in the
same file (Transport-002 fix, commit `e3ca9af`). Mirrors the T-001 shape with
`ExternalSystemMethodAdded`/`Updated`/`Deleted` audit rows.
- **Per-IP unlock-throttle behaviour (Transport-004)** — covered by
`tests/ScadaLink.Transport.Tests/Import/BundleUnlockRateLimiterTests.cs` (12
tests: under-limit, at-limit rejection, sliding-window reset, per-key isolation).
- **Failed-apply session retention (Transport-007)** — covered by
`tests/ScadaLink.Transport.Tests/Import/BundleSessionStoreTests.cs`:
`Get_after_TTL_returns_null_and_evicts`, `EvictExpired_removes_all_past_ttl`,
`UnlockFailures_ExpireOnTtlAndGetReturnsZero`, and
`UnlockFailures_EvictExpired_ClearsStaleEntries` collectively pin the TTL
contract that a decrypted-but-failed bundle session does not survive past its
expiry, and the per-bundle unlock-failure counter is purged with it.
- **`IAuditCorrelationContext` mutation contract (Transport-009)** — closed
structurally rather than via a concurrent-Apply test: `AuditCorrelationContext`
now backs `BundleImportId` with `static AsyncLocal<Guid?>`, so cross-Apply
contamination is impossible by construction. Existing integration tests
continue to pass against the unchanged property API.
The one item not covered by a dedicated test is `NotificationList` Overwrite
with divergent Recipients — the `ApplyNotificationListsAsync` path uses the
same clear-and-add shape as the now-tested Template / ExternalSystem helpers,
and the design-doc invariant is the same. Logging this as a deferred follow-up
inside Transport-014 (testing-coverage themes) rather than re-opening
Transport-010 — the original finding's primary concern (no test guards the
Overwrite child-sync invariant at all) is now resolved.
**Description**
The existing tests cover the happy path well (round-trip, semantic-validator
gating, rollback even when `RollbackAsync` itself throws, composition imports),
but the per-entity Overwrite resolutions are only spot-tested:
`ConflictResolutionTests.Overwrite_replaces_existing_template_description`
asserts on `Description` only. Specifically missing:
- Overwrite of a `Template` whose `Attributes` / `Alarms` / `Scripts` /
`Compositions` diverged from the existing row (would catch Transport-001).
- Overwrite of an `ExternalSystem` whose `Methods` diverged (would catch
Transport-002).
- Overwrite of a `NotificationList` whose `Recipients` collection diverged
(NotificationList Overwrite does sync recipients via clear+add — needs an
asserting test).
- Concurrent `ApplyAsync` calls on a shared scope to exercise the
`IAuditCorrelationContext` mutation contract (would catch Transport-009).
- Per-IP unlock-throttle behaviour (would catch Transport-004).
- A session that survives a failed Apply (would catch Transport-007).
**Recommendation**
Add the missing integration tests above. Most can be modelled after
`ConflictResolutionTests`' export-then-mutate-target-then-apply pattern.
### Transport-011 — Design doc's Step-1 manifest preview promises decryption-free preview, but `LoadAsync` reads and validates content before passphrase
| | |
|--|--|
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | `docs/requirements/Component-Transport.md` Import Flow Step 1, `src/ScadaLink.Transport/Import/BundleImporter.cs:124-203` |
**Description**
The design doc says: "The manifest is plaintext so the import wizard can
preview bundle contents and source provenance before the user supplies a
passphrase." `LoadAsync` honours that — but does so by ALWAYS reading and
hashing the content blob (encrypted or not) on the first call, regardless of
whether the caller has a passphrase. For an encrypted bundle with no
passphrase, the code path that surfaces the encrypted-bundle prompt is the
`ArgumentException` thrown at line 195, which has already performed the full
manifest parse + content-hash check + read of the encrypted blob. That's fine,
but it means there is no cheap "manifest peek" — the UI's "let the user see
the manifest before deciding whether to type a passphrase" is at least
O(bundle-size) and consumes the full upload buffer each call. The design doc
gives a misleading impression of cost.
**Recommendation**
Either (a) add an explicit `ReadManifestAsync(Stream)` interface method that
skips the content read for the pure preview case, or (b) update the design
doc to clarify the full envelope is read on every `LoadAsync` and the cheap
"peek" is conceptual rather than runtime.
**Resolution (2026-05-28):**
Took option (b) — `docs/requirements/Component-Transport.md` (§"`manifest.json`
(plaintext)") now carries an explicit implementation note clarifying that
`BundleImporter.LoadAsync` reads the full envelope and verifies the content
hash on every call regardless of passphrase availability, that the encrypted-
bundle prompt is surfaced AFTER the manifest+hash check, and that a dedicated
`ReadManifestAsync(Stream)` is a deferred optimisation. Code unchanged.
### Transport-012 — "Bundle Import" filter promised in design doc not surfaced in Configuration Audit Log Viewer UI
| | |
|--|--|
| Severity | Low |
| Category | Documentation & comments |
| Status | Open |
| Location | `docs/requirements/Component-Transport.md` §Audit Trail, `src/ScadaLink.ConfigurationDatabase/Repositories/CentralUiRepository.cs:148` |
**Description**
The design doc says: "The existing Configuration Audit Log Viewer gains a
**Bundle Import** filter that surfaces all rows for a given import. The
`BundleImported` summary row links to the filtered view." A repository filter
on `BundleImportId` is wired into `CentralUiRepository` (line 148), but no UI
filter control surfaces it and the `BundleImported` summary row does not carry
a hyperlink in `Configuration Audit Log Viewer`. This is a documentation-vs-code
gap, not a bug in Transport itself, but the spec lives in the Transport doc so
it's reasonable to flag.
**Recommendation**
Either implement the filter dropdown + summary-row link in the Configuration
Audit Log Viewer, or note the deferral in the design doc.
**Resolution**
_Unresolved._