fix(secrets-cli): bundle import — verbatim ApplyReplicated writes, format gate, entry-aware errors
This commit is contained in:
+112
-2
@@ -77,7 +77,13 @@ public sealed class BundleServiceTests : IDisposable
|
||||
{
|
||||
LiteralMasterKeyProvider kek = NewKek();
|
||||
SecretsSession source = BuildSession(Db("a.db"), kek);
|
||||
await SealAsync(source, "svc/one", "value-one");
|
||||
|
||||
// Seed non-trivial metadata: created_by/updated_by set, and a second update so Revision == 1
|
||||
// and created_utc != updated_utc — makes the "verbatim" assertion below actually load-bearing.
|
||||
StoredSecret seed = source.Cipher!.Encrypt(new SecretName("svc/one"), "value-one", SecretContentType.Text)
|
||||
with { Description = "the one", CreatedBy = "creator", UpdatedBy = "updater" };
|
||||
await source.Store.UpsertAsync(seed, CancellationToken.None);
|
||||
await source.Store.UpsertAsync(seed, CancellationToken.None);
|
||||
await SealAsync(source, "svc/two", "value-two");
|
||||
|
||||
var service = new BundleService();
|
||||
@@ -98,7 +104,7 @@ public sealed class BundleServiceTests : IDisposable
|
||||
Assert.NotNull(src);
|
||||
Assert.NotNull(dst);
|
||||
|
||||
// Fields UpsertAsync preserves verbatim.
|
||||
// Crypto + identity fields.
|
||||
Assert.Equal(src!.Name.Value, dst!.Name.Value);
|
||||
Assert.Equal(src.ContentType, dst.ContentType);
|
||||
Assert.Equal(src.Description, dst.Description);
|
||||
@@ -110,9 +116,113 @@ public sealed class BundleServiceTests : IDisposable
|
||||
Assert.Equal(src.WrapNonce, dst.WrapNonce);
|
||||
Assert.Equal(src.WrapTag, dst.WrapTag);
|
||||
|
||||
// Verbatim import: LWW metadata + audit stamps survive unchanged (ApplyReplicatedAsync).
|
||||
Assert.Equal(src.Revision, dst.Revision);
|
||||
Assert.Equal(src.CreatedUtc, dst.CreatedUtc);
|
||||
Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);
|
||||
Assert.Equal(src.IsDeleted, dst.IsDeleted);
|
||||
Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
|
||||
Assert.Equal(src.CreatedBy, dst.CreatedBy);
|
||||
Assert.Equal(src.UpdatedBy, dst.UpdatedBy);
|
||||
|
||||
// And it decrypts to the same plaintext under the same KEK.
|
||||
Assert.Equal(value, target.Cipher!.Decrypt(dst));
|
||||
}
|
||||
|
||||
// The twice-updated row genuinely exercised non-default metadata.
|
||||
StoredSecret one = (await target.Store.GetAsync(new SecretName("svc/one"), CancellationToken.None))!;
|
||||
Assert.Equal(1, one.Revision);
|
||||
Assert.Equal("creator", one.CreatedBy);
|
||||
Assert.Equal("updater", one.UpdatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_preserves_tombstones_verbatim()
|
||||
{
|
||||
LiteralMasterKeyProvider kek = NewKek();
|
||||
SecretsSession source = BuildSession(Db("a.db"), kek);
|
||||
await SealAsync(source, "svc/dead", "dead-value");
|
||||
await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: "remover", CancellationToken.None);
|
||||
StoredSecret src = (await source.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;
|
||||
|
||||
var service = new BundleService();
|
||||
int exported = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None);
|
||||
Assert.Equal(1, exported);
|
||||
|
||||
SecretsSession target = BuildSession(Db("b.db"), kek);
|
||||
BundleImportReport report =
|
||||
await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
|
||||
Assert.Equal(1, report.Imported);
|
||||
|
||||
// The imported row stays a tombstone (no resurrection) with its delete metadata intact.
|
||||
StoredSecret dst = (await target.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;
|
||||
Assert.True(dst.IsDeleted);
|
||||
Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
|
||||
Assert.Equal(src.Revision, dst.Revision);
|
||||
Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);
|
||||
|
||||
// And it is not listed as a live secret.
|
||||
IReadOnlyList<SecretMetadata> live = await target.Store.ListAsync(includeDeleted: false, CancellationToken.None);
|
||||
Assert.Empty(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_rejects_unknown_format_version()
|
||||
{
|
||||
LiteralMasterKeyProvider kek = NewKek();
|
||||
SecretsSession target = BuildSession(Db("b.db"), kek);
|
||||
|
||||
const string json = """
|
||||
{
|
||||
"FormatVersion": 99,
|
||||
"ExportedUtc": "2026-01-01T00:00:00+00:00",
|
||||
"SourceKekId": "kek",
|
||||
"Entries": []
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);
|
||||
|
||||
var service = new BundleService();
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
|
||||
Assert.Contains("99", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("1", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_malformed_entry_fails_with_entry_context()
|
||||
{
|
||||
LiteralMasterKeyProvider kek = NewKek();
|
||||
SecretsSession target = BuildSession(Db("b.db"), kek);
|
||||
|
||||
// A well-formed bundle envelope carrying one entry with non-base64 ciphertext.
|
||||
const string json = """
|
||||
{
|
||||
"FormatVersion": 1,
|
||||
"ExportedUtc": "2026-01-01T00:00:00+00:00",
|
||||
"SourceKekId": "kek",
|
||||
"Entries": [
|
||||
{
|
||||
"Name": "svc/broken", "Description": null, "ContentType": "Text",
|
||||
"Ciphertext": "@@not-base64@@", "Nonce": "AAAA", "Tag": "AAAA",
|
||||
"WrappedDek": "AAAA", "WrapNonce": "AAAA", "WrapTag": "AAAA",
|
||||
"KekId": "kek", "Revision": 0, "IsDeleted": false, "DeletedUtc": null,
|
||||
"CreatedUtc": "2026-01-01T00:00:00+00:00", "UpdatedUtc": "2026-01-01T00:00:00+00:00",
|
||||
"CreatedBy": null, "UpdatedBy": null
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);
|
||||
|
||||
var service = new BundleService();
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
|
||||
Assert.Contains("svc/broken", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains(BundlePath, ex.Message, StringComparison.Ordinal);
|
||||
|
||||
// The store was left untouched.
|
||||
Assert.Null(await target.Store.GetAsync(new SecretName("svc/broken"), CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user