@@ -48,13 +48,34 @@ type fakeService struct {
4848 // concurrency-safe: the tests that read them drive a single sequential
4949 // operation through the fake.
5050 createCalls int
51+ setSecretCalls int
52+ deleteCalls int
5153 setSecretItems []dbus.ObjectPath
5254 deletedItems []dbus.ObjectPath
5355
56+ // {createItem,setSecret,deleteItem}LockedErrs is how many leading calls of
57+ // each kind fail with the secret service "collection is locked" D-Bus error
58+ // before one succeeds, simulating a collection that relocks underneath the
59+ // store (see withRelockRetry). The error is wrapped exactly as the real
60+ // service wraps it, so the tests exercise the errors.As-through-wrap path
61+ // isLockedDBusError depends on. unlockCalls counts the re-unlocks the retry
62+ // issues; unlockErr, when set, makes Unlock fail (e.g. a dismissed prompt).
63+ createItemLockedErrs int
64+ setSecretLockedErrs int
65+ deleteItemLockedErrs int
66+ unlockCalls int
67+ unlockErr error
68+
5469 opened atomic.Int64
5570 closed atomic.Int64
5671}
5772
73+ // lockedErr mirrors how the real SecretService wraps a locked-collection D-Bus
74+ // error (see secretservice.go), so isLockedDBusError must unwrap to detect it.
75+ func lockedErr (op string ) error {
76+ return fmt .Errorf ("failed to %s: %w" , op , dbus.Error {Name : secretServiceIsLockedError })
77+ }
78+
5879func (f * fakeService ) Collections () ([]dbus.ObjectPath , error ) {
5980 return []dbus.ObjectPath {loginKeychainObjectPath }, nil
6081}
@@ -65,24 +86,39 @@ func (f *fakeService) OpenSession(kc.AuthenticationMode) (*kc.Session, error) {
6586 // lets the Save path run end-to-end against the fake.
6687 return & kc.Session {Mode : kc .AuthenticationInsecurePlain }, nil
6788}
68- func (f * fakeService ) CloseSession (* kc.Session ) {}
69- func (f * fakeService ) Unlock ([]dbus.ObjectPath ) error { return nil }
89+ func (f * fakeService ) CloseSession (* kc.Session ) {}
90+ func (f * fakeService ) Unlock ([]dbus.ObjectPath ) error {
91+ f .unlockCalls ++
92+ return f .unlockErr
93+ }
94+
7095func (f * fakeService ) SearchCollection (dbus.ObjectPath , kc.Attributes ) ([]dbus.ObjectPath , error ) {
7196 return f .items , nil
7297}
7398
7499func (f * fakeService ) CreateItem (dbus.ObjectPath , map [string ]dbus.Variant , kc.Secret , kc.ReplaceBehavior ) (dbus.ObjectPath , error ) {
75100 f .createCalls ++
101+ if f .createCalls <= f .createItemLockedErrs {
102+ return "" , lockedErr ("create item" )
103+ }
76104 return "/created" , nil
77105}
78106
79107func (f * fakeService ) DeleteItem (item dbus.ObjectPath ) error {
108+ f .deleteCalls ++
109+ if f .deleteCalls <= f .deleteItemLockedErrs {
110+ return lockedErr ("delete item" )
111+ }
80112 f .deletedItems = append (f .deletedItems , item )
81113 return nil
82114}
83115func (f * fakeService ) GetAttributes (dbus.ObjectPath ) (kc.Attributes , error ) { return nil , nil }
84116func (f * fakeService ) GetSecret (dbus.ObjectPath , kc.Session ) ([]byte , error ) { return nil , nil }
85117func (f * fakeService ) SetItemSecret (item dbus.ObjectPath , _ kc.Secret ) error {
118+ f .setSecretCalls ++
119+ if f .setSecretCalls <= f .setSecretLockedErrs {
120+ return lockedErr ("set item secret" )
121+ }
86122 f .setSecretItems = append (f .setSecretItems , item )
87123 return nil
88124}
@@ -105,6 +141,16 @@ func withFakeService(t *testing.T, fake *fakeService) {
105141 }
106142}
107143
144+ // stubRelockSleep replaces the relock backoff sleep with a no-op so retry tests
145+ // run without real delays, restoring it on cleanup. It mutates the package-level
146+ // sleepFn var, so tests using it must not run in parallel.
147+ func stubRelockSleep (t * testing.T ) {
148+ t .Helper ()
149+ orig := sleepFn
150+ t .Cleanup (func () { sleepFn = orig })
151+ sleepFn = func (time.Duration ) {}
152+ }
153+
108154// TestKeychainGetNotFound exercises the full Get path against the fake — open,
109155// resolve collection, search — and asserts an empty search maps to
110156// ErrCredentialNotFound, all without a live keyring.
@@ -180,6 +226,79 @@ func TestKeychainSaveCollapsesDuplicatesInPlace(t *testing.T) {
180226 "the remaining duplicates must be collapsed, leaving only the first match" )
181227}
182228
229+ // TestKeychainSaveRetriesWhenCreateRelocks covers the create path: gnome-keyring
230+ // can relock the collection between the unlock and the CreateItem, so a fresh
231+ // Save must react to the locked error by unlocking and retrying rather than
232+ // failing.
233+ func TestKeychainSaveRetriesWhenCreateRelocks (t * testing.T ) {
234+ stubRelockSleep (t )
235+ fake := & fakeService {} // no items -> create path
236+ fake .createItemLockedErrs = 2
237+ withFakeService (t , fake )
238+
239+ ks := setupKeychain (t , nil )
240+ require .NoError (t , ks .Save (t .Context (), store .MustParseID ("com.test.test/test/bob" ),
241+ & mocks.MockCredential {Username : "bob" , Password : "bob-password" }))
242+
243+ assert .Equal (t , 3 , fake .createCalls , "two locked failures then one success" )
244+ assert .Equal (t , 2 , fake .unlockCalls , "exactly one Unlock per relock retry" )
245+ }
246+
247+ // TestKeychainSaveRetriesWhenSetSecretRelocks covers the in-place update path:
248+ // the SetItemSecret that rewrites the surviving item must survive a relock.
249+ func TestKeychainSaveRetriesWhenSetSecretRelocks (t * testing.T ) {
250+ stubRelockSleep (t )
251+ fake := & fakeService {items : []dbus.ObjectPath {"/item/a" }}
252+ fake .setSecretLockedErrs = 2
253+ withFakeService (t , fake )
254+
255+ ks := setupKeychain (t , nil )
256+ require .NoError (t , ks .Save (t .Context (), store .MustParseID ("com.test.test/test/bob" ),
257+ & mocks.MockCredential {Username : "bob" , Password : "bob-password" }))
258+
259+ assert .Equal (t , []dbus.ObjectPath {"/item/a" }, fake .setSecretItems ,
260+ "the secret must be written in place once the relock clears" )
261+ assert .Equal (t , 2 , fake .unlockCalls , "exactly one Unlock per relock retry" )
262+ }
263+
264+ // TestKeychainSaveCollapseRetriesWhenDeleteRelocks is the unit-level counterpart
265+ // of the real-keyring backlog test: collapsing a duplicate must drain it even if
266+ // the collection relocks mid-delete. The collapse delete is best-effort, but a
267+ // silently swallowed locked error would leave the duplicate behind — the exact
268+ // #446 symptom — so it is still relock-aware.
269+ func TestKeychainSaveCollapseRetriesWhenDeleteRelocks (t * testing.T ) {
270+ stubRelockSleep (t )
271+ fake := & fakeService {items : []dbus.ObjectPath {"/item/a" , "/item/b" }}
272+ fake .deleteItemLockedErrs = 2
273+ withFakeService (t , fake )
274+
275+ ks := setupKeychain (t , nil )
276+ require .NoError (t , ks .Save (t .Context (), store .MustParseID ("com.test.test/test/bob" ),
277+ & mocks.MockCredential {Username : "bob" , Password : "bob-password" }))
278+
279+ assert .Equal (t , []dbus.ObjectPath {"/item/b" }, fake .deletedItems ,
280+ "the duplicate must be collapsed once the relock clears" )
281+ assert .Equal (t , 3 , fake .deleteCalls , "two locked failures then one success" )
282+ assert .Equal (t , 2 , fake .unlockCalls , "exactly one Unlock per relock retry" )
283+ }
284+
285+ // TestKeychainSaveStopsRetryingAfterMaxRelocks asserts the retry is bounded: a
286+ // persistently locked collection surfaces the locked error to the caller instead
287+ // of looping forever.
288+ func TestKeychainSaveStopsRetryingAfterMaxRelocks (t * testing.T ) {
289+ stubRelockSleep (t )
290+ fake := & fakeService {} // no items -> create path
291+ fake .createItemLockedErrs = 1 << 30 // never recovers
292+ withFakeService (t , fake )
293+
294+ ks := setupKeychain (t , nil )
295+ err := ks .Save (t .Context (), store .MustParseID ("com.test.test/test/bob" ),
296+ & mocks.MockCredential {Username : "bob" , Password : "bob-password" })
297+ require .Error (t , err )
298+ assert .True (t , isLockedDBusError (err ), "the persistent locked error must reach the caller" )
299+ assert .Equal (t , maxRelockRetries + 1 , fake .createCalls , "initial attempt plus the bounded retries" )
300+ }
301+
183302// The real-keychain dedup tests use their own service group/name so their items
184303// are namespace-isolated from TestKeychain (which shares com.test.test/test).
185304// GetAllMetadata/Filter search by {service:group, service:name}, so a leaked
@@ -293,7 +412,14 @@ func seedRealDuplicates(t *testing.T, serviceGroup, serviceName string, id store
293412 safelySetMetadata (serviceGroup , serviceName , attrs )
294413 safelySetID (id , attrs )
295414
296- _ , err = svc .CreateItem (collection , kc .NewSecretProperties (label , attrs ), sessSecret , kc .ReplaceBehaviorDoNotReplace )
415+ // Seed directly against the daemon, but stay relock-aware: a prior op's
416+ // closing connection can relock the collection between the unlock above
417+ // and this create (see withRelockRetry), which would otherwise fail the
418+ // seed with "Cannot create an item in a locked collection".
419+ err = withRelockRetry (svc , collection , func () error {
420+ _ , createErr := svc .CreateItem (collection , kc .NewSecretProperties (label , attrs ), sessSecret , kc .ReplaceBehaviorDoNotReplace )
421+ return createErr
422+ })
297423 require .NoError (t , err )
298424 }
299425}
@@ -318,7 +444,9 @@ func purgeRealItems(t *testing.T, serviceGroup, serviceName string, id store.ID)
318444 items , err := svc .SearchCollection (collection , attrs )
319445 require .NoError (t , err )
320446 for _ , item := range items {
321- require .NoError (t , svc .DeleteItem (item ))
447+ require .NoError (t , withRelockRetry (svc , collection , func () error {
448+ return svc .DeleteItem (item )
449+ }))
322450 }
323451}
324452
0 commit comments