Skip to content

Commit ef6cbee

Browse files
Benehikoclaude
andcommitted
test(keychain): isolate real-keychain dedup tests and make unlock/cleanup reliable
The real-keychain dedup tests shared the com.test.test/test service namespace with TestKeychain. When their cleanup failed, the leftover credential leaked into the shared collection and broke TestKeychain (list_all_credentials saw an extra entry; filter_credentials failed to unmarshal the seed's secret). Cleanup failed because purgeRealItems deleted without unlocking the collection, and the seed itself intermittently hit "Cannot create an item in a locked collection": the first Unlock in a run can return before the daemon has actually unlocked, so an immediate CreateItem/DeleteItem races and fails. - Give the dedup tests their own service group/name (com.test.dedup/dedup) so their items are namespace-isolated; a leak can no longer reach TestKeychain's GetAllMetadata/Filter. - Add ensureUnlocked, which unlocks then polls IsLocked until the daemon reports the collection unlocked, closing the unlock race for the direct-to-daemon seed and purge paths. - Make purgeRealItems unlock first and delete all matches, asserting success so a silent cleanup failure surfaces as a test failure rather than a cross-test leak. - Seed items with a valid username:password secret so seeded items are well-formed. Refs: docker/secrets-engine-private#446 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5709d2d commit ef6cbee

1 file changed

Lines changed: 54 additions & 32 deletions

File tree

store/keychain/keychain_linux_test.go

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,30 @@ func TestKeychainSaveCollapsesDuplicatesInPlace(t *testing.T) {
180180
"the remaining duplicates must be collapsed, leaving only the first match")
181181
}
182182

183+
// The real-keychain dedup tests use their own service group/name so their items
184+
// are namespace-isolated from TestKeychain (which shares com.test.test/test).
185+
// GetAllMetadata/Filter search by {service:group, service:name}, so a leaked
186+
// dedup item can never show up in — and break — the shared suite.
187+
const (
188+
dedupServiceGroup = "com.test.dedup"
189+
dedupServiceName = "dedup"
190+
)
191+
192+
// ensureUnlocked unlocks the collection and waits until the daemon actually
193+
// reports it unlocked. The freedesktop Unlock call can return before the
194+
// collection is fully unlocked, so a CreateItem/DeleteItem issued immediately
195+
// after can still fail with "locked collection" — polling IsLocked closes that
196+
// race. (The store's own Save/Get/Delete avoid it only because the collection
197+
// stays unlocked once any earlier operation has unlocked it.)
198+
func ensureUnlocked(t *testing.T, svc *kc.SecretService, collection dbus.ObjectPath) {
199+
t.Helper()
200+
require.NoError(t, svc.Unlock([]dbus.ObjectPath{collection}))
201+
require.Eventually(t, func() bool {
202+
locked, err := svc.IsLocked(collection)
203+
return err == nil && !locked
204+
}, 5*time.Second, 100*time.Millisecond, "collection did not unlock")
205+
}
206+
183207
// searchRealItems queries a live Secret Service for every item sharing id's
184208
// stable identity triple {service:group, service:name, id}. The store API keys
185209
// results by ID and so would collapse duplicates into one logical entry —
@@ -251,15 +275,14 @@ func seedRealDuplicates(t *testing.T, serviceGroup, serviceName string, id store
251275
collection, err := getDefaultCollection(svc)
252276
require.NoError(t, err)
253277

254-
// Unlock the collection first — talking to the daemon directly skips the
255-
// unlock the store does internally, and gnome-keyring reports even the
256-
// passwordless 'login' collection as locked until then. Unlock is a no-op on
257-
// an already-unlocked collection.
258-
require.NoError(t, svc.Unlock([]dbus.ObjectPath{collection}))
278+
// Talking to the daemon directly skips the unlock the store does internally,
279+
// and gnome-keyring reports even the passwordless 'login' collection as
280+
// locked until then.
281+
ensureUnlocked(t, svc, collection)
259282

260283
label := serviceGroup + ":" + serviceName + ":" + id.String()
261284
for i := range n {
262-
sessSecret, err := session.NewSecret(fmt.Appendf(nil, "seed-secret-%d", i))
285+
sessSecret, err := session.NewSecret(fmt.Appendf(nil, "seed-user:seed-pass-%d", i))
263286
require.NoError(t, err)
264287

265288
attrs := map[string]string{
@@ -276,19 +299,26 @@ func seedRealDuplicates(t *testing.T, serviceGroup, serviceName string, id store
276299
}
277300

278301
// purgeRealItems removes every item for id, draining any leftover duplicates so
279-
// a failed assertion cannot leak state into another test. Best-effort: a delete
280-
// that fails should not mask the test's own failure.
302+
// the test cannot leak state. It unlocks the collection first (DeleteItem fails
303+
// on a locked collection) and deletes all matches, asserting success so a silent
304+
// cleanup failure surfaces as a leak rather than corrupting a later test.
281305
func purgeRealItems(t *testing.T, serviceGroup, serviceName string, id store.ID) {
282306
t.Helper()
283-
items := findRealItems(t, serviceGroup, serviceName, id)
284-
if len(items) == 0 {
285-
return
286-
}
287307
svc, err := kc.NewService()
288308
require.NoError(t, err)
289309
defer func() { _ = svc.Close() }()
310+
311+
collection, err := getDefaultCollection(svc)
312+
require.NoError(t, err)
313+
ensureUnlocked(t, svc, collection)
314+
315+
attrs := map[string]string{}
316+
safelySetMetadata(serviceGroup, serviceName, attrs)
317+
safelySetID(id, attrs)
318+
items, err := svc.SearchCollection(collection, attrs)
319+
require.NoError(t, err)
290320
for _, item := range items {
291-
_ = svc.DeleteItem(item)
321+
require.NoError(t, svc.DeleteItem(item))
292322
}
293323
}
294324

@@ -297,18 +327,14 @@ func purgeRealItems(t *testing.T, serviceGroup, serviceName string, id store.ID)
297327
// identity, a single Save must update one item in place and delete the rest,
298328
// leaving exactly one item holding the latest secret.
299329
func TestKeychainCollapsesExistingDuplicates(t *testing.T) {
300-
const (
301-
serviceGroup = "com.test.test"
302-
serviceName = "test"
303-
)
304-
id := store.MustParseID("com.test.test/test/dup-backlog")
305-
t.Cleanup(func() { purgeRealItems(t, serviceGroup, serviceName, id) })
330+
id := store.MustParseID(dedupServiceGroup + "/" + dedupServiceName + "/backlog")
331+
t.Cleanup(func() { purgeRealItems(t, dedupServiceGroup, dedupServiceName, id) })
306332

307333
// Pre-existing backlog: three duplicate items for one identity.
308-
seedRealDuplicates(t, serviceGroup, serviceName, id, 3)
309-
require.Len(t, findRealItems(t, serviceGroup, serviceName, id), 3, "precondition: three duplicates seeded")
334+
seedRealDuplicates(t, dedupServiceGroup, dedupServiceName, id, 3)
335+
require.Len(t, findRealItems(t, dedupServiceGroup, dedupServiceName, id), 3, "precondition: three duplicates seeded")
310336

311-
ks, err := New(serviceGroup, serviceName, func(_ context.Context, _ store.ID) store.Secret {
337+
ks, err := New(dedupServiceGroup, dedupServiceName, func(_ context.Context, _ store.ID) store.Secret {
312338
return &mocks.MockCredential{}
313339
})
314340
require.NoError(t, err)
@@ -318,7 +344,7 @@ func TestKeychainCollapsesExistingDuplicates(t *testing.T) {
318344
Password: "final-password",
319345
}))
320346

321-
requireItemCount(t, serviceGroup, serviceName, id, 1,
347+
requireItemCount(t, dedupServiceGroup, dedupServiceName, id, 1,
322348
"a single Save must collapse the duplicate backlog to one item")
323349

324350
got, err := ks.Get(t.Context(), id)
@@ -332,14 +358,10 @@ func TestKeychainCollapsesExistingDuplicates(t *testing.T) {
332358
// metadata that changes on every save (mimicking volatile JWT claims) must keep
333359
// exactly one item, never minting duplicates.
334360
func TestKeychainSaveDoesNotAccumulate(t *testing.T) {
335-
const (
336-
serviceGroup = "com.test.test"
337-
serviceName = "test"
338-
)
339-
id := store.MustParseID("com.test.test/test/no-accumulate")
340-
t.Cleanup(func() { purgeRealItems(t, serviceGroup, serviceName, id) })
341-
342-
ks, err := New(serviceGroup, serviceName, func(_ context.Context, _ store.ID) store.Secret {
361+
id := store.MustParseID(dedupServiceGroup + "/" + dedupServiceName + "/no-accumulate")
362+
t.Cleanup(func() { purgeRealItems(t, dedupServiceGroup, dedupServiceName, id) })
363+
364+
ks, err := New(dedupServiceGroup, dedupServiceName, func(_ context.Context, _ store.ID) store.Secret {
343365
return &mocks.MockCredential{}
344366
})
345367
require.NoError(t, err)
@@ -355,7 +377,7 @@ func TestKeychainSaveDoesNotAccumulate(t *testing.T) {
355377
}))
356378
}
357379

358-
requireItemCount(t, serviceGroup, serviceName, id, 1,
380+
requireItemCount(t, dedupServiceGroup, dedupServiceName, id, 1,
359381
"saving with changing metadata must not accumulate duplicate items")
360382

361383
got, err := ks.Get(t.Context(), id)

0 commit comments

Comments
 (0)