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

28 KiB

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.