29049c4ef6
Secret-store outage (final review M5): SiteSecretsCollector returns a store read failure as SiteSecretsCollection.StoreError instead of throwing. A deploy now fails only the sites that reference a secret (nothing sent, not stamped, DeploySiteSecrets row with the error), and ComputeCurrentArtifactHashesAsync omits only those sites (they read Out of date) instead of the exception aborting the whole sweep and the fleet-wide pending-updates check. Removal-only deploys: the site reply gains SecretsRemoved (proto field 7, names only) - the deployed_secrets rows the deploy soft-removed. Central carries it on SiteArtifactResult and writes the DeploySiteSecrets row with a removed list whenever it is non-empty, so dropping a site's last secret: reference is audited centrally rather than only in site logs.
176 lines
7.3 KiB
C#
176 lines
7.3 KiB
C#
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
|
|
|
/// <summary>
|
|
/// <see cref="SiteSecretsCollector"/> is the central half of design §3.1/§3.3: derive which
|
|
/// secrets a site's data connections reference, resolve each against the central
|
|
/// <see cref="ISecretStore"/>, and report the ones that could not be resolved. It never sees
|
|
/// or produces plaintext — everything here is ciphertext bytes.
|
|
/// </summary>
|
|
public class SiteSecretsCollectorTests
|
|
{
|
|
private static DataConnectionArtifact Dc(string name, string? primary, string? backup = null)
|
|
=> new(name, "MxGateway", primary, backup, 3);
|
|
|
|
private static StoredSecret Row(string name, long revision, bool deleted = false, string? description = null) => new()
|
|
{
|
|
Name = new SecretName(name),
|
|
Description = description,
|
|
ContentType = SecretContentType.Text,
|
|
Ciphertext = [1, 2, 3],
|
|
Nonce = [4, 5],
|
|
Tag = [6, 7],
|
|
WrappedDek = [8, 9],
|
|
WrapNonce = [10, 11],
|
|
WrapTag = [12, 13],
|
|
KekId = "kek-1",
|
|
Revision = revision,
|
|
IsDeleted = deleted,
|
|
CreatedUtc = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
|
UpdatedUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z"),
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Collect_ReturnsOnlyReferencedRows_AndNamesMissing()
|
|
{
|
|
var store = new FakeSecretStore(Row("a", 3), Row("b", 1, deleted: true), Row("other", 1));
|
|
|
|
var result = await new SiteSecretsCollector(store).CollectAsync(
|
|
[Dc("GW1", """{"ApiKey":"secret:a"}"""), Dc("GW2", """{"ApiKey":"secret:b"}""")], default);
|
|
|
|
// Only the referenced names come back — "other" is in the store but never referenced.
|
|
Assert.Equal(["a"], result.Secrets.Select(s => s.Name));
|
|
Assert.Equal(3, result.Secrets[0].Revision);
|
|
// "b" is tombstoned (IsDeleted) — treated the same as absent.
|
|
Assert.Equal(["b"], result.Missing.Select(m => m.Name));
|
|
Assert.Equal(["GW2"], result.Missing[0].ReferencedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Collect_ResolvedRow_MapsEveryFieldFromTheStoredSecretVerbatim()
|
|
{
|
|
var row = Row("mxgw-key", revision: 7, description: "MxGateway API key");
|
|
var store = new FakeSecretStore(row);
|
|
|
|
var result = await new SiteSecretsCollector(store).CollectAsync(
|
|
[Dc("GW1", """{"ApiKey":"secret:mxgw-key"}""")], default);
|
|
|
|
var artifact = Assert.Single(result.Secrets);
|
|
Assert.Empty(result.Missing);
|
|
Assert.Equal(row.Name.Value, artifact.Name);
|
|
Assert.Equal(row.ContentType.ToString(), artifact.ContentType);
|
|
Assert.Equal(row.Ciphertext, artifact.Ciphertext);
|
|
Assert.Equal(row.Nonce, artifact.Nonce);
|
|
Assert.Equal(row.Tag, artifact.Tag);
|
|
Assert.Equal(row.WrappedDek, artifact.WrappedDek);
|
|
Assert.Equal(row.WrapNonce, artifact.WrapNonce);
|
|
Assert.Equal(row.WrapTag, artifact.WrapTag);
|
|
Assert.Equal(row.KekId, artifact.KekId);
|
|
Assert.Equal(row.Revision, artifact.Revision);
|
|
Assert.Equal(row.CreatedUtc, artifact.CreatedUtc);
|
|
Assert.Equal(row.UpdatedUtc, artifact.UpdatedUtc);
|
|
Assert.Equal(row.Description, artifact.Description);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Collect_AbsentName_IsReportedMissing_NotThrown()
|
|
{
|
|
var store = new FakeSecretStore();
|
|
|
|
var result = await new SiteSecretsCollector(store).CollectAsync(
|
|
[Dc("GW1", """{"ApiKey":"secret:never-created"}""")], default);
|
|
|
|
Assert.Empty(result.Secrets);
|
|
var missing = Assert.Single(result.Missing);
|
|
Assert.Equal("never-created", missing.Name);
|
|
Assert.Equal(["GW1"], missing.ReferencedBy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Collect_NoSecretReferences_ReturnsEmptyWithoutQueryingTheStore()
|
|
{
|
|
var store = new FakeSecretStore(Row("unused", 1));
|
|
|
|
var result = await new SiteSecretsCollector(store).CollectAsync(
|
|
[Dc("GW1", """{"Endpoint":"http://gw:5000"}""")], default);
|
|
|
|
Assert.Empty(result.Secrets);
|
|
Assert.Empty(result.Missing);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Collect_StoreThrows_ReturnsStoreError_InsteadOfThrowing()
|
|
{
|
|
// Final review M5: one site's store read failing must not abort a whole sweep.
|
|
var store = new FakeSecretStore(Row("a", 1)) { Fault = new InvalidOperationException("db down") };
|
|
|
|
var result = await new SiteSecretsCollector(store).CollectAsync(
|
|
[Dc("GW1", """{"ApiKey":"secret:a"}""")], default);
|
|
|
|
Assert.NotNull(result.StoreError);
|
|
Assert.Contains("'a'", result.StoreError);
|
|
Assert.Contains("InvalidOperationException: db down", result.StoreError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Collect_CallerCancellation_StillPropagates()
|
|
{
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
var store = new FakeSecretStore(Row("a", 1)) { Fault = new OperationCanceledException(cts.Token) };
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => new SiteSecretsCollector(store).CollectAsync(
|
|
[Dc("GW1", """{"ApiKey":"secret:a"}""")], cts.Token));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Collect_Success_HasNoStoreError()
|
|
{
|
|
var result = await new SiteSecretsCollector(new FakeSecretStore(Row("a", 1))).CollectAsync(
|
|
[Dc("GW1", """{"ApiKey":"secret:a"}""")], default);
|
|
|
|
Assert.Null(result.StoreError);
|
|
}
|
|
|
|
/// <summary>
|
|
/// In-memory <see cref="ISecretStore"/> stand-in. Implements only <see cref="GetAsync"/> —
|
|
/// the sole member <see cref="SiteSecretsCollector"/> calls; every other member throws so an
|
|
/// accidental call (a write, a list, a replication apply) fails the test loudly instead of
|
|
/// silently no-op'ing.
|
|
/// </summary>
|
|
private sealed class FakeSecretStore : ISecretStore
|
|
{
|
|
private readonly Dictionary<SecretName, StoredSecret> _rows;
|
|
|
|
public FakeSecretStore(params StoredSecret[] rows) => _rows = rows.ToDictionary(r => r.Name);
|
|
|
|
/// <summary>When set, <see cref="GetAsync"/> throws this instead of reading.</summary>
|
|
public Exception? Fault { get; init; }
|
|
|
|
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
|
|
Fault is not null
|
|
? Task.FromException<StoredSecret?>(Fault)
|
|
: Task.FromResult(_rows.TryGetValue(name, out var row) ? row : null);
|
|
|
|
public Task UpsertAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
|
|
|
|
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
|
|
public Task<bool> ApplyRewrapAsync(
|
|
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
}
|
|
}
|