30-task plan covering ScadaLink.Transport project, AES-256-GCM bundle encryption, IAuditCorrelationContext for BundleImportId threading, TreeView checkbox-selection mode + TemplateFolderTree wrapper, two Central UI wizard pages, EF migration, integration tests, README + cross-reference updates. Single shipping slice, no feature flag.
1666 lines
60 KiB
Markdown
1666 lines
60 KiB
Markdown
# Transport (Component #24) Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Ship Component #24 (Transport) — file-based, encrypted bundle export/import of templates, system artifacts, and central-only configuration via the Central UI — as a single shipping slice.
|
||
|
||
**Architecture:** New `ScadaLink.Transport` project peer to Template Engine + Deployment Manager. Two new Central UI pages under the Design nav group. Persistence flows through existing audited repositories with a new scoped `IAuditCorrelationContext` carrying `BundleImportId`. AES-256-GCM + PBKDF2-SHA256 (600 000 iterations) for content encryption. No site changes.
|
||
|
||
**Tech Stack:** .NET 10, EF Core, ASP.NET Core / Blazor Server, xUnit + NSubstitute + FluentAssertions, in-memory EF for integration tests, existing `TreeView.razor` component extended with a checkbox-selection mode.
|
||
|
||
**Source design doc:** `docs/plans/2026-05-24-transport-design.md` (already committed).
|
||
|
||
**Key codebase facts that shape this plan:**
|
||
- Audit entity is `AuditLogEntry` (`src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs`), DbSet `_context.AuditLogEntries`.
|
||
- `IAuditService.LogAsync(user, action, entityType, entityId, entityName, afterState, ct)` — no existing correlation parameter; we add a scoped `IAuditCorrelationContext` rather than changing every repository signature.
|
||
- **No persisted `DeployedRevisionHash` on Instance.** `DeploymentService.CompareAsync` computes stale-vs-fresh live by comparing `snapshot.RevisionHash` to the current flattened-config hash. Overwriting a template naturally changes the hash, so the Deployments page surfaces affected instances automatically — **no explicit stale-mark write is required.**
|
||
- `TreeView.razor` exists at `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor` with single-select navigation. We extend it with a checkbox / multi-select mode rather than building a parallel widget.
|
||
- DI extension convention: `AddTransport()` in `ServiceCollectionExtensions.cs`, registered in `src/ScadaLink.Host/Program.cs` under "Central-only components".
|
||
- Test integration pattern: `ScadaLinkWebApplicationFactory : WebApplicationFactory<Program>` with in-memory EF.
|
||
|
||
**No feature flag** — backward compatible additive change; users who never open the new pages see no behavior difference.
|
||
|
||
---
|
||
|
||
## Task Index
|
||
|
||
| # | Task | Class | Time |
|
||
|---|---|---|---|
|
||
| 0 | Bundle DTOs in Commons | small | ~3 min |
|
||
| 1 | Transport interfaces | small | ~3 min |
|
||
| 2 | `BundleImportId` on `AuditLogEntry` + EF Fluent config | small | ~3 min |
|
||
| 3 | EF migration `AddBundleImportIdToAuditLog` | trivial | ~2 min |
|
||
| 4 | `IAuditCorrelationContext` scoped service | small | ~3 min |
|
||
| 5 | `AuditService` reads correlation context | small | ~3 min |
|
||
| 6 | `ScadaLink.Transport` project skeleton + slnx + DI | small | ~3 min |
|
||
| 7 | `TransportOptions` | trivial | ~2 min |
|
||
| 8 | `BundleSecretEncryptor` (AES-256-GCM + PBKDF2) + tests | standard | ~4 min |
|
||
| 9 | `ManifestBuilder` + manifest schema validation | standard | ~4 min |
|
||
| 10 | `EntitySerializer` — DTOs + secret carving | standard | ~5 min |
|
||
| 11 | `BundleSerializer` (ZIP packer + reader + content hash) | standard | ~4 min |
|
||
| 12 | `DependencyResolver` + topology tests | standard | ~5 min |
|
||
| 13 | `BundleSessionStore` (TTL + 3-strike lockout) | standard | ~4 min |
|
||
| 14 | `BundleExporter.ExportAsync` + audit + tests | standard | ~5 min |
|
||
| 15 | `BundleImporter.LoadAsync` | standard | ~4 min |
|
||
| 16 | `BundleImporter.PreviewAsync` (diff engine) | standard | ~5 min |
|
||
| 17 | `BundleImporter.ApplyAsync` (transaction + validation + audit) | high-risk | ~5 min |
|
||
| 18 | Host registration + transport options binding | trivial | ~2 min |
|
||
| 19 | `TreeView` checkbox-selection mode + tri-state propagation | standard | ~5 min |
|
||
| 20 | `TemplateFolderTree.razor` wrapper + Templates.razor refactor | standard | ~4 min |
|
||
| 21 | `TransportExport.razor` wizard | high-risk | ~5 min |
|
||
| 22 | `TransportImport.razor` wizard | high-risk | ~5 min |
|
||
| 23 | NavMenu entries + UI auth attributes verified | small | ~3 min |
|
||
| 24 | Audit log "Bundle Import" filter on existing page | small | ~3 min |
|
||
| 25 | Integration: export → wipe → import round-trip | standard | ~5 min |
|
||
| 26 | Integration: conflict resolutions + rollback on validation fail | standard | ~5 min |
|
||
| 27 | `Component-Transport.md` design doc | small | ~3 min |
|
||
| 28 | README + cross-reference updates | small | ~3 min |
|
||
| 29 | Manual cluster verification checklist | trivial | ~3 min |
|
||
|
||
---
|
||
|
||
## Task 0: Bundle DTOs in Commons
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 1, Task 2, Task 4, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/BundleManifest.cs`
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/BundleSummary.cs`
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/EncryptionMetadata.cs`
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/ManifestContentEntry.cs`
|
||
|
||
**Step 1: Write the DTOs**
|
||
|
||
`BundleManifest.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public sealed record BundleManifest(
|
||
int BundleFormatVersion,
|
||
string SchemaVersion,
|
||
DateTimeOffset CreatedAtUtc,
|
||
string SourceEnvironment,
|
||
string ExportedBy,
|
||
string ScadaLinkVersion,
|
||
string ContentHash,
|
||
EncryptionMetadata? Encryption,
|
||
BundleSummary Summary,
|
||
IReadOnlyList<ManifestContentEntry> Contents);
|
||
```
|
||
|
||
`EncryptionMetadata.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public sealed record EncryptionMetadata(
|
||
string Algorithm, // "AES-256-GCM"
|
||
string Kdf, // "PBKDF2-SHA256"
|
||
int Iterations,
|
||
string SaltB64,
|
||
string IvB64);
|
||
```
|
||
|
||
`BundleSummary.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public sealed record BundleSummary(
|
||
int Templates,
|
||
int TemplateFolders,
|
||
int SharedScripts,
|
||
int ExternalSystems,
|
||
int DbConnections,
|
||
int NotificationLists,
|
||
int SmtpConfigs,
|
||
int ApiKeys,
|
||
int ApiMethods);
|
||
```
|
||
|
||
`ManifestContentEntry.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public sealed record ManifestContentEntry(
|
||
string Type,
|
||
string Name,
|
||
int Version,
|
||
IReadOnlyList<string> DependsOn);
|
||
```
|
||
|
||
**Step 2: Build**
|
||
|
||
Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj`
|
||
Expected: build succeeds.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.Commons/Types/Transport/
|
||
git commit -m "feat(transport): add bundle manifest DTOs in Commons"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Transport interfaces
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 0, Task 2, Task 4, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/ExportSelection.cs`
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/ImportPreview.cs`
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/ImportResolution.cs`
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/ImportResult.cs`
|
||
- Create: `src/ScadaLink.Commons/Types/Transport/BundleSession.cs`
|
||
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleExporter.cs`
|
||
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleImporter.cs`
|
||
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IBundleSessionStore.cs`
|
||
- Create: `src/ScadaLink.Commons/Interfaces/Transport/IAuditCorrelationContext.cs`
|
||
|
||
**Step 1: Write the types and interfaces**
|
||
|
||
`ExportSelection.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public sealed record ExportSelection(
|
||
IReadOnlyList<int> TemplateIds,
|
||
IReadOnlyList<int> SharedScriptIds,
|
||
IReadOnlyList<int> ExternalSystemIds,
|
||
IReadOnlyList<int> DatabaseConnectionIds,
|
||
IReadOnlyList<int> NotificationListIds,
|
||
IReadOnlyList<int> SmtpConfigurationIds,
|
||
IReadOnlyList<int> ApiKeyIds,
|
||
IReadOnlyList<int> ApiMethodIds,
|
||
bool IncludeDependencies);
|
||
```
|
||
|
||
`ImportPreview.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public enum ConflictKind { Identical, Modified, New, Blocker }
|
||
|
||
public sealed record ImportPreviewItem(
|
||
string EntityType,
|
||
string Name,
|
||
int? ExistingVersion,
|
||
int? IncomingVersion,
|
||
ConflictKind Kind,
|
||
string? FieldDiffJson,
|
||
string? BlockerReason);
|
||
|
||
public sealed record ImportPreview(
|
||
Guid SessionId,
|
||
IReadOnlyList<ImportPreviewItem> Items);
|
||
```
|
||
|
||
`ImportResolution.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public enum ResolutionAction { Add, Overwrite, Skip, Rename }
|
||
|
||
public sealed record ImportResolution(
|
||
string EntityType,
|
||
string Name,
|
||
ResolutionAction Action,
|
||
string? RenameTo);
|
||
```
|
||
|
||
`ImportResult.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public sealed record ImportResult(
|
||
Guid BundleImportId,
|
||
int Added,
|
||
int Overwritten,
|
||
int Skipped,
|
||
int Renamed,
|
||
IReadOnlyList<int> StaleInstanceIds,
|
||
string AuditEventCorrelation);
|
||
```
|
||
|
||
`BundleSession.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Types.Transport;
|
||
|
||
public sealed class BundleSession
|
||
{
|
||
public Guid SessionId { get; init; }
|
||
public BundleManifest Manifest { get; init; } = null!;
|
||
public byte[] DecryptedContent { get; init; } = Array.Empty<byte>();
|
||
public DateTimeOffset ExpiresAt { get; init; }
|
||
public int FailedUnlockAttempts { get; set; }
|
||
public bool Locked => FailedUnlockAttempts >= 3;
|
||
}
|
||
```
|
||
|
||
`IBundleExporter.cs`:
|
||
```csharp
|
||
using ScadaLink.Commons.Types.Transport;
|
||
|
||
namespace ScadaLink.Commons.Interfaces.Transport;
|
||
|
||
public interface IBundleExporter
|
||
{
|
||
Task<Stream> ExportAsync(
|
||
ExportSelection selection,
|
||
string user,
|
||
string sourceEnvironment,
|
||
string? passphrase,
|
||
CancellationToken cancellationToken = default);
|
||
}
|
||
```
|
||
|
||
`IBundleImporter.cs`:
|
||
```csharp
|
||
using ScadaLink.Commons.Types.Transport;
|
||
|
||
namespace ScadaLink.Commons.Interfaces.Transport;
|
||
|
||
public interface IBundleImporter
|
||
{
|
||
Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default);
|
||
Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default);
|
||
Task<ImportResult> ApplyAsync(
|
||
Guid sessionId,
|
||
IReadOnlyList<ImportResolution> resolutions,
|
||
string user,
|
||
CancellationToken ct = default);
|
||
}
|
||
```
|
||
|
||
`IBundleSessionStore.cs`:
|
||
```csharp
|
||
using ScadaLink.Commons.Types.Transport;
|
||
|
||
namespace ScadaLink.Commons.Interfaces.Transport;
|
||
|
||
public interface IBundleSessionStore
|
||
{
|
||
BundleSession Open(BundleSession session);
|
||
BundleSession? Get(Guid sessionId);
|
||
void Remove(Guid sessionId);
|
||
void EvictExpired();
|
||
}
|
||
```
|
||
|
||
`IAuditCorrelationContext.cs`:
|
||
```csharp
|
||
namespace ScadaLink.Commons.Interfaces.Transport;
|
||
|
||
/// <summary>
|
||
/// Scoped service the bundle importer sets to thread a BundleImportId through to
|
||
/// the audit log entries emitted by the audited repository methods invoked during
|
||
/// ApplyAsync. AuditService reads this and stamps every AuditLogEntry it writes.
|
||
/// </summary>
|
||
public interface IAuditCorrelationContext
|
||
{
|
||
Guid? BundleImportId { get; set; }
|
||
}
|
||
```
|
||
|
||
**Step 2: Build**
|
||
|
||
Run: `dotnet build src/ScadaLink.Commons/ScadaLink.Commons.csproj`
|
||
Expected: build succeeds.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.Commons/Types/Transport/ src/ScadaLink.Commons/Interfaces/Transport/
|
||
git commit -m "feat(transport): add IBundleExporter / IBundleImporter interfaces"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `BundleImportId` on `AuditLogEntry` + EF Fluent config
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 0, Task 1, Task 19
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs` (add nullable `BundleImportId`)
|
||
- Modify: `src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs` (add Fluent config in `OnModelCreating` near the existing `AuditLogEntry` mapping; add `IX_AuditLogEntries_BundleImportId` non-clustered index)
|
||
|
||
**Step 1: Add property**
|
||
|
||
In `AuditLogEntry.cs`, after `Timestamp`:
|
||
```csharp
|
||
public Guid? BundleImportId { get; set; }
|
||
```
|
||
|
||
**Step 2: Add Fluent config + index**
|
||
|
||
In `ScadaLinkDbContext.OnModelCreating`, find the existing `modelBuilder.Entity<AuditLogEntry>(...)` block and add:
|
||
```csharp
|
||
e.HasIndex(x => x.BundleImportId).HasDatabaseName("IX_AuditLogEntries_BundleImportId");
|
||
```
|
||
|
||
If no existing block, add one near other audit configs.
|
||
|
||
**Step 3: Build**
|
||
|
||
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
|
||
Expected: build succeeds.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.Commons/Entities/Audit/AuditLogEntry.cs \
|
||
src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs
|
||
git commit -m "feat(transport): add BundleImportId column on AuditLogEntry"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: EF migration `AddBundleImportIdToAuditLog`
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~2 min
|
||
**Parallelizable with:** none (depends on Task 2)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.cs` (generated)
|
||
- Create: `src/ScadaLink.ConfigurationDatabase/Migrations/<timestamp>_AddBundleImportIdToAuditLog.Designer.cs` (generated)
|
||
|
||
**Step 1: Generate**
|
||
|
||
Run:
|
||
```bash
|
||
dotnet ef migrations add AddBundleImportIdToAuditLog \
|
||
-p src/ScadaLink.ConfigurationDatabase \
|
||
-s src/ScadaLink.Host
|
||
```
|
||
Expected: two files generated under `Migrations/` with naming pattern `yyyyMMddHHmmss_AddBundleImportIdToAuditLog.cs`.
|
||
|
||
**Step 2: Sanity-check the migration**
|
||
|
||
Open the generated `.cs` file. It must:
|
||
- Call `AddColumn<Guid>(...)` for `BundleImportId` with `nullable: true`.
|
||
- Call `CreateIndex(...)` for `IX_AuditLogEntries_BundleImportId`.
|
||
|
||
If the generated migration includes anything else (drift from other entity changes), abort and surface it — that's a plan defect.
|
||
|
||
**Step 3: Build**
|
||
|
||
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
|
||
Expected: build succeeds.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.ConfigurationDatabase/Migrations/
|
||
git commit -m "feat(transport): EF migration AddBundleImportIdToAuditLog"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `IAuditCorrelationContext` scoped service
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 0, Task 1, Task 2, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs`
|
||
- Modify: `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (add scoped registration; if no file, create alongside existing convention used by audit service)
|
||
|
||
**Step 1: Write the implementation**
|
||
|
||
`AuditCorrelationContext.cs`:
|
||
```csharp
|
||
using ScadaLink.Commons.Interfaces.Transport;
|
||
|
||
namespace ScadaLink.ConfigurationDatabase.Services;
|
||
|
||
/// <summary>
|
||
/// Per-scope mutable holder for the active bundle import id. AuditService reads it
|
||
/// while writing AuditLogEntry rows. Registered as Scoped so each Blazor circuit /
|
||
/// request gets its own value; ApplyAsync explicitly creates a service scope and
|
||
/// sets the id at the top of the transaction.
|
||
/// </summary>
|
||
public sealed class AuditCorrelationContext : IAuditCorrelationContext
|
||
{
|
||
public Guid? BundleImportId { get; set; }
|
||
}
|
||
```
|
||
|
||
**Step 2: Register**
|
||
|
||
In `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs` (the `AddConfigurationDatabase()` method), add:
|
||
```csharp
|
||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||
```
|
||
If a `ServiceCollectionExtensions.cs` doesn't exist in `ScadaLink.ConfigurationDatabase`, look at where `IAuditService` is currently registered (search the repo for `AddScoped<IAuditService`) and add the registration adjacent to it.
|
||
|
||
**Step 3: Build**
|
||
|
||
Run: `dotnet build src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj`
|
||
Expected: build succeeds.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.ConfigurationDatabase/Services/AuditCorrelationContext.cs \
|
||
src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs
|
||
git commit -m "feat(transport): add IAuditCorrelationContext scoped service"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `AuditService` reads correlation context
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none (depends on Tasks 2 + 4)
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs`
|
||
- Modify: `tests/ScadaLink.Commons.Tests/AuditServiceTests.cs` (if exists) OR create `tests/ScadaLink.ConfigurationDatabase.Tests/AuditServiceTests.cs`
|
||
|
||
**Step 1: Write the failing test first**
|
||
|
||
Add test that sets `IAuditCorrelationContext.BundleImportId`, calls `IAuditService.LogAsync`, asserts the emitted `AuditLogEntry.BundleImportId` matches. Test that with `BundleImportId = null`, the column is null.
|
||
|
||
**Step 2: Run to verify it fails**
|
||
|
||
Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/`
|
||
Expected: FAIL (correlation not yet wired through).
|
||
|
||
**Step 3: Implement**
|
||
|
||
In `AuditService.cs`:
|
||
1. Inject `IAuditCorrelationContext` via constructor.
|
||
2. In `LogAsync`, set `entry.BundleImportId = _correlationContext.BundleImportId;` right after constructing `entry`.
|
||
|
||
**Step 4: Run tests**
|
||
|
||
Run: `dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/`
|
||
Expected: PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs \
|
||
tests/ScadaLink.ConfigurationDatabase.Tests/
|
||
git commit -m "feat(transport): AuditService stamps BundleImportId from correlation context"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `ScadaLink.Transport` project skeleton
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none (subsequent tasks depend on the project existing)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/ScadaLink.Transport.csproj`
|
||
- Create: `src/ScadaLink.Transport/ServiceCollectionExtensions.cs`
|
||
- Modify: `ScadaLink.slnx` (add `<Project Path="src/ScadaLink.Transport/ScadaLink.Transport.csproj" />` in alphabetical position)
|
||
- Create: `tests/ScadaLink.Transport.Tests/ScadaLink.Transport.Tests.csproj`
|
||
- Create: `tests/ScadaLink.Transport.IntegrationTests/ScadaLink.Transport.IntegrationTests.csproj`
|
||
- Modify: `ScadaLink.slnx` (add the two test projects)
|
||
|
||
**Step 1: Write the csproj**
|
||
|
||
`ScadaLink.Transport.csproj`:
|
||
```xml
|
||
<Project Sdk="Microsoft.NET.Sdk">
|
||
<PropertyGroup>
|
||
<TargetFramework>net10.0</TargetFramework>
|
||
<ImplicitUsings>enable</ImplicitUsings>
|
||
<Nullable>enable</Nullable>
|
||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||
</PropertyGroup>
|
||
<ItemGroup>
|
||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||
<ProjectReference Include="../ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj" />
|
||
<ProjectReference Include="../ScadaLink.TemplateEngine/ScadaLink.TemplateEngine.csproj" />
|
||
</ItemGroup>
|
||
<ItemGroup>
|
||
<InternalsVisibleTo Include="ScadaLink.Transport.Tests" />
|
||
<InternalsVisibleTo Include="ScadaLink.Transport.IntegrationTests" />
|
||
</ItemGroup>
|
||
</Project>
|
||
```
|
||
|
||
`ServiceCollectionExtensions.cs`:
|
||
```csharp
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
|
||
namespace ScadaLink.Transport;
|
||
|
||
public static class ServiceCollectionExtensions
|
||
{
|
||
public const string OptionsSection = "ScadaLink:Transport";
|
||
|
||
public static IServiceCollection AddTransport(this IServiceCollection services)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(services);
|
||
services.AddOptions<TransportOptions>().BindConfiguration(OptionsSection);
|
||
// Concrete services added in later tasks.
|
||
return services;
|
||
}
|
||
}
|
||
```
|
||
|
||
Test csprojs follow the existing test pattern (look at `tests/ScadaLink.NotificationOutbox.Tests/ScadaLink.NotificationOutbox.Tests.csproj` for shape — xUnit + NSubstitute + FluentAssertions + ProjectReference back to ScadaLink.Transport).
|
||
|
||
**Step 2: Add to slnx**
|
||
|
||
Insert in alphabetical order under `/src/` and `/tests/` folder sections.
|
||
|
||
**Step 3: Build**
|
||
|
||
Run: `dotnet build ScadaLink.slnx`
|
||
Expected: build succeeds (Transport empty + tests empty).
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.Transport/ tests/ScadaLink.Transport.Tests/ tests/ScadaLink.Transport.IntegrationTests/ ScadaLink.slnx
|
||
git commit -m "feat(transport): scaffold ScadaLink.Transport project + test projects"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `TransportOptions`
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~2 min
|
||
**Parallelizable with:** Task 8 (different file)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/TransportOptions.cs`
|
||
|
||
**Step 1: Write the options class**
|
||
|
||
```csharp
|
||
namespace ScadaLink.Transport;
|
||
|
||
public sealed class TransportOptions
|
||
{
|
||
public int BundleSessionTtlMinutes { get; set; } = 30;
|
||
public int MaxBundleSizeMb { get; set; } = 100;
|
||
public int MaxUnlockAttemptsPerSession { get; set; } = 3;
|
||
public int MaxUnlockAttemptsPerIpPerHour { get; set; } = 10;
|
||
public int Pbkdf2Iterations { get; set; } = 600_000;
|
||
public int SchemaVersionMajor { get; set; } = 1;
|
||
}
|
||
```
|
||
|
||
**Step 2: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.Transport/TransportOptions.cs
|
||
git commit -m "feat(transport): add TransportOptions"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `BundleSecretEncryptor` (AES-256-GCM + PBKDF2)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 7, Task 9, Task 10, Task 12, Task 13, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Encryption/BundleSecretEncryptor.cs`
|
||
- Create: `tests/ScadaLink.Transport.Tests/BundleSecretEncryptorTests.cs`
|
||
|
||
**Step 1: Write failing tests first**
|
||
|
||
Tests to write (all in `BundleSecretEncryptorTests.cs`):
|
||
1. `Encrypt_then_Decrypt_roundtrips_arbitrary_bytes`
|
||
2. `Decrypt_with_wrong_passphrase_throws_CryptographicException`
|
||
3. `Decrypt_with_tampered_ciphertext_throws_CryptographicException` (flip one byte in ciphertext)
|
||
4. `Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv`
|
||
5. `Encrypt_emits_metadata_matching_decryption_inputs` (salt, iv, iterations, algorithm)
|
||
|
||
**Step 2: Run tests to verify failure**
|
||
|
||
Run: `dotnet test tests/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests`
|
||
Expected: FAIL (class not yet defined).
|
||
|
||
**Step 3: Implement**
|
||
|
||
```csharp
|
||
using System.Security.Cryptography;
|
||
using ScadaLink.Commons.Types.Transport;
|
||
|
||
namespace ScadaLink.Transport.Encryption;
|
||
|
||
public sealed class BundleSecretEncryptor
|
||
{
|
||
private const int KeyBytes = 32; // AES-256
|
||
private const int SaltBytes = 16;
|
||
private const int NonceBytes = 12; // GCM standard
|
||
private const int TagBytes = 16;
|
||
|
||
public (byte[] Ciphertext, EncryptionMetadata Metadata) Encrypt(
|
||
ReadOnlySpan<byte> plaintext,
|
||
string passphrase,
|
||
int iterations)
|
||
{
|
||
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
|
||
var nonce = RandomNumberGenerator.GetBytes(NonceBytes);
|
||
var key = DeriveKey(passphrase, salt, iterations);
|
||
|
||
var ciphertext = new byte[plaintext.Length];
|
||
var tag = new byte[TagBytes];
|
||
using var aes = new AesGcm(key, TagBytes);
|
||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||
|
||
// Format: ciphertext || tag
|
||
var output = new byte[ciphertext.Length + TagBytes];
|
||
Buffer.BlockCopy(ciphertext, 0, output, 0, ciphertext.Length);
|
||
Buffer.BlockCopy(tag, 0, output, ciphertext.Length, TagBytes);
|
||
|
||
return (output, new EncryptionMetadata(
|
||
"AES-256-GCM", "PBKDF2-SHA256", iterations,
|
||
Convert.ToBase64String(salt),
|
||
Convert.ToBase64String(nonce)));
|
||
}
|
||
|
||
public byte[] Decrypt(ReadOnlySpan<byte> payload, EncryptionMetadata metadata, string passphrase)
|
||
{
|
||
if (metadata.Algorithm != "AES-256-GCM" || metadata.Kdf != "PBKDF2-SHA256")
|
||
throw new CryptographicException("Unsupported bundle encryption parameters.");
|
||
|
||
var salt = Convert.FromBase64String(metadata.SaltB64);
|
||
var nonce = Convert.FromBase64String(metadata.IvB64);
|
||
var key = DeriveKey(passphrase, salt, metadata.Iterations);
|
||
|
||
if (payload.Length < TagBytes) throw new CryptographicException("Bundle payload too short.");
|
||
var ctLen = payload.Length - TagBytes;
|
||
var ciphertext = payload[..ctLen];
|
||
var tag = payload[ctLen..];
|
||
|
||
var plaintext = new byte[ctLen];
|
||
using var aes = new AesGcm(key, TagBytes);
|
||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||
return plaintext;
|
||
}
|
||
|
||
private static byte[] DeriveKey(string passphrase, byte[] salt, int iterations)
|
||
{
|
||
using var pbkdf2 = new Rfc2898DeriveBytes(passphrase, salt, iterations, HashAlgorithmName.SHA256);
|
||
return pbkdf2.GetBytes(KeyBytes);
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 4: Run tests**
|
||
|
||
Run: `dotnet test tests/ScadaLink.Transport.Tests/ --filter FullyQualifiedName~BundleSecretEncryptorTests`
|
||
Expected: PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.Transport/Encryption/ tests/ScadaLink.Transport.Tests/BundleSecretEncryptorTests.cs
|
||
git commit -m "feat(transport): AES-256-GCM + PBKDF2 BundleSecretEncryptor"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: `ManifestBuilder` + schema validation
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 7, Task 8, Task 10, Task 12, Task 13, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Serialization/ManifestBuilder.cs`
|
||
- Create: `src/ScadaLink.Transport/Serialization/ManifestValidator.cs`
|
||
- Create: `tests/ScadaLink.Transport.Tests/ManifestBuilderTests.cs`
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Tests:
|
||
1. `Build_populates_summary_from_contents`
|
||
2. `Build_serializes_to_valid_json`
|
||
3. `Validate_rejects_unsupported_bundleFormatVersion`
|
||
4. `Validate_rejects_when_contentHash_mismatch`
|
||
5. `Validate_accepts_well_formed_v1_manifest`
|
||
|
||
**Step 2-5:** Run-fail → implement → run-pass → commit.
|
||
|
||
`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaLinkVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`.
|
||
|
||
`ManifestValidator.Validate(BundleManifest manifest, byte[] contentBytes)` returns a `ValidationResult` enum (`Ok | UnsupportedFormatVersion | ContentHashMismatch | MalformedManifest`).
|
||
|
||
**Commit:**
|
||
```bash
|
||
git commit -m "feat(transport): ManifestBuilder + ManifestValidator with schema-version gating"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: `EntitySerializer` — DTOs + secret carving
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 7, Task 8, Task 9, Task 12, Task 13, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Serialization/EntityDtos.cs` (all bundle DTOs: `TemplateDto`, `TemplateFolderDto`, `SharedScriptDto`, `ExternalSystemDto`, `DatabaseConnectionDto`, `NotificationListDto`, `SmtpConfigDto`, `ApiKeyDto`, `ApiMethodDto`, each with a nested `SecretsBlock` where applicable)
|
||
- Create: `src/ScadaLink.Transport/Serialization/EntitySerializer.cs`
|
||
- Create: `tests/ScadaLink.Transport.Tests/EntitySerializerTests.cs`
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Tests:
|
||
1. `ToDto_carves_external_system_credentials_into_secrets_block`
|
||
2. `ToDto_carves_smtp_password_into_secrets_block`
|
||
3. `ToDto_carves_api_key_hash_into_secrets_block`
|
||
4. `Roundtrip_template_preserves_attributes_alarms_scripts_composition`
|
||
5. `Roundtrip_template_folder_preserves_hierarchy`
|
||
|
||
**Step 3: Implement**
|
||
|
||
DTOs are flat records mirroring the Commons POCOs minus EF-only navigation properties. Secrets fields move into a nested `SecretsBlock` record on the DTO:
|
||
```csharp
|
||
public sealed record ExternalSystemDto(
|
||
string Name,
|
||
string BaseUrl,
|
||
string AuthType,
|
||
IReadOnlyList<ExternalSystemMethodDto> Methods,
|
||
SecretsBlock? Secrets);
|
||
|
||
public sealed record SecretsBlock(IReadOnlyDictionary<string, string> Values);
|
||
```
|
||
|
||
`EntitySerializer` exposes:
|
||
- `BundleContentDto ToBundleContent(EntityAggregate aggregate)` — converts in-memory aggregate to top-level DTO grouped by type.
|
||
- `EntityAggregate FromBundleContent(BundleContentDto content)` — inverse.
|
||
|
||
`BundleContentDto`:
|
||
```csharp
|
||
public sealed record BundleContentDto(
|
||
IReadOnlyList<TemplateFolderDto> TemplateFolders,
|
||
IReadOnlyList<TemplateDto> Templates,
|
||
IReadOnlyList<SharedScriptDto> SharedScripts,
|
||
IReadOnlyList<ExternalSystemDto> ExternalSystems,
|
||
IReadOnlyList<DatabaseConnectionDto> DatabaseConnections,
|
||
IReadOnlyList<NotificationListDto> NotificationLists,
|
||
IReadOnlyList<SmtpConfigDto> SmtpConfigs,
|
||
IReadOnlyList<ApiKeyDto> ApiKeys,
|
||
IReadOnlyList<ApiMethodDto> ApiMethods);
|
||
```
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): bundle entity DTOs + secret carving in EntitySerializer"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: `BundleSerializer` (ZIP packer + reader + content hash)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (depends on Tasks 8, 9, 10)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Serialization/BundleSerializer.cs`
|
||
- Create: `tests/ScadaLink.Transport.Tests/BundleSerializerTests.cs`
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Tests:
|
||
1. `Pack_emits_manifest_and_content_entries`
|
||
2. `Pack_encrypts_content_when_passphrase_supplied`
|
||
3. `Pack_leaves_content_plaintext_when_no_passphrase`
|
||
4. `Roundtrip_through_temp_stream_recovers_identical_content`
|
||
5. `Read_rejects_zip_without_manifest`
|
||
|
||
**Step 3: Implement**
|
||
|
||
`BundleSerializer` uses `System.IO.Compression.ZipArchive`:
|
||
- `Stream Pack(BundleContentDto content, ManifestContext ctx, string? passphrase)` — serializes `content` → JSON bytes → optional encrypt → writes `manifest.json` + (`content.json` OR `content.enc`) to a `MemoryStream`-backed ZIP.
|
||
- `(BundleManifest manifest, byte[] contentBytes) ReadHeader(Stream zipStream)` — opens, reads `manifest.json` only.
|
||
- `BundleContentDto Unpack(Stream zipStream, BundleManifest manifest, string? passphrase)` — full unpack + decrypt.
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): BundleSerializer ZIP packer/reader"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: `DependencyResolver`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 13, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Export/DependencyResolver.cs`
|
||
- Create: `tests/ScadaLink.Transport.Tests/DependencyResolverTests.cs`
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Tests:
|
||
1. `Resolve_includes_base_template_for_composed_template`
|
||
2. `Resolve_includes_shared_script_referenced_by_template`
|
||
3. `Resolve_includes_external_system_referenced_by_template`
|
||
4. `Resolve_includes_api_method_shared_script_dependency`
|
||
5. `Resolve_handles_diamond_dependency_without_duplication`
|
||
6. `Resolve_includes_template_folder_for_each_selected_template`
|
||
7. `Resolve_returns_topological_order_base_before_derived`
|
||
|
||
**Step 3: Implement**
|
||
|
||
`DependencyResolver` takes `ExportSelection` + repository readers, expands the selection through the edges in design §6.1, and returns a `ResolvedExport` aggregate:
|
||
```csharp
|
||
public sealed record ResolvedExport(
|
||
IReadOnlyList<TemplateFolder> TemplateFolders,
|
||
IReadOnlyList<Template> Templates,
|
||
IReadOnlyList<SharedScript> SharedScripts,
|
||
// ... one list per entity type
|
||
IReadOnlyList<ManifestContentEntry> ContentManifest); // for the manifest
|
||
```
|
||
|
||
Use Kahn's algorithm for topological ordering of templates (base before derived). Cycle detection — throw `InvalidOperationException` (templates are acyclic by Template Engine invariant, but defensive).
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): DependencyResolver with topological closure"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: `BundleSessionStore`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** Task 7, Task 8, Task 9, Task 10, Task 12, Task 19
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Import/BundleSessionStore.cs`
|
||
- Create: `tests/ScadaLink.Transport.Tests/BundleSessionStoreTests.cs`
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Tests:
|
||
1. `Open_then_Get_returns_session`
|
||
2. `Get_after_TTL_returns_null`
|
||
3. `Remove_evicts_session`
|
||
4. `EvictExpired_removes_all_past_ttl`
|
||
5. `Three_failed_unlock_attempts_locks_session` (Locked == true)
|
||
|
||
**Step 3: Implement**
|
||
|
||
Backed by `ConcurrentDictionary<Guid, BundleSession>`. Constructor takes `IOptions<TransportOptions>` and `TimeProvider` for testable expiry. Implements `IBundleSessionStore`.
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): in-memory BundleSessionStore with TTL + lockout"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: `BundleExporter.ExportAsync`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Tasks 8–12)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Export/BundleExporter.cs`
|
||
- Create: `tests/ScadaLink.Transport.IntegrationTests/BundleExporterTests.cs` (integration — needs DB)
|
||
- Modify: `src/ScadaLink.Transport/ServiceCollectionExtensions.cs` (register `IBundleExporter -> BundleExporter`)
|
||
|
||
**Step 1: Write failing integration test**
|
||
|
||
Test against in-memory EF:
|
||
```csharp
|
||
[Fact]
|
||
public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle()
|
||
```
|
||
Seeds two templates with composition + a shared script. Calls `ExportAsync` with selection={Template1Id}, includeDependencies=true. Asserts:
|
||
- Returned stream is non-empty and is a valid zip.
|
||
- A `BundleExported` audit row exists with the user, content hash, encrypted=false.
|
||
- Manifest summary lists 2 templates + 1 shared script.
|
||
|
||
**Step 3: Implement**
|
||
|
||
`BundleExporter.ExportAsync`:
|
||
1. Resolve dependencies via `DependencyResolver`.
|
||
2. Convert to `BundleContentDto` via `EntitySerializer`.
|
||
3. Serialize content to JSON bytes.
|
||
4. Optionally encrypt with `BundleSecretEncryptor`.
|
||
5. Build manifest via `ManifestBuilder`.
|
||
6. Pack via `BundleSerializer`.
|
||
7. Emit audit row: `IAuditService.LogAsync(user, "BundleExported", "Bundle", manifest.ContentHash, manifest.SourceEnvironment, manifest)`.
|
||
8. `await _context.SaveChangesAsync(ct)`.
|
||
9. Return stream.
|
||
|
||
Register in `ServiceCollectionExtensions.AddTransport`:
|
||
```csharp
|
||
services.AddScoped<IBundleExporter, BundleExporter>();
|
||
services.AddSingleton<BundleSecretEncryptor>();
|
||
services.AddSingleton<ManifestBuilder>();
|
||
services.AddSingleton<ManifestValidator>();
|
||
services.AddSingleton<BundleSerializer>();
|
||
services.AddSingleton<EntitySerializer>();
|
||
services.AddScoped<DependencyResolver>();
|
||
```
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): BundleExporter with audit logging"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: `BundleImporter.LoadAsync`
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (depends on Tasks 11, 13)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.Transport/Import/BundleImporter.cs`
|
||
- Create: `tests/ScadaLink.Transport.Tests/BundleImporterLoadTests.cs`
|
||
- Modify: `src/ScadaLink.Transport/ServiceCollectionExtensions.cs`
|
||
|
||
**Step 1: Write failing tests**
|
||
|
||
Tests:
|
||
1. `LoadAsync_returns_session_for_well_formed_bundle`
|
||
2. `LoadAsync_rejects_unsupported_bundleFormatVersion_with_clear_message`
|
||
3. `LoadAsync_rejects_content_hash_mismatch`
|
||
4. `LoadAsync_rejects_missing_manifest`
|
||
5. `LoadAsync_decrypts_when_passphrase_correct`
|
||
6. `LoadAsync_throws_CryptographicException_on_wrong_passphrase` (caller increments lockout counter)
|
||
|
||
**Step 3: Implement**
|
||
|
||
Logic:
|
||
1. Open zip.
|
||
2. `BundleSerializer.ReadHeader` → manifest + content bytes.
|
||
3. `ManifestValidator.Validate` → reject on mismatch.
|
||
4. If manifest has encryption metadata: passphrase required; decrypt content bytes.
|
||
5. Deserialize content to `BundleContentDto`.
|
||
6. Create `BundleSession { SessionId = Guid.NewGuid(), Manifest, DecryptedContent, ExpiresAt = now + TTL }` → `_sessionStore.Open(session)`.
|
||
|
||
Register in DI:
|
||
```csharp
|
||
services.AddScoped<IBundleImporter, BundleImporter>();
|
||
services.AddSingleton<IBundleSessionStore, BundleSessionStore>();
|
||
```
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): BundleImporter.LoadAsync with manifest validation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: `BundleImporter.PreviewAsync` (diff engine)
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Task 15)
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.Transport/Import/BundleImporter.cs`
|
||
- Create: `src/ScadaLink.Transport/Import/ArtifactDiff.cs`
|
||
- Create: `tests/ScadaLink.Transport.IntegrationTests/BundleImporterPreviewTests.cs`
|
||
|
||
**Step 1: Write failing integration tests**
|
||
|
||
Tests against in-memory EF:
|
||
1. `PreviewAsync_classifies_artifact_as_Identical_when_fields_match`
|
||
2. `PreviewAsync_classifies_artifact_as_Modified_with_field_diff`
|
||
3. `PreviewAsync_classifies_artifact_as_New_when_absent_from_target`
|
||
4. `PreviewAsync_emits_Blocker_when_required_dependency_is_missing_in_bundle_and_target`
|
||
|
||
**Step 3: Implement**
|
||
|
||
`ArtifactDiff.Compare(string entityType, object incoming, object? existing) → (ConflictKind, string? fieldDiffJson)`. Uses reflection over the DTO's properties; emits a per-field `+/-/~` summary in JSON format. For script bodies, emits a line-based diff (use a simple Myers-style diff implemented in-project; ~80 LOC). No third-party diff library.
|
||
|
||
`PreviewAsync` walks each top-level DTO list in `session.DecryptedContent`, looks up existing by name via repositories, calls `ArtifactDiff.Compare`, builds `ImportPreviewItem`s. Blocker items appear when a referenced shared script / external system is neither in the bundle nor pre-existing in the target.
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): BundleImporter.PreviewAsync diff engine"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: `BundleImporter.ApplyAsync` (transaction + validation + audit)
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** none (depends on Task 16)
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.Transport/Import/BundleImporter.cs`
|
||
- Create: `tests/ScadaLink.Transport.IntegrationTests/BundleImporterApplyTests.cs`
|
||
|
||
**Step 1: Write failing integration tests**
|
||
|
||
Tests:
|
||
1. `ApplyAsync_adds_new_artifacts_in_single_transaction`
|
||
2. `ApplyAsync_overwrites_artifact_when_resolution_is_Overwrite`
|
||
3. `ApplyAsync_skips_artifact_when_resolution_is_Skip`
|
||
4. `ApplyAsync_renames_artifact_when_resolution_is_Rename`
|
||
5. `ApplyAsync_rolls_back_all_changes_when_semantic_validation_fails`
|
||
6. `ApplyAsync_writes_BundleImportId_on_every_emitted_audit_row` (the correlation guarantee)
|
||
7. `ApplyAsync_writes_BundleImported_summary_row_inside_transaction`
|
||
8. `ApplyAsync_writes_BundleImportFailed_outside_rolled_back_transaction`
|
||
|
||
**Step 3: Implement**
|
||
|
||
Flow:
|
||
```csharp
|
||
public async Task<ImportResult> ApplyAsync(Guid sessionId, IReadOnlyList<ImportResolution> resolutions, string user, CancellationToken ct)
|
||
{
|
||
var session = _sessionStore.Get(sessionId) ?? throw new InvalidOperationException("Session expired");
|
||
var bundleImportId = Guid.NewGuid();
|
||
_correlation.BundleImportId = bundleImportId;
|
||
|
||
var content = DeserializeContent(session.DecryptedContent);
|
||
var resolutionMap = resolutions.ToDictionary(r => (r.EntityType, r.Name));
|
||
|
||
await using var tx = await _context.Database.BeginTransactionAsync(ct);
|
||
try
|
||
{
|
||
// Apply in dependency order: folders → templates (base→derived) → shared scripts
|
||
// → external systems → db conn → notification lists
|
||
// → smtp configs → api keys → api methods
|
||
var summary = await ApplyAllAsync(content, resolutionMap, user, ct);
|
||
|
||
// Pre-deployment semantic validator — fail fast
|
||
var validation = _semanticValidator.Validate(BuildFlattenedConfig(content));
|
||
if (!validation.IsValid)
|
||
throw new SemanticValidationException(validation.Errors);
|
||
|
||
// BundleImported summary row inside the transaction
|
||
await _auditService.LogAsync(user, "BundleImported", "Bundle",
|
||
bundleImportId.ToString(), session.Manifest.SourceEnvironment,
|
||
new { session.Manifest.SourceEnvironment, session.Manifest.ContentHash, Summary = summary }, ct);
|
||
|
||
await _context.SaveChangesAsync(ct);
|
||
await tx.CommitAsync(ct);
|
||
|
||
_sessionStore.Remove(sessionId);
|
||
return new ImportResult(bundleImportId, summary.Added, summary.Overwritten, summary.Skipped, summary.Renamed, StaleInstanceIds: Array.Empty<int>(), AuditEventCorrelation: bundleImportId.ToString());
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await tx.RollbackAsync(ct);
|
||
// BundleImportFailed row OUTSIDE the rolled-back tx
|
||
_correlation.BundleImportId = null;
|
||
await _auditService.LogAsync(user, "BundleImportFailed", "Bundle",
|
||
bundleImportId.ToString(), session.Manifest.SourceEnvironment,
|
||
new { Reason = ex.Message }, ct);
|
||
await _context.SaveChangesAsync(ct);
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
_correlation.BundleImportId = null;
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: `StaleInstanceIds: Array.Empty<int>()` because the brainstorming survey showed there's no persisted stale flag — `DeploymentService.CompareAsync` detects staleness live by comparing revision hashes, so overwriting a template surfaces stale instances automatically on the Deployments page.
|
||
|
||
**Step 5: Commit**
|
||
```bash
|
||
git commit -m "feat(transport): BundleImporter.ApplyAsync transactional with audit correlation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Host registration + transport options binding
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~2 min
|
||
**Parallelizable with:** Task 19, Task 27, Task 28
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.Host/Program.cs` (add `builder.Services.AddTransport();` in the "Central-only components" section, alongside `AddNotificationOutbox()`)
|
||
- Modify: `src/ScadaLink.Host/appsettings.json` (add `ScadaLink:Transport` section with defaults that match `TransportOptions`)
|
||
- Modify: `src/ScadaLink.Host/ScadaLink.Host.csproj` (add `ProjectReference` to ScadaLink.Transport)
|
||
|
||
**Step 1: Modify Program.cs**
|
||
|
||
Find the existing `if (centralRolesActive) { ... builder.Services.AddNotificationOutbox(); ... }` block (or equivalent — search for `AddNotificationOutbox`) and add `builder.Services.AddTransport();` directly after.
|
||
|
||
**Step 2: appsettings.json**
|
||
|
||
```json
|
||
"ScadaLink": {
|
||
"Transport": {
|
||
"BundleSessionTtlMinutes": 30,
|
||
"MaxBundleSizeMb": 100,
|
||
"MaxUnlockAttemptsPerSession": 3,
|
||
"MaxUnlockAttemptsPerIpPerHour": 10,
|
||
"Pbkdf2Iterations": 600000,
|
||
"SchemaVersionMajor": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3: Build**
|
||
|
||
Run: `dotnet build ScadaLink.slnx`
|
||
Expected: build succeeds.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.Host/
|
||
git commit -m "feat(transport): register AddTransport() on central nodes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: `TreeView` checkbox-selection mode + tri-state propagation
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 0, Task 1, Task 2, Task 4, Task 7, Task 8, Task 9, Task 10, Task 12, Task 13
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.CentralUI/Components/Shared/TreeView.razor`
|
||
- Create: `tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs`
|
||
|
||
**Step 1: Extend the component API**
|
||
|
||
Add parameters:
|
||
```razor
|
||
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
|
||
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
|
||
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
|
||
```
|
||
|
||
`TreeViewSelectionMode { Single, Checkbox }`.
|
||
|
||
In `Checkbox` mode, render a `<input type="checkbox">` left of the node content. State is:
|
||
- `Checked` — node and all descendant leaves are in `SelectedKeys`.
|
||
- `Unchecked` — none are.
|
||
- `Indeterminate` — some are. Set via `ref` + `element.indeterminate = true` in JS interop (use the existing JS bundle helper if there is one; otherwise a tiny `treeview.js` file is acceptable).
|
||
|
||
Toggle semantics:
|
||
- Clicking a folder toggles all descendants on/off.
|
||
- Clicking a leaf toggles just itself; parent indeterminate state recomputed.
|
||
|
||
**Step 2: Write failing tests**
|
||
|
||
Use bUnit (already in `tests/ScadaLink.CentralUI.Tests/`):
|
||
1. `Checkbox_mode_renders_checkbox_per_node`
|
||
2. `Clicking_folder_selects_all_descendants`
|
||
3. `Clicking_leaf_makes_parent_indeterminate_when_sibling_unchecked`
|
||
4. `Single_mode_still_works_unchanged` (regression)
|
||
|
||
**Step 4: Run tests**
|
||
|
||
Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter FullyQualifiedName~TreeViewMultiSelect`
|
||
Expected: PASS.
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.CentralUI/Components/Shared/TreeView.razor \
|
||
tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs
|
||
git commit -m "feat(centralui): TreeView checkbox-selection mode with tri-state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: `TemplateFolderTree.razor` wrapper + Templates.razor refactor
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~4 min
|
||
**Parallelizable with:** none (depends on Task 19)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor`
|
||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor` (use the new component in `Single` mode)
|
||
|
||
**Step 1: Build the wrapper**
|
||
|
||
`TemplateFolderTree.razor` wraps `TreeView<TemplateTreeNode>` with template-folder-specific configuration:
|
||
- `[Parameter] IReadOnlyList<TemplateFolder> Folders { get; set; }`
|
||
- `[Parameter] IReadOnlyList<Template> Templates { get; set; }`
|
||
- `[Parameter] TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single`
|
||
- `[Parameter] HashSet<object>? SelectedKeys { get; set; }`
|
||
- `[Parameter] EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }`
|
||
- `[Parameter] string Filter { get; set; } = ""` — applies search filter in-place
|
||
- `[Parameter] RenderFragment<TemplateTreeNode>? NodeExtras { get; set; }` — optional content rendered next to node label
|
||
|
||
Internally builds a `TemplateTreeNode { Kind: Folder|Template, Id, Name, Children }` adapter tree from the input lists.
|
||
|
||
**Step 2: Refactor Templates.razor**
|
||
|
||
Replace the existing inline tree usage with `<TemplateFolderTree Folders="..." Templates="..." SelectionMode="TreeViewSelectionMode.Single" SelectedKey="@_selectedKey" ... />`. Keep all current behavior (navigation, search). The replacement should be ≤30 LOC change in Templates.razor.
|
||
|
||
**Step 3: Manual smoke (no new tests needed — existing Templates.razor tests cover this)**
|
||
|
||
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
|
||
Then: `dotnet test tests/ScadaLink.CentralUI.Tests/`
|
||
Expected: all existing Templates page tests still pass.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.CentralUI/Components/Shared/TemplateFolderTree.razor \
|
||
src/ScadaLink.CentralUI/Components/Pages/Design/Templates.razor
|
||
git commit -m "refactor(centralui): extract TemplateFolderTree as shared component"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 21: `TransportExport.razor` wizard
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 22 (separate page; only NavMenu touches both)
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor`
|
||
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs` (code-behind for the wizard state machine)
|
||
- Create: `tests/ScadaLink.CentralUI.Tests/TransportExportPageTests.cs`
|
||
|
||
**Step 1: Page skeleton**
|
||
|
||
```razor
|
||
@page "/design/transport/export"
|
||
@using ScadaLink.Security
|
||
@using ScadaLink.Commons.Types.Transport
|
||
@using ScadaLink.Commons.Interfaces.Transport
|
||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
|
||
@inject IBundleExporter BundleExporter
|
||
@inject ITemplateEngineRepository TemplateRepo
|
||
@inject IExternalSystemRepository ExternalRepo
|
||
@inject INotificationRepository NotificationRepo
|
||
@inject IInboundApiRepository InboundApiRepo
|
||
@inject IJSRuntime JS
|
||
@inject AuthenticationStateProvider Auth
|
||
@inject IOptions<TransportOptions> Options
|
||
```
|
||
|
||
Four-step wizard rendered with a step indicator at top and a single content area beneath. State machine in code-behind (`Step1Select`, `Step2Review`, `Step3Encrypt`, `Step4Download`).
|
||
|
||
Step 1 hosts the `<TemplateFolderTree SelectionMode="TreeViewSelectionMode.Checkbox" ... />` plus flat checkbox lists for shared scripts / external systems / notification lists / SMTP configs / API keys / API methods.
|
||
|
||
Step 2 calls `DependencyResolver` server-side and shows the expanded selection (user-selected + auto-included).
|
||
|
||
Step 3 collects passphrase + confirm (or explicit "Export without encryption" path with warning banner).
|
||
|
||
Step 4 calls `IBundleExporter.ExportAsync`, streams the result to the browser via `IJSRuntime.InvokeVoidAsync("scadalinkTransport.downloadBundle", filename, bytes)`. Display SHA-256 + size.
|
||
|
||
**Step 2: Page tests with bUnit**
|
||
|
||
1. `Renders_step1_with_template_tree`
|
||
2. `Step2_shows_resolved_dependencies`
|
||
3. `Step4_triggers_ExportAsync_on_user_with_Design_role`
|
||
4. `Page_returns_403_for_user_without_Design_role`
|
||
|
||
**Step 3: Run tests**
|
||
|
||
Run: `dotnet test tests/ScadaLink.CentralUI.Tests/ --filter FullyQualifiedName~TransportExportPage`
|
||
Expected: PASS.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor \
|
||
src/ScadaLink.CentralUI/Components/Pages/Design/TransportExport.razor.cs \
|
||
tests/ScadaLink.CentralUI.Tests/TransportExportPageTests.cs
|
||
git commit -m "feat(centralui): TransportExport wizard under Design nav group"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 22: `TransportImport.razor` wizard
|
||
|
||
**Classification:** high-risk
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 21
|
||
|
||
**Files:**
|
||
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor`
|
||
- Create: `src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs`
|
||
- Create: `tests/ScadaLink.CentralUI.Tests/TransportImportPageTests.cs`
|
||
|
||
**Step 1: Page skeleton**
|
||
|
||
```razor
|
||
@page "/design/transport/import"
|
||
@using ScadaLink.Security
|
||
@using ScadaLink.Commons.Types.Transport
|
||
@using ScadaLink.Commons.Interfaces.Transport
|
||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
|
||
@inject IBundleImporter BundleImporter
|
||
@inject NavigationManager Nav
|
||
@inject AuthenticationStateProvider Auth
|
||
```
|
||
|
||
Five-step wizard:
|
||
1. **Upload** — `InputFile` component (size-limited to `Options.MaxBundleSizeMb`). On select, call `LoadAsync(stream, passphrase: null)` to read the manifest only.
|
||
2. **Passphrase** — render iff `manifest.Encryption != null`. Submit calls `LoadAsync` again with the passphrase. On wrong passphrase, increment a session-local counter; after 3 attempts, evict the session and force re-upload.
|
||
3. **Diff & resolve** — render `ImportPreview` rows with per-row radio buttons (Skip/Overwrite/Rename). Show field-level diff via `<details>` disclosure. Bulk "Apply to all" header.
|
||
4. **Confirm** — text input requires user to retype `manifest.SourceEnvironment` exactly.
|
||
5. **Result** — show counts + link to Deployments page (`<a href="/deployment/deployments">View on Deployments →</a>`) + link to filtered audit log (`<a href="/audit/configuration?bundleImportId={result.BundleImportId}">Audit trail →</a>`).
|
||
|
||
**Step 2: Page tests**
|
||
|
||
1. `Renders_step1_upload_input`
|
||
2. `Decryption_failure_increments_attempt_counter`
|
||
3. `Three_failed_unlocks_force_reupload`
|
||
4. `Confirm_step_requires_exact_environment_name_match`
|
||
5. `Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions`
|
||
6. `Page_returns_403_for_user_without_Admin_role`
|
||
|
||
**Step 3: Run + commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor \
|
||
src/ScadaLink.CentralUI/Components/Pages/Design/TransportImport.razor.cs \
|
||
tests/ScadaLink.CentralUI.Tests/TransportImportPageTests.cs
|
||
git commit -m "feat(centralui): TransportImport wizard under Design nav group"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 23: NavMenu entries + UI auth attributes verified
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none (depends on Tasks 21, 22)
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor`
|
||
|
||
**Step 1: Add nav entries**
|
||
|
||
Inside the existing Design `<NavSection>`, after the Templates entry:
|
||
```razor
|
||
<li class="nav-item">
|
||
<NavLink class="nav-link" href="/design/transport/export">Export Bundle</NavLink>
|
||
</li>
|
||
<AuthorizeView Policy="@AuthorizationPolicies.RequireAdmin">
|
||
<Authorized>
|
||
<li class="nav-item">
|
||
<NavLink class="nav-link" href="/design/transport/import">Import Bundle</NavLink>
|
||
</li>
|
||
</Authorized>
|
||
</AuthorizeView>
|
||
```
|
||
|
||
(Export shows for anyone with Design; Import only renders when the user also has Admin.)
|
||
|
||
**Step 2: Build + smoke-test**
|
||
|
||
Run: `dotnet build src/ScadaLink.CentralUI/ScadaLink.CentralUI.csproj`
|
||
Expected: build succeeds.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/ScadaLink.CentralUI/Components/Layout/NavMenu.razor
|
||
git commit -m "feat(centralui): add Export/Import Bundle nav entries"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 24: Audit log "Bundle Import" filter on existing page
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 22, Task 23
|
||
|
||
**Files:**
|
||
- Modify: `src/ScadaLink.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor` (locate by `git grep -l ConfigurationAuditLog src/ScadaLink.CentralUI/`)
|
||
- Modify: `src/ScadaLink.ConfigurationDatabase/Repositories/` (the repository that drives the audit page — extend the query with an optional `Guid? bundleImportId` filter)
|
||
|
||
**Step 1: Extend the repository query**
|
||
|
||
Find the audit log query method (likely `IAuditLogRepository.QueryAsync(...)` or similar). Add an optional `Guid? bundleImportId = null` parameter and:
|
||
```csharp
|
||
if (bundleImportId.HasValue)
|
||
query = query.Where(x => x.BundleImportId == bundleImportId.Value);
|
||
```
|
||
|
||
**Step 2: Wire the page filter**
|
||
|
||
In `ConfigurationAuditLog.razor`:
|
||
- Accept `[Parameter, SupplyParameterFromQuery] public Guid? BundleImportId { get; set; }`.
|
||
- Display the active filter as a removable badge ("Filtered by Bundle Import: {id}").
|
||
- Pass `BundleImportId` through to the repository call.
|
||
|
||
This is what the `TransportImport.razor` Step 5 link (`/audit/configuration?bundleImportId=…`) consumes.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git commit -m "feat(centralui): Bundle Import filter on ConfigurationAuditLog page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 25: Integration: export → wipe → import round-trip
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 26, Task 27, Task 28
|
||
|
||
**Files:**
|
||
- Create: `tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs`
|
||
|
||
**Step 1: Write the test**
|
||
|
||
```csharp
|
||
[Fact]
|
||
public async Task Export_then_wipe_then_import_restores_state()
|
||
```
|
||
|
||
1. Seed in-memory DB with 3 templates (one composed), 1 shared script, 1 external system, 1 notification list.
|
||
2. Call `IBundleExporter.ExportAsync` selecting all of them.
|
||
3. Read the returned stream into bytes.
|
||
4. Wipe DB: delete all those entities.
|
||
5. Call `IBundleImporter.LoadAsync(new MemoryStream(bytes), passphrase)`.
|
||
6. `PreviewAsync` → all `ConflictKind.New`.
|
||
7. Apply with all `Add` resolutions.
|
||
8. Re-query DB. Assert every entity is back, field-equivalent to the seed.
|
||
9. Assert one `BundleExported` + one `BundleImported` audit row exists, both linked by content hash.
|
||
10. Assert every per-entity audit row written during apply has `BundleImportId == result.BundleImportId`.
|
||
|
||
**Step 2: Run + commit**
|
||
|
||
```bash
|
||
git add tests/ScadaLink.Transport.IntegrationTests/RoundTripTests.cs
|
||
git commit -m "test(transport): integration round-trip export → wipe → import"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 26: Integration: conflict resolutions + rollback on validation failure
|
||
|
||
**Classification:** standard
|
||
**Estimated implement time:** ~5 min
|
||
**Parallelizable with:** Task 25, Task 27, Task 28
|
||
|
||
**Files:**
|
||
- Create: `tests/ScadaLink.Transport.IntegrationTests/ConflictResolutionTests.cs`
|
||
- Create: `tests/ScadaLink.Transport.IntegrationTests/ValidationFailureTests.cs`
|
||
|
||
**Step 1: Write the tests**
|
||
|
||
`ConflictResolutionTests`:
|
||
1. `Overwrite_replaces_existing_template_fields`
|
||
2. `Skip_leaves_existing_template_unchanged`
|
||
3. `Rename_creates_new_template_alongside_existing`
|
||
|
||
`ValidationFailureTests`:
|
||
1. `Semantic_validation_failure_rolls_back_all_writes` — craft a bundle with a template whose script references a missing call target. Apply must throw, transaction must roll back, no rows from the bundle persist, AND a `BundleImportFailed` row exists outside the rolled-back transaction.
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add tests/ScadaLink.Transport.IntegrationTests/
|
||
git commit -m "test(transport): integration conflict resolution + rollback"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 27: `Component-Transport.md` design doc
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 25, Task 26, Task 28
|
||
|
||
**Files:**
|
||
- Create: `docs/requirements/Component-Transport.md`
|
||
|
||
**Step 1: Write the doc**
|
||
|
||
Follow the standard structure used by `docs/requirements/Component-NotificationOutbox.md`:
|
||
- **Purpose** — one-paragraph summary.
|
||
- **Location** — paths.
|
||
- **Responsibilities** — bullet list.
|
||
- **Bundle format** — copy §4 from `2026-05-24-transport-design.md` plus JSON schema appendix.
|
||
- **Architecture** — copy §5.
|
||
- **Export flow / Import flow** — copy §6 + §7.
|
||
- **Error handling** — copy §8.
|
||
- **Security** — copy §9.
|
||
- **Configuration audit trail** — copy §10.
|
||
- **Authorization** — copy §12.
|
||
- **Dependencies** — Commons, ConfigurationDatabase, TemplateEngine.
|
||
- **Interactions** — Central UI (pages), Deployment Manager (stale-detection via existing CompareAsync), Security & Auth (RequireDesign/RequireAdmin).
|
||
|
||
**Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/requirements/Component-Transport.md
|
||
git commit -m "docs: Component-Transport.md (component #24)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 28: README + cross-reference updates
|
||
|
||
**Classification:** small
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** Task 25, Task 26, Task 27
|
||
|
||
**Files:**
|
||
- Modify: `README.md` (component table — add row #24)
|
||
- Modify: `docs/requirements/Component-TemplateEngine.md` (Interactions section — mention Transport as consumer)
|
||
- Modify: `docs/requirements/Component-DeploymentManager.md` (Interactions section — note that Transport-driven template overwrites surface naturally on existing CompareAsync)
|
||
- Modify: `docs/requirements/Component-SecurityAuth.md` (record the new `RequireDesign` export + `RequireAdmin` import mapping)
|
||
- Modify: `docs/requirements/Component-ConfigurationDatabase.md` (record the new `BundleImportId` column + index)
|
||
- Modify: `docs/requirements/Component-CentralUI.md` (record the two new pages under Design)
|
||
- Modify: `CLAUDE.md` (component list — add #24 Transport)
|
||
|
||
**Step 1: README table row**
|
||
|
||
After the row for component #23:
|
||
```markdown
|
||
| 24 | Transport | Bundle export/import for templates, shared scripts, external systems, central-only artifacts. AES-256-GCM encryption; per-conflict resolution on import; correlated audit trail. |
|
||
```
|
||
|
||
**Step 2: CLAUDE.md**
|
||
|
||
Add to the Current Component List:
|
||
```markdown
|
||
24. Transport — File-based, encrypted bundle export/import via Central UI; templates, system artifacts, central-only configuration; per-conflict resolution; correlated audit via `BundleImportId`.
|
||
```
|
||
|
||
**Step 3: Cross-reference updates** — single sentence per doc as described in the design doc §16.
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add README.md CLAUDE.md docs/requirements/
|
||
git commit -m "docs: README + component cross-references for Transport (#24)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 29: Manual cluster verification checklist
|
||
|
||
**Classification:** trivial
|
||
**Estimated implement time:** ~3 min
|
||
**Parallelizable with:** none (final verification gate)
|
||
|
||
**Files:**
|
||
- Create: `docs/plans/2026-05-24-transport-manual-verification.md`
|
||
|
||
**Step 1: Write the checklist**
|
||
|
||
The verification doc lists steps to be done manually against the docker cluster (no automation in this task — the checklist is the deliverable):
|
||
|
||
```markdown
|
||
# Transport Manual Verification
|
||
|
||
## Prerequisites
|
||
- `bash docker/deploy.sh` succeeded (image rebuilt with Transport).
|
||
- `cd infra && docker compose up -d` (LDAP, SQL ready).
|
||
|
||
## Steps
|
||
1. Log in to http://localhost:9000 as `multi-role` / `password`.
|
||
2. Design → Export Bundle.
|
||
3. Select 1 template + 1 shared script. Verify dependency expansion in Step 2.
|
||
4. Set passphrase "test123". Export. Verify .scadabundle file downloads.
|
||
5. Log out, log in as `admin` / `password`.
|
||
6. Design → Import Bundle. Upload the bundle.
|
||
7. Enter passphrase "test123". Verify diff page shows the artifacts.
|
||
8. Apply with Add for all. Confirm by retyping source env.
|
||
9. Verify Step 5 result page shows counts + links.
|
||
10. Open Audit → Configuration Audit Log → filter by the BundleImported row's id.
|
||
Verify all per-entity rows from the import are listed.
|
||
11. Open Deployments page → verify any instance that referenced the overwritten
|
||
template appears stale (revision-hash mismatch).
|
||
12. Verify wrong passphrase fails cleanly (3 attempts → re-upload required).
|
||
```
|
||
|
||
**Step 2: Commit**
|
||
|
||
```bash
|
||
git add docs/plans/2026-05-24-transport-manual-verification.md
|
||
git commit -m "docs(transport): manual cluster verification checklist"
|
||
```
|
||
|
||
---
|
||
|
||
## Acceptance Gate (run before declaring done)
|
||
|
||
After Task 29:
|
||
|
||
```bash
|
||
dotnet build ScadaLink.slnx
|
||
dotnet test tests/ScadaLink.Transport.Tests/
|
||
dotnet test tests/ScadaLink.Transport.IntegrationTests/
|
||
dotnet test tests/ScadaLink.CentralUI.Tests/
|
||
dotnet test tests/ScadaLink.ConfigurationDatabase.Tests/
|
||
```
|
||
|
||
All must pass. Then proceed to Task 29 manual checklist against the docker cluster.
|
||
|
||
---
|
||
|
||
## Out-of-Scope Reminders (do NOT do)
|
||
|
||
- Do NOT add a feature flag.
|
||
- Do NOT touch site-side projects (`ScadaLink.SiteRuntime` etc.).
|
||
- Do NOT add a stale-mark column on `Instance` — the existing `DeploymentService.CompareAsync` already detects this.
|
||
- Do NOT build CLI commands — those are deferred per design doc §13.
|
||
- Do NOT add bundle signing — content hash in manifest is sufficient for v1.
|
||
- Do NOT retain bundles server-side after export download or import apply.
|