Skip to content

Commit cff4885

Browse files
Benehikoclaude
andcommitted
test(keychain): cover create, in-place collapse, and real-keychain dedup convergence
Fake-backed unit tests for the create vs in-place-collapse branches, plus real-keychain integration tests (run under the gnome-keyring harness) that seed a duplicate backlog and assert a single Save collapses it to one item, and that repeated saves with changing metadata never accumulate. The integration assertions poll the daemon via require.EventuallyWithT until the expected item count is reached, absorbing the lag between the store deleting duplicates and an independent connection observing it without masking a genuine failure to converge. The dedup tests use their own service group/name (com.test.dedup/dedup) so a leaked credential can never reach TestKeychain, and ensureUnlocked polls IsLocked after Unlock to close the first-unlock race on the direct-to-daemon seed and purge paths. Refs: docker/secrets-engine-private#446 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d8b4000 commit cff4885

1 file changed

Lines changed: 275 additions & 3 deletions

File tree

store/keychain/keychain_linux_test.go

Lines changed: 275 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,19 @@
1717
package keychain
1818

1919
import (
20+
"context"
21+
"fmt"
2022
"sync/atomic"
2123
"testing"
24+
"time"
2225

2326
dbus "github.com/godbus/dbus/v5"
2427
"github.com/stretchr/testify/assert"
2528
"github.com/stretchr/testify/require"
2629

2730
"github.com/docker/secrets-engine/store"
2831
kc "github.com/docker/secrets-engine/store/keychain/internal/go-keychain/secretservice"
32+
"github.com/docker/secrets-engine/store/mocks"
2933
)
3034

3135
// fakeService is a pure in-memory [secretService]. It never talks to a real
@@ -40,6 +44,13 @@ type fakeService struct {
4044
// "credential not found" path.
4145
items []dbus.ObjectPath
4246

47+
// recorded write operations, for assertions in the Save tests. Not
48+
// concurrency-safe: the tests that read them drive a single sequential
49+
// operation through the fake.
50+
createCalls int
51+
setSecretItems []dbus.ObjectPath
52+
deletedItems []dbus.ObjectPath
53+
4354
opened atomic.Int64
4455
closed atomic.Int64
4556
}
@@ -50,7 +61,9 @@ func (f *fakeService) Collections() ([]dbus.ObjectPath, error) {
5061
func (f *fakeService) ReadAlias(string) (dbus.ObjectPath, error) { return loginKeychainObjectPath, nil }
5162
func (f *fakeService) IsLocked(dbus.ObjectPath) (bool, error) { return false, nil }
5263
func (f *fakeService) OpenSession(kc.AuthenticationMode) (*kc.Session, error) {
53-
return &kc.Session{}, nil
64+
// plain mode so Session.NewSecret works without a negotiated AES key, which
65+
// lets the Save path run end-to-end against the fake.
66+
return &kc.Session{Mode: kc.AuthenticationInsecurePlain}, nil
5467
}
5568
func (f *fakeService) CloseSession(*kc.Session) {}
5669
func (f *fakeService) Unlock([]dbus.ObjectPath) error { return nil }
@@ -59,11 +72,22 @@ func (f *fakeService) SearchCollection(dbus.ObjectPath, kc.Attributes) ([]dbus.O
5972
}
6073

6174
func (f *fakeService) CreateItem(dbus.ObjectPath, map[string]dbus.Variant, kc.Secret, kc.ReplaceBehavior) (dbus.ObjectPath, error) {
62-
return "", nil
75+
f.createCalls++
76+
return "/created", nil
77+
}
78+
79+
func (f *fakeService) DeleteItem(item dbus.ObjectPath) error {
80+
f.deletedItems = append(f.deletedItems, item)
81+
return nil
6382
}
64-
func (f *fakeService) DeleteItem(dbus.ObjectPath) error { return nil }
6583
func (f *fakeService) GetAttributes(dbus.ObjectPath) (kc.Attributes, error) { return nil, nil }
6684
func (f *fakeService) GetSecret(dbus.ObjectPath, kc.Session) ([]byte, error) { return nil, nil }
85+
func (f *fakeService) SetItemSecret(item dbus.ObjectPath, _ kc.Secret) error {
86+
f.setSecretItems = append(f.setSecretItems, item)
87+
return nil
88+
}
89+
func (f *fakeService) SetItemAttributes(dbus.ObjectPath, kc.Attributes) error { return nil }
90+
func (f *fakeService) SetItemLabel(dbus.ObjectPath, string) error { return nil }
6791
func (f *fakeService) Close() error {
6892
f.closed.Add(1)
6993
return nil
@@ -116,6 +140,254 @@ func TestKeychainClosesEveryConnection(t *testing.T) {
116140
assert.Equal(t, opened, closed, "every opened connection must be closed")
117141
}
118142

143+
// TestKeychainSaveCreatesWhenAbsent asserts Save mints a new item only when the
144+
// identity has no existing item, and performs no in-place update or cleanup.
145+
func TestKeychainSaveCreatesWhenAbsent(t *testing.T) {
146+
fake := &fakeService{} // no items -> create path
147+
withFakeService(t, fake)
148+
149+
ks := setupKeychain(t, nil)
150+
id := store.MustParseID("com.test.test/test/new-user")
151+
creds := &mocks.MockCredential{Username: "alice", Password: "alice-password"}
152+
153+
require.NoError(t, ks.Save(t.Context(), id, creds))
154+
155+
assert.Equal(t, 1, fake.createCalls, "must CreateItem when no existing item")
156+
assert.Empty(t, fake.setSecretItems, "no in-place update when creating")
157+
assert.Empty(t, fake.deletedItems, "nothing to collapse")
158+
}
159+
160+
// TestKeychainSaveCollapsesDuplicatesInPlace is the issue #446 regression test:
161+
// when several items already share one stable identity (the accumulated
162+
// duplicates), Save must update the first match in place — never minting a new
163+
// item — and drain the remaining duplicates, leaving exactly one.
164+
func TestKeychainSaveCollapsesDuplicatesInPlace(t *testing.T) {
165+
fake := &fakeService{
166+
items: []dbus.ObjectPath{"/item/a", "/item/b", "/item/c"},
167+
}
168+
withFakeService(t, fake)
169+
170+
ks := setupKeychain(t, nil)
171+
id := store.MustParseID("com.test.test/test/bob")
172+
creds := &mocks.MockCredential{Username: "bob", Password: "bob-password"}
173+
174+
require.NoError(t, ks.Save(t.Context(), id, creds))
175+
176+
assert.Zero(t, fake.createCalls, "must not CreateItem when an item already exists")
177+
assert.Equal(t, []dbus.ObjectPath{"/item/a"}, fake.setSecretItems,
178+
"secret must be rewritten on the first match in place")
179+
assert.ElementsMatch(t, []dbus.ObjectPath{"/item/b", "/item/c"}, fake.deletedItems,
180+
"the remaining duplicates must be collapsed, leaving only the first match")
181+
}
182+
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+
207+
// searchRealItems queries a live Secret Service for every item sharing id's
208+
// stable identity triple {service:group, service:name, id}. The store API keys
209+
// results by ID and so would collapse duplicates into one logical entry —
210+
// counting the physical items requires going under it, straight to the daemon.
211+
//
212+
// It opens its own short-lived connection (mirroring how the store dials a fresh
213+
// connection per operation) so it observes exactly what an independent client
214+
// would see. Object paths it returns stay valid after the connection closes.
215+
//
216+
// It returns its error rather than failing the test so it is safe to call from a
217+
// [require.Eventually] condition, which runs on a separate goroutine where the
218+
// require.* helpers must not be used.
219+
func searchRealItems(serviceGroup, serviceName string, id store.ID) ([]dbus.ObjectPath, error) {
220+
svc, err := kc.NewService()
221+
if err != nil {
222+
return nil, err
223+
}
224+
defer func() { _ = svc.Close() }()
225+
226+
collection, err := getDefaultCollection(svc)
227+
if err != nil {
228+
return nil, err
229+
}
230+
231+
attrs := map[string]string{}
232+
safelySetMetadata(serviceGroup, serviceName, attrs)
233+
safelySetID(id, attrs)
234+
235+
return svc.SearchCollection(collection, attrs)
236+
}
237+
238+
// findRealItems is the test-goroutine wrapper around [searchRealItems] that fails
239+
// the test on error.
240+
func findRealItems(t *testing.T, serviceGroup, serviceName string, id store.ID) []dbus.ObjectPath {
241+
t.Helper()
242+
items, err := searchRealItems(serviceGroup, serviceName, id)
243+
require.NoError(t, err)
244+
return items
245+
}
246+
247+
// requireItemCount polls the live Secret Service until exactly want items remain
248+
// for id, failing after a short timeout. Polling absorbs any lag between the
249+
// store deleting duplicates and an independent connection observing it; on
250+
// timeout EventuallyWithT reports the last observed count (and any search
251+
// error), so a genuine failure to converge is still caught and diagnosable.
252+
func requireItemCount(t *testing.T, serviceGroup, serviceName string, id store.ID, want int, msg string) {
253+
t.Helper()
254+
require.EventuallyWithT(t, func(c *assert.CollectT) {
255+
items, err := searchRealItems(serviceGroup, serviceName, id)
256+
assert.NoError(c, err)
257+
assert.Len(c, items, want, msg)
258+
}, 10*time.Second, 200*time.Millisecond)
259+
}
260+
261+
// seedRealDuplicates creates n separate Secret Service items that all share id's
262+
// stable identity triple but carry a distinct volatile attribute each — exactly
263+
// how issue #446 accumulates duplicates: the daemon's replace match fails on the
264+
// differing volatile attributes, so every save mints a fresh item.
265+
func seedRealDuplicates(t *testing.T, serviceGroup, serviceName string, id store.ID, n int) {
266+
t.Helper()
267+
svc, err := kc.NewService()
268+
require.NoError(t, err)
269+
defer func() { _ = svc.Close() }()
270+
271+
session, err := svc.OpenSession(kc.AuthenticationDHAES)
272+
require.NoError(t, err)
273+
defer svc.CloseSession(session)
274+
275+
collection, err := getDefaultCollection(svc)
276+
require.NoError(t, err)
277+
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)
282+
283+
label := serviceGroup + ":" + serviceName + ":" + id.String()
284+
for i := range n {
285+
sessSecret, err := session.NewSecret(fmt.Appendf(nil, "seed-user:seed-pass-%d", i))
286+
require.NoError(t, err)
287+
288+
attrs := map[string]string{
289+
// volatile: distinct per item, so each CreateItem adds a new one
290+
// rather than replacing — the duplicate-accumulation pattern.
291+
"nonce": fmt.Sprintf("seed-%d", i),
292+
}
293+
safelySetMetadata(serviceGroup, serviceName, attrs)
294+
safelySetID(id, attrs)
295+
296+
_, err = svc.CreateItem(collection, kc.NewSecretProperties(label, attrs), sessSecret, kc.ReplaceBehaviorDoNotReplace)
297+
require.NoError(t, err)
298+
}
299+
}
300+
301+
// purgeRealItems removes every item for id, draining any leftover duplicates so
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.
305+
func purgeRealItems(t *testing.T, serviceGroup, serviceName string, id store.ID) {
306+
t.Helper()
307+
svc, err := kc.NewService()
308+
require.NoError(t, err)
309+
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)
320+
for _, item := range items {
321+
require.NoError(t, svc.DeleteItem(item))
322+
}
323+
}
324+
325+
// TestKeychainCollapsesExistingDuplicates is the issue #446 backlog test against
326+
// a real Secret Service: given several duplicate items already stored under one
327+
// identity, a single Save must update one item in place and delete the rest,
328+
// leaving exactly one item holding the latest secret.
329+
func TestKeychainCollapsesExistingDuplicates(t *testing.T) {
330+
id := store.MustParseID(dedupServiceGroup + "/" + dedupServiceName + "/backlog")
331+
t.Cleanup(func() { purgeRealItems(t, dedupServiceGroup, dedupServiceName, id) })
332+
333+
// Pre-existing backlog: three duplicate items for one identity.
334+
seedRealDuplicates(t, dedupServiceGroup, dedupServiceName, id, 3)
335+
require.Len(t, findRealItems(t, dedupServiceGroup, dedupServiceName, id), 3, "precondition: three duplicates seeded")
336+
337+
ks, err := New(dedupServiceGroup, dedupServiceName, func(_ context.Context, _ store.ID) store.Secret {
338+
return &mocks.MockCredential{}
339+
})
340+
require.NoError(t, err)
341+
342+
require.NoError(t, ks.Save(t.Context(), id, &mocks.MockCredential{
343+
Username: "backlog-user",
344+
Password: "final-password",
345+
}))
346+
347+
requireItemCount(t, dedupServiceGroup, dedupServiceName, id, 1,
348+
"a single Save must collapse the duplicate backlog to one item")
349+
350+
got, err := ks.Get(t.Context(), id)
351+
require.NoError(t, err)
352+
assert.Equal(t, "final-password", got.(*mocks.MockCredential).Password,
353+
"the surviving item must hold the latest secret")
354+
}
355+
356+
// TestKeychainSaveDoesNotAccumulate is the forward-looking issue #446 test
357+
// against a real Secret Service: saving the same identity repeatedly with
358+
// metadata that changes on every save (mimicking volatile JWT claims) must keep
359+
// exactly one item, never minting duplicates.
360+
func TestKeychainSaveDoesNotAccumulate(t *testing.T) {
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 {
365+
return &mocks.MockCredential{}
366+
})
367+
require.NoError(t, err)
368+
369+
const saves = 5
370+
for i := range saves {
371+
require.NoError(t, ks.Save(t.Context(), id, &mocks.MockCredential{
372+
Username: "no-accumulate-user",
373+
Password: fmt.Sprintf("password-%d", i),
374+
Attributes: map[string]string{
375+
"nonce": fmt.Sprintf("%d", i), // volatile: differs every save
376+
},
377+
}))
378+
}
379+
380+
requireItemCount(t, dedupServiceGroup, dedupServiceName, id, 1,
381+
"saving with changing metadata must not accumulate duplicate items")
382+
383+
got, err := ks.Get(t.Context(), id)
384+
require.NoError(t, err)
385+
actual := got.(*mocks.MockCredential)
386+
assert.Equal(t, fmt.Sprintf("password-%d", saves-1), actual.Password)
387+
assert.Equal(t, fmt.Sprintf("%d", saves-1), actual.Attributes["nonce"],
388+
"the surviving item's metadata must be refreshed in place")
389+
}
390+
119391
func TestResolveDefaultCollection(t *testing.T) {
120392
const customCollection = dbus.ObjectPath("/org/freedesktop/secrets/collection/custom")
121393

0 commit comments

Comments
 (0)