using System.Security.Cryptography; using Spectre.Console; using Spectre.Console.Testing; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Cli.Interactive; using ZB.MOM.WW.Secrets.Cli.Interactive.Flows; using ZB.MOM.WW.Secrets.MasterKey; namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows; /// /// Drives the two bundle flows (, ) directly /// against real temp-SQLite full sessions. Export writes a ciphertext-only bundle to a scripted path; /// import reconciles into a second store — exercising last-writer-wins, the per-row conflict prompt, and /// the foreign-KEK detection that prompts for the source key and re-wraps. Each flow renders to a scripted /// , so the tests assert on rendered and on the /// resulting store state — the invariant under test is that a pasted source key never reaches the screen. /// public sealed class BundleFlowTests : IDisposable { private readonly string _dir = Path.Combine(Path.GetTempPath(), $"zb-secrets-bundleflow-{Guid.NewGuid():N}"); public BundleFlowTests() => 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 TestConsole NewConsole() { var console = (TestConsole)new TestConsole().Interactive(); console.Profile.Width = 240; // wide enough that the report table never truncates a cell return console; } private static LiteralMasterKeyProvider NewKek(out string base64) { byte[] key = new byte[32]; RandomNumberGenerator.Fill(key); base64 = Convert.ToBase64String(key); return new LiteralMasterKeyProvider(base64); } // A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the // test never depends on ambient environment state. private async Task NewSessionAsync(string dbName, LiteralMasterKeyProvider kek) { StoreTarget target = new TargetConfigReader().Manual(Db(dbName), new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}", }); return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None); } 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); } // Writes a row into a store verbatim (bypassing the revision bump) so a test can pin an explicit // UpdatedUtc/Revision — used to make a target row deterministically newer than an incoming bundle row. private static async Task ApplyNewerAsync(SecretsSession session, string name, string value) { StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text) with { UpdatedUtc = DateTimeOffset.UtcNow.AddDays(1), CreatedUtc = DateTimeOffset.UtcNow.AddDays(1), Revision = 7, }; await session.Store.ApplyReplicatedAsync(row, CancellationToken.None); } [Fact] public async Task Export_prompts_for_path_and_reports_row_count() { LiteralMasterKeyProvider kek = NewKek(out _); SecretsSession session = await NewSessionAsync("a.db", kek); await SealAsync(session, "svc/one", "value-one"); await SealAsync(session, "svc/two", "value-two"); string outPath = Path.Combine(_dir, "typed-bundle.json"); TestConsole console = NewConsole(); console.Input.PushTextWithEnter(outPath); // path (overrides the offered default) console.Input.PushTextWithEnter("n"); // include tombstoned? no await new BundleExportFlow().RunAsync(console, session, CancellationToken.None); Assert.True(new BundleExportFlow().RequiresKek); Assert.True(File.Exists(outPath)); Assert.Contains("2", console.Output, StringComparison.Ordinal); // The offered default filename carries the target name and the .json extension. Assert.Contains($"secrets-bundle-{session.Target.Name}", console.Output, StringComparison.Ordinal); Assert.Contains(".json", console.Output, StringComparison.Ordinal); } [Fact] public async Task Import_reports_counts_and_applies_lww() { LiteralMasterKeyProvider kek = NewKek(out _); SecretsSession source = await NewSessionAsync("a.db", kek); await SealAsync(source, "svc/x", "bundle-older-value"); // Export the bundle from the source store (same KEK as the target). var exportConsole = NewConsole(); exportConsole.Input.PushTextWithEnter(BundlePath); exportConsole.Input.PushTextWithEnter("n"); await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None); // Target holds a newer conflicting row: LWW must keep it and skip the bundle row. SecretsSession target = await NewSessionAsync("b.db", kek); await ApplyNewerAsync(target, "svc/x", "local-newer-value"); TestConsole console = NewConsole(); console.Input.PushTextWithEnter(BundlePath); // path (same KEK -> no source-key prompt) console.Input.PushTextWithEnter("n"); // prompt per conflict? no -> pure LWW await new BundleImportFlow().RunAsync(console, target, CancellationToken.None); Assert.Contains("Imported", console.Output, StringComparison.Ordinal); // report table rendered StoredSecret? after = await target.Store.GetAsync(new SecretName("svc/x"), CancellationToken.None); Assert.NotNull(after); Assert.Equal("local-newer-value", target.Cipher!.Decrypt(after!)); // LWW kept the newer local row } [Fact] public async Task Import_conflict_prompt_lets_operator_pick_per_row() { LiteralMasterKeyProvider kek = NewKek(out _); SecretsSession source = await NewSessionAsync("a.db", kek); await SealAsync(source, "svc/a", "bundle-a"); await SealAsync(source, "svc/b", "bundle-b"); var exportConsole = NewConsole(); exportConsole.Input.PushTextWithEnter(BundlePath); exportConsole.Input.PushTextWithEnter("n"); await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None); // Both names already exist in the target (as newer rows) -> both are conflicts. SecretsSession target = await NewSessionAsync("b.db", kek); await ApplyNewerAsync(target, "svc/a", "local-a"); await ApplyNewerAsync(target, "svc/b", "local-b"); TestConsole console = NewConsole(); console.Input.PushTextWithEnter(BundlePath); // path console.Input.PushTextWithEnter("y"); // prompt per conflict? yes // Bundle entries arrive in name order: svc/a first, svc/b second. console.Input.PushTextWithEnter("n"); // svc/a: take the bundle row? no -> keep local console.Input.PushTextWithEnter("y"); // svc/b: take the bundle row? yes -> take bundle await new BundleImportFlow().RunAsync(console, target, CancellationToken.None); StoredSecret? a = await target.Store.GetAsync(new SecretName("svc/a"), CancellationToken.None); StoredSecret? b = await target.Store.GetAsync(new SecretName("svc/b"), CancellationToken.None); Assert.Equal("local-a", target.Cipher!.Decrypt(a!)); // kept local Assert.Equal("bundle-b", target.Cipher!.Decrypt(b!)); // took the bundle } [Fact] public async Task Import_foreign_kek_prompts_for_source_key_then_rewraps() { LiteralMasterKeyProvider kekA = NewKek(out string base64A); LiteralMasterKeyProvider kekB = NewKek(out _); Assert.NotEqual(kekA.KekId, kekB.KekId); SecretsSession source = await NewSessionAsync("a.db", kekA); await SealAsync(source, "svc/cross", "cross-value"); var exportConsole = NewConsole(); exportConsole.Input.PushTextWithEnter(BundlePath); exportConsole.Input.PushTextWithEnter("n"); await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None); // Session on KEK-B: the flow must detect the foreign source KEK and prompt for KEK-A. SecretsSession target = await NewSessionAsync("b.db", kekB); TestConsole console = NewConsole(); console.Input.PushTextWithEnter(BundlePath); // path console.Input.PushKey(ConsoleKey.Enter); // source-key selection: first choice (paste base64) console.Input.PushTextWithEnter(base64A); // pasted KEK-A material (masked) console.Input.PushTextWithEnter("n"); // prompt per conflict? no (empty target anyway) await new BundleImportFlow().RunAsync(console, target, CancellationToken.None); StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None); Assert.NotNull(dst); Assert.Equal(kekB.KekId, dst!.KekId); // re-wrapped under the session KEK Assert.Equal("cross-value", target.Cipher!.Decrypt(dst)); // and decrypts Assert.DoesNotContain(base64A, console.Output, StringComparison.Ordinal); // pasted key never echoed } }