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); await SealAsync(source, "svc/one", "value-one"); 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); // Fields UpsertAsync preserves verbatim. 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); // And it decrypts to the same plaintext under the same KEK. Assert.Equal(value, target.Cipher!.Decrypt(dst)); } } [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); } }