using System.Security.Cryptography; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Cli.Interactive; using ZB.MOM.WW.Secrets.Crypto; using ZB.MOM.WW.Secrets.MasterKey; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; /// /// Exercises against real temp-dir SQLite stores: the same-KEK /// export/import round-trip, the no-plaintext-at-rest guarantee, tombstone filtering, /// last-writer-wins conflict resolution (and the per-row override that can force a bundle row), /// and cross-KEK import (rewrap when the source KEK is supplied, skip-and-report when it is not). /// public sealed class BundleServiceTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-secrets-bundle-{Guid.NewGuid():N}"); public BundleServiceTests() => Directory.CreateDirectory(_dir); public void Dispose() { if (Directory.Exists(_dir)) { try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ } } } private string Db(string name) => Path.Combine(_dir, name); private string BundlePath => Path.Combine(_dir, "bundle.json"); private static LiteralMasterKeyProvider NewKek() { byte[] key = new byte[32]; RandomNumberGenerator.Fill(key); return new LiteralMasterKeyProvider(Convert.ToBase64String(key)); } private static SecretsSession BuildSession(string dbPath, LiteralMasterKeyProvider kek) { var factory = new SecretsSqliteConnectionFactory(dbPath); var migrator = new SqliteSecretsStoreMigrator(factory); migrator.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult(); return new SecretsSession { Target = new TargetConfigReader().Manual(dbPath, new MasterKeyOptions()), Store = new SqliteSecretStore(factory), Migrator = migrator, MasterKey = kek, Cipher = new AesGcmEnvelopeCipher(kek), StoreKind = "sqlite", }; } private static async Task SealAsync(SecretsSession session, string name, string value) { StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text); await session.Store.UpsertAsync(row, CancellationToken.None); } private void WriteBundle(string sourceKekId, params StoredSecret[] rows) { var bundle = new SecretBundle { ExportedUtc = DateTimeOffset.UtcNow, SourceKekId = sourceKekId, Entries = rows.Select(SecretBundleCodec.FromRow).ToList(), }; File.WriteAllText(BundlePath, SecretBundleCodec.Serialize(bundle)); } [Fact] public async Task Export_then_import_into_empty_store_roundtrips_rows() { LiteralMasterKeyProvider kek = NewKek(); SecretsSession source = BuildSession(Db("a.db"), kek); // 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(); int exported = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); Assert.Equal(2, exported); // Same KEK, fresh empty store. SecretsSession target = BuildSession(Db("b.db"), kek); BundleImportReport report = await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); Assert.Equal(2, report.Imported); Assert.Equal(0, report.SkippedForeignKek); foreach ((string name, string value) in new[] { ("svc/one", "value-one"), ("svc/two", "value-two") }) { StoredSecret? src = await source.Store.GetAsync(new SecretName(name), CancellationToken.None); StoredSecret? dst = await target.Store.GetAsync(new SecretName(name), CancellationToken.None); Assert.NotNull(src); Assert.NotNull(dst); // Crypto + identity fields. Assert.Equal(src!.Name.Value, dst!.Name.Value); Assert.Equal(src.ContentType, dst.ContentType); Assert.Equal(src.Description, dst.Description); Assert.Equal(src.KekId, dst.KekId); Assert.Equal(src.Ciphertext, dst.Ciphertext); Assert.Equal(src.Nonce, dst.Nonce); Assert.Equal(src.Tag, dst.Tag); Assert.Equal(src.WrappedDek, dst.WrappedDek); 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 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( () => 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( () => 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] public async Task Bundle_json_contains_no_plaintext() { const string sentinel = "SENTINEL-3f9a2c-DO-NOT-LEAK"; LiteralMasterKeyProvider kek = NewKek(); SecretsSession source = BuildSession(Db("a.db"), kek); await SealAsync(source, "svc/secret", sentinel); var service = new BundleService(); await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); string raw = await File.ReadAllTextAsync(BundlePath, CancellationToken.None); Assert.DoesNotContain(sentinel, raw, StringComparison.Ordinal); // The base64 ciphertext of the sealed row is present in the file. StoredSecret? row = await source.Store.GetAsync(new SecretName("svc/secret"), CancellationToken.None); Assert.NotNull(row); Assert.Contains(Convert.ToBase64String(row!.Ciphertext), raw, StringComparison.Ordinal); Assert.Contains("\"Ciphertext\"", raw, StringComparison.Ordinal); } [Fact] public async Task Export_excludes_tombstones_by_default_and_includes_them_when_asked() { LiteralMasterKeyProvider kek = NewKek(); SecretsSession source = BuildSession(Db("a.db"), kek); await SealAsync(source, "svc/live", "live-value"); await SealAsync(source, "svc/dead", "dead-value"); await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: null, CancellationToken.None); var service = new BundleService(); int excluded = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); Assert.Equal(1, excluded); SecretBundle withoutDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None)); Assert.Equal(["svc/live"], withoutDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray()); int included = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None); Assert.Equal(2, included); SecretBundle withDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None)); Assert.Equal(["svc/dead", "svc/live"], withDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray()); } [Fact] public async Task Import_conflict_resolved_by_last_writer_wins() { LiteralMasterKeyProvider kek = NewKek(); SecretsSession target = BuildSession(Db("b.db"), kek); await SealAsync(target, "svc/conf", "existing-value"); StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; var service = new BundleService(); // Incoming OLDER than the existing row -> existing survives. StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "older-bundle-value", SecretContentType.Text) with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision }; WriteBundle(kek.KekId, older); BundleImportReport olderReport = await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); Assert.Equal(0, olderReport.Imported); Assert.Equal(1, olderReport.SkippedOlder); Assert.Equal(1, olderReport.Conflicts); StoredSecret afterOlder = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; Assert.Equal("existing-value", target.Cipher!.Decrypt(afterOlder)); // Incoming NEWER than the existing row -> bundle row replaces it. StoredSecret newer = target.Cipher!.Encrypt(new SecretName("svc/conf"), "newer-bundle-value", SecretContentType.Text) with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(1), Revision = existing.Revision }; WriteBundle(kek.KekId, newer); BundleImportReport newerReport = await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); Assert.Equal(1, newerReport.Imported); Assert.Equal(0, newerReport.SkippedOlder); Assert.Equal(1, newerReport.Conflicts); StoredSecret afterNewer = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; Assert.Equal("newer-bundle-value", target.Cipher!.Decrypt(afterNewer)); } [Fact] public async Task Import_conflict_callback_can_force_bundle_row() { LiteralMasterKeyProvider kek = NewKek(); SecretsSession target = BuildSession(Db("b.db"), kek); await SealAsync(target, "svc/conf", "existing-value"); StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; // Incoming is OLDER, so LWW would skip it — but the override forces take-incoming. StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "forced-bundle-value", SecretContentType.Text) with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision }; WriteBundle(kek.KekId, older); var service = new BundleService(); BundleImportReport report = await service.ImportAsync( target, BundlePath, sourceKek: null, conflictOverride: (_, _) => true, CancellationToken.None); Assert.Equal(1, report.Imported); Assert.Equal(0, report.SkippedOlder); Assert.Equal(1, report.Conflicts); StoredSecret after = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!; Assert.Equal("forced-bundle-value", target.Cipher!.Decrypt(after)); } [Fact] public async Task Cross_kek_import_rewraps_when_source_kek_supplied() { LiteralMasterKeyProvider kekA = NewKek(); LiteralMasterKeyProvider kekB = NewKek(); SecretsSession source = BuildSession(Db("a.db"), kekA); await SealAsync(source, "svc/cross", "cross-value"); var service = new BundleService(); await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); SecretsSession target = BuildSession(Db("b.db"), kekB); BundleImportReport report = await service.ImportAsync(target, BundlePath, sourceKek: kekA, conflictOverride: null, CancellationToken.None); Assert.Equal(1, report.Imported); Assert.Equal(0, report.SkippedForeignKek); StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None); Assert.NotNull(dst); Assert.Equal(kekB.KekId, dst!.KekId); Assert.Equal("cross-value", target.Cipher!.Decrypt(dst)); } [Fact] public async Task Cross_kek_import_without_source_kek_skips_rows_and_reports_them() { LiteralMasterKeyProvider kekA = NewKek(); LiteralMasterKeyProvider kekB = NewKek(); SecretsSession source = BuildSession(Db("a.db"), kekA); await SealAsync(source, "svc/cross", "cross-value"); var service = new BundleService(); await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None); SecretsSession target = BuildSession(Db("b.db"), kekB); BundleImportReport report = await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None); Assert.Equal(0, report.Imported); Assert.Equal(1, report.SkippedForeignKek); StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None); Assert.Null(dst); } }