fix(secrets-cli): doctor flow — contained old-key errors, corrupt-row + rewrap-failure coverage
This commit is contained in:
+101
@@ -31,6 +31,9 @@ public sealed class KekDoctorFlowTests : IAsyncLifetime, IDisposable
|
||||
// retained so a test can paste KEK-A at the prompt and assert it never echoes back to the terminal.
|
||||
private readonly string _kekABase64 = Convert.ToBase64String(FillKey(0xA1));
|
||||
private readonly string _kekBBase64 = Convert.ToBase64String(FillKey(0xB2));
|
||||
|
||||
// A third, valid-format 32-byte key never used to seal a row — a "wrong but well-formed" paste.
|
||||
private readonly string _kekCBase64 = Convert.ToBase64String(FillKey(0xC3));
|
||||
private readonly LiteralMasterKeyProvider _kekA;
|
||||
private readonly LiteralMasterKeyProvider _kekB;
|
||||
|
||||
@@ -184,6 +187,104 @@ public sealed class KekDoctorFlowTests : IAsyncLifetime, IDisposable
|
||||
Assert.DoesNotContain("NEW-A-VALUE", console.Output, StringComparison.Ordinal); // value never echoed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Malformed_pasted_old_key_renders_friendly_error_and_still_re_diagnoses()
|
||||
{
|
||||
await SeedAsync("svc/a", "value-a", _kekA);
|
||||
SecretsSession session = Session(_kekB);
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
|
||||
console.Input.PushKey(ConsoleKey.DownArrow);
|
||||
console.Input.PushKey(ConsoleKey.DownArrow);
|
||||
console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key"
|
||||
console.Input.PushTextWithEnter("!!!not-valid-base64!!!"); // malformed → ArgumentException
|
||||
|
||||
await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
|
||||
|
||||
Assert.Contains("Invalid old KEK", console.Output, StringComparison.Ordinal); // contained, not shell-escaped
|
||||
Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs
|
||||
Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Env_var_old_key_source_naming_unset_var_renders_friendly_error_and_still_re_diagnoses()
|
||||
{
|
||||
await SeedAsync("svc/a", "value-a", _kekA);
|
||||
SecretsSession session = Session(_kekB);
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
|
||||
console.Input.PushKey(ConsoleKey.Enter); // old-key source: "Environment variable" (index 0)
|
||||
// An env var guaranteed unset → KekId read lazily throws MasterKeyUnavailableException.
|
||||
console.Input.PushTextWithEnter($"ZB_TEST_UNSET_{Guid.NewGuid():N}");
|
||||
|
||||
await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
|
||||
|
||||
Assert.Contains("Old KEK unavailable", console.Output, StringComparison.Ordinal); // contained
|
||||
Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs
|
||||
Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Corrupt_row_is_offered_reset_as_the_only_remedy()
|
||||
{
|
||||
// Seed under the session KEK, then tamper the DEK wrap tag: kek_id still matches the session KEK,
|
||||
// but the decrypt probe fails closed → the row diagnoses Corrupt (not WrongKek).
|
||||
await SeedAsync("svc/corrupt", "original", _kekA);
|
||||
StoredSecret seeded = await GetAsync("svc/corrupt");
|
||||
byte[] tag = (byte[])seeded.WrapTag.Clone();
|
||||
tag[0] ^= 0xFF;
|
||||
await _store.UpsertAsync(seeded with { WrapTag = tag }, CancellationToken.None);
|
||||
|
||||
SecretsSession session = Session(_kekA); // same KEK — so the row is Corrupt, not WrongKek
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushKey(ConsoleKey.DownArrow); // remedy menu: move to
|
||||
console.Input.PushKey(ConsoleKey.Enter); // "Old KEK is lost — re-set affected secrets"
|
||||
console.Input.PushTextWithEnter("y"); // accept re-set of the corrupt row
|
||||
console.Input.PushTextWithEnter("RESEEDED-VALUE"); // masked new value
|
||||
console.Input.PushKey(ConsoleKey.Enter); // content type: Text
|
||||
|
||||
await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
|
||||
|
||||
Assert.Contains("corrupt", console.Output, StringComparison.Ordinal); // the diagnosis surfaced it
|
||||
Assert.Contains("only remedy", console.Output, StringComparison.Ordinal); // the re-set note
|
||||
|
||||
// The corrupt row was re-seeded and now decrypts cleanly under the session KEK.
|
||||
var cipherA = new AesGcmEnvelopeCipher(_kekA);
|
||||
StoredSecret after = await GetAsync("svc/corrupt");
|
||||
Assert.Equal("RESEEDED-VALUE", cipherA.Decrypt(after));
|
||||
Assert.DoesNotContain("RESEEDED-VALUE", console.Output, StringComparison.Ordinal); // never echoed
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_but_wellformed_old_key_fails_the_rewrap_and_still_re_diagnoses()
|
||||
{
|
||||
// Rows under KEK-A; session on KEK-B. The operator pastes KEK-C — valid format, but neither the
|
||||
// old (A) nor the session (B) KEK → RewrapAllAsync aborts with SecretDecryptionException.
|
||||
await SeedAsync("svc/a", "value-a", _kekA);
|
||||
await SeedAsync("svc/b", "value-b", _kekA);
|
||||
SecretsSession session = Session(_kekB);
|
||||
|
||||
TestConsole console = NewConsole();
|
||||
console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK"
|
||||
console.Input.PushKey(ConsoleKey.DownArrow);
|
||||
console.Input.PushKey(ConsoleKey.DownArrow);
|
||||
console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key"
|
||||
console.Input.PushTextWithEnter(_kekCBase64); // valid format, wrong key
|
||||
console.Input.PushTextWithEnter("y"); // confirm the rewrap
|
||||
|
||||
await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None);
|
||||
|
||||
Assert.Contains("Rewrap failed", console.Output, StringComparison.Ordinal); // the SecretDecryptionException path
|
||||
Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs
|
||||
|
||||
// The pass aborted on the first row → both rows remain under KEK-A.
|
||||
Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId);
|
||||
Assert.Equal(_kekA.KekId, (await GetAsync("svc/b")).KekId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
Reference in New Issue
Block a user