From 15bd814021bb40b0f29926078c19fee541f5770b Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Fri, 21 Aug 2026 05:09:13 +0000 Subject: [PATCH] test: harden read-after-write in secret-store export/restore tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Keychain Export/Restore tests read back with Assert.Single(await ExportCredentialsAsync()) immediately after an Add/Restore. ExportCredentialsAsync enumerates the whole app via QueryAllItemsForApp, and a just-completed add isn't always visible to that query yet under concurrent (multi-targeted) macOS runs — so RestoreCredentialAsync_PreservesAccountIdAndSelection flaked with "Assert.Single() Failure: The collection was empty" on a macOS leg. Same add-visibility race we already fixed for the delete assertions; extend the fix to the read-after-write points. Adds RetryHelper.UntilAsync(action, predicate) and wraps the six ExportCredentialsAsync read-backs across the Keychain and libsecret suites so they poll for the item to become visible. Returns the last result, so a genuine failure still surfaces via the assertion. Co-Authored-By: Claude Opus 4.8 --- .../Infrastructure/RetryHelper.cs | 25 +++++++++++++++++++ .../KeychainCredentialManagerTests.cs | 6 ++--- .../LibsecretCredentialManagerTests.cs | 6 ++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs index dc357e8..bf72c6a 100644 --- a/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs +++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Infrastructure/RetryHelper.cs @@ -38,4 +38,29 @@ internal static async Task UntilTrueAsync( return false; } + + /// + /// Invokes until its result satisfies + /// or the attempts are exhausted, and returns + /// that result. Used for read-after-write against the OS secret stores, + /// where a just-completed add isn't always immediately visible to the next + /// query under concurrent (multi-targeted) test runs. Returns the last + /// result even if the predicate never held, so the caller's assertion still + /// reports the real failure rather than a timeout. + /// + internal static async Task UntilAsync( + Func> action, + Func predicate, + int maxAttempts = 20, + int delayMs = 25) + { + var result = await action(); + for (var attempt = 1; !predicate(result) && attempt < maxAttempts; attempt++) + { + await Task.Delay(delayMs); + result = await action(); + } + + return result; + } } diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs index 3805081..88b52aa 100644 --- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs +++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/KeychainCredentialManagerTests.cs @@ -76,7 +76,7 @@ public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection() var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}"); Assert.True(await manager.SelectCredentialAsync(id)); - var adobe = Assert.Single(await manager.ExportCredentialsAsync()); + var adobe = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1)); Assert.Equal(id, adobe.AccountId); Assert.Equal("prod", adobe.AccountName); Assert.Equal("Adobe", adobe.ProviderName); @@ -94,13 +94,13 @@ public async Task RestoreCredentialAsync_PreservesAccountIdAndSelection() var manager = NewManager(); var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}"); _ = await manager.SelectCredentialAsync(id); - var exported = Assert.Single(await manager.ExportCredentialsAsync()); + var exported = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1)); // Simulate an import: drop it, then restore from the export record. Assert.True(await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(id))); await manager.RestoreCredentialAsync(exported); - var restored = Assert.Single(await manager.ExportCredentialsAsync()); + var restored = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1)); Assert.Equal(id, restored.AccountId); Assert.True(restored.IsSelected); Assert.Equal("{\"apiKey\":\"secret\"}", await manager.GetSelectedCredentialAsync("Adobe")); diff --git a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs index c181582..cbeb557 100644 --- a/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs +++ b/tests/NextIteration.SpectreConsole.Auth.Tests/Persistence/LibsecretCredentialManagerTests.cs @@ -113,7 +113,7 @@ public async Task ExportCredentialsAsync_ReturnsDecryptedPayloadAndSelection() var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}"); Assert.True(await manager.SelectCredentialAsync(id)); - var adobe = Assert.Single(await manager.ExportCredentialsAsync()); + var adobe = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1)); Assert.Equal(id, adobe.AccountId); Assert.Equal("prod", adobe.AccountName); Assert.Equal("Adobe", adobe.ProviderName); @@ -129,13 +129,13 @@ public async Task RestoreCredentialAsync_PreservesAccountIdCreatedAtAndSelection var manager = NewManager(); var id = await manager.AddCredentialAsync("Adobe", "prod", "Production", "{\"apiKey\":\"secret\"}"); _ = await manager.SelectCredentialAsync(id); - var exported = Assert.Single(await manager.ExportCredentialsAsync()); + var exported = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1)); // Simulate an import: drop it, then restore from the export record. Assert.True(await RetryHelper.UntilTrueAsync(() => manager.DeleteCredentialAsync(id))); await manager.RestoreCredentialAsync(exported); - var restored = Assert.Single(await manager.ExportCredentialsAsync()); + var restored = Assert.Single(await RetryHelper.UntilAsync(() => manager.ExportCredentialsAsync(), r => r.Count == 1)); Assert.Equal(id, restored.AccountId); Assert.Equal(exported.CreatedAt, restored.CreatedAt); // libsecret preserves it via attribute Assert.True(restored.IsSelected);