@@ -3477,144 +3477,144 @@ func TestWritePreservesMissingMiddleChunkSlot(t *testing.T) {
34773477 }
34783478}
34793479
3480- func TestKeyringDeleteRemovesCredentialFromLegacyBlob (t * testing.T ) {
3480+ // TestStoreKeyringDurableLogoutHidesSurvivingIndexedCredential is the regression for
3481+ // [P1] Make a durable logout hide a surviving indexed credential:
3482+ // when a tombstone exists for a key, read()/Load()/Status() must treat it as deleted
3483+ // even if an interrupted delete left the per-key entry and key index intact.
3484+ func TestStoreKeyringDurableLogoutHidesSurvivingIndexedCredential (t * testing.T ) {
34813485 kr := newFakeKR ()
3482- legacyTokens := map [string ]Token {
3483- ProviderKey ("p1" ): {AccessToken : "at1" , RefreshToken : "rt1" },
3484- ProviderKey ("p2" ): {AccessToken : "at2" , RefreshToken : "rt2" },
3485- }
3486- legacyRaw , _ := json .Marshal (storeFile {SchemaVersion : 1 , Tokens : legacyTokens })
3487- kr .data [keyringService + "/" + keyringLegacyAccount ] = base64 .StdEncoding .EncodeToString (legacyRaw )
3488-
34893486 s , err := NewStore (StoreOptions {Storage : "keyring" , Keyring : kr })
34903487 if err != nil {
34913488 t .Fatalf ("NewStore: %v" , err )
34923489 }
34933490
3494- // Loading p1 works via legacy migration.
3495- tok1 , ok , err := s .Load (ProviderKey ("p1" ))
3496- if err != nil || ! ok || tok1 .AccessToken != "at1" {
3497- t .Fatalf ("Load(p1) = %+v, ok=%v, err=%v" , tok1 , ok , err )
3491+ if err := s .Save (ProviderKey ("alpha" ), Token {AccessToken : "alpha-token" }); err != nil {
3492+ t .Fatalf ("Save(alpha): %v" , err )
34983493 }
34993494
3500- // Delete p1 (logout) .
3501- removed , err := s .Delete (ProviderKey ("p1 " ))
3502- if err != nil || ! removed {
3503- t .Fatalf ("Delete(p1) = %v, err=%v" , removed , err )
3495+ // Verify alpha is loaded .
3496+ tok , ok , err := s .Load (ProviderKey ("alpha " ))
3497+ if err != nil || ! ok || tok . AccessToken != "alpha-token" {
3498+ t .Fatalf ("Load(alpha) before interruption: tok=%v, ok= %v, err=%v" , tok , ok , err )
35043499 }
35053500
3506- // Inspect legacy entry directly: p1 must be deleted from legacy entry.
3507- blob := keyringBlob {kr : kr , service : keyringService , indexAccount : keyringIndexAccount , legacyAccount : keyringLegacyAccount }
3508- remainingLegacy , err := blob .readLegacyTokens ()
3501+ // Simulate an interrupted Delete: publish tombstone for alpha, but leave alpha's
3502+ // per-key entry in keyring.
3503+ blob := keyringBlob {kr : kr , service : keyringService , indexAccount : keyringIndexAccount }
3504+ if err := blob .writeTombstones (map [string ]bool {ProviderKey ("alpha" ): true }, noLeaseLoss ); err != nil {
3505+ t .Fatalf ("writeTombstones: %v" , err )
3506+ }
3507+
3508+ // A fresh store reader must hide alpha immediately, despite the per-key entry surviving.
3509+ sFresh , err := NewStore (StoreOptions {Storage : "keyring" , Keyring : kr })
35093510 if err != nil {
3510- t .Fatalf ("readLegacyTokens : %v" , err )
3511+ t .Fatalf ("NewStore fresh : %v" , err )
35113512 }
3512- if remainingLegacy != nil {
3513- if _ , exists := remainingLegacy [ProviderKey ("p1" )]; exists {
3514- t .Fatal ("p1 must not exist in legacy tokens blob after logout" )
3515- }
3516- if _ , exists := remainingLegacy [ProviderKey ("p2" )]; ! exists {
3517- t .Fatal ("p2 must still be present in legacy tokens blob" )
3518- }
3513+
3514+ if tok , ok , err := sFresh .Load (ProviderKey ("alpha" )); err != nil || ok {
3515+ t .Fatalf ("Load(alpha) after tombstone published: ok=%v (tok=%+v), err=%v; want ok=false" , ok , tok , err )
35193516 }
35203517
3521- // Delete p2 (all tokens removed): legacy account should be deleted entirely.
3522- removed2 , err := s .Delete (ProviderKey ("p2" ))
3523- if err != nil || ! removed2 {
3524- t .Fatalf ("Delete(p2) = %v, err=%v" , removed2 , err )
3518+ status , err := sFresh .Status ("" )
3519+ if err != nil {
3520+ t .Fatalf ("Status: %v" , err )
35253521 }
3526- if _ , ok := kr .data [keyringService + "/" + keyringLegacyAccount ]; ok {
3527- t .Fatal ("legacy account should be deleted when all legacy tokens are removed" )
3522+ for _ , entry := range status {
3523+ if entry .Key == ProviderKey ("alpha" ) {
3524+ t .Fatalf ("Status returned alpha after tombstone published: %+v" , entry )
3525+ }
35283526 }
35293527}
35303528
3531- // TestStoreKeyringDowngradedLegacyReaderCannotAuthenticateAfterLogout is the
3532- // regression for [P1] Logout doesn't revoke legacy representation: after migration
3533- // to per-key entries, Delete removes the per-key entry and records a tombstone,
3534- // but must also remove that provider from the legacy oauth-tokens blob so a
3535- // downgraded / pre-PR binary reading the legacy account directly cannot
3536- // authenticate using the logged-out credentials.
3537- func TestStoreKeyringDowngradedLegacyReaderCannotAuthenticateAfterLogout (t * testing.T ) {
3538- t .Setenv ("XDG_CONFIG_HOME" , t .TempDir ())
3529+ // TestStoreKeyringReloginRetiresLegacyOriginPrecedence is the regression for
3530+ // [P1] Retire legacy-origin precedence when an explicit re-login succeeds:
3531+ // after legacy migration and old-binary logout of alpha, an explicit new-binary
3532+ // Save for alpha must retire the legacyOrigin marker so future reads and unrelated
3533+ // Saves do not re-delete alpha via the absence heuristic.
3534+ func TestStoreKeyringReloginRetiresLegacyOriginPrecedence (t * testing.T ) {
35393535 kr := newFakeKR ()
35403536
3541- // 1. Older binary logged into github and anthropic via the legacy single-blob entry .
3537+ // 1. Initial state: legacy blob has alpha and beta .
35423538 legacyTokens := map [string ]Token {
3543- ProviderKey ("github" ): {AccessToken : "gh-secret-token" , RefreshToken : "gh-refresh" },
3544- ProviderKey ("anthropic" ): {AccessToken : "ant-secret-token" , RefreshToken : "ant-refresh" },
3545- }
3546- legacyRaw , err := json .Marshal (storeFile {SchemaVersion : storeSchemaVersion , Tokens : legacyTokens })
3547- if err != nil {
3548- t .Fatal (err )
3539+ ProviderKey ("alpha" ): {AccessToken : "old-alpha-token" },
3540+ ProviderKey ("beta" ): {AccessToken : "beta-token" },
35493541 }
3542+ legacyRaw , _ := json .Marshal (storeFile {SchemaVersion : storeSchemaVersion , Tokens : legacyTokens })
35503543 kr .data [keyringService + "/" + keyringLegacyAccount ] = base64 .StdEncoding .EncodeToString (legacyRaw )
35513544
3552- // A legacy reader helper simulating a pre-PR binary that only reads the legacy account.
3553- legacyReaderAuthenticate := func (provider string ) (Token , bool ) {
3554- enc , ok := kr .data [keyringService + "/" + keyringLegacyAccount ]
3555- if ! ok {
3556- return Token {}, false
3557- }
3558- raw , err := base64 .StdEncoding .DecodeString (strings .TrimSpace (enc ))
3559- if err != nil {
3560- return Token {}, false
3561- }
3562- var sf storeFile
3563- if err := json .Unmarshal (raw , & sf ); err != nil {
3564- return Token {}, false
3565- }
3566- tok , has := sf .Tokens [ProviderKey (provider )]
3567- return tok , has
3545+ s1 , err := NewStore (StoreOptions {Storage : "keyring" , Keyring : kr })
3546+ if err != nil {
3547+ t .Fatalf ("NewStore s1: %v" , err )
35683548 }
35693549
3570- // Legacy reader sees both credentials initially .
3571- if tok , ok := legacyReaderAuthenticate ( "github" ); ! ok || tok . AccessToken != "gh-secret-token" {
3572- t .Fatalf ("legacy reader before logout: github token = %v, ok = %v" , tok , ok )
3550+ // Migration happens on save of any token or explicit read .
3551+ if _ , ok , err := s1 . Load ( ProviderKey ( "alpha" )); err != nil || ! ok {
3552+ t .Fatalf ("initial Load(alpha): ok= %v, err= %v" , ok , err )
35733553 }
3574- if tok , ok := legacyReaderAuthenticate ("anthropic" ); ! ok || tok .AccessToken != "ant-secret-token" {
3575- t .Fatalf ("legacy reader before logout: anthropic token = %v, ok = %v" , tok , ok )
3554+
3555+ // 2. Old binary logs out alpha by writing {beta} to the legacy blob.
3556+ legacyTokensAfterLogout := map [string ]Token {
3557+ ProviderKey ("beta" ): {AccessToken : "beta-token" },
35763558 }
3559+ legacyRaw2 , _ := json .Marshal (storeFile {SchemaVersion : storeSchemaVersion , Tokens : legacyTokensAfterLogout })
3560+ kr .data [keyringService + "/" + keyringLegacyAccount ] = base64 .StdEncoding .EncodeToString (legacyRaw2 )
35773561
3578- // 2. Upgraded binary initializes the store, migrating credentials to per-key entries.
3579- s , err := NewStore (StoreOptions {Storage : "keyring" , Keyring : kr })
3580- if err != nil {
3581- t .Fatal (err )
3562+ // 3. New binary explicitly logs into alpha with a new token.
3563+ if err := s1 .Save (ProviderKey ("alpha" ), Token {AccessToken : "new-alpha-token" }); err != nil {
3564+ t .Fatalf ("Save(alpha): %v" , err )
35823565 }
35833566
3584- // User logs out of github in the upgraded binary .
3585- removed , err := s . Delete (ProviderKey ("github " ))
3586- if err != nil || ! removed {
3587- t .Fatalf ("Delete(github): removed =%v err=%v" , removed , err )
3567+ // 4. Verify alpha is loaded immediately .
3568+ tok , ok , err := s1 . Load (ProviderKey ("alpha " ))
3569+ if err != nil || ! ok || tok . AccessToken != "new-alpha-token" {
3570+ t .Fatalf ("Load(alpha) after re-login: tok =%v, ok=%v, err=%v" , tok , ok , err )
35883571 }
35893572
3590- // Upgraded binary confirms github is gone.
3591- if _ , ok , err := s .Load (ProviderKey ("github" )); err != nil || ok {
3592- t .Fatalf ("upgraded Load(github): ok=%v err=%v, want false" , ok , err )
3573+ // 5. Subsequent read from a fresh store must preserve alpha.
3574+ sFresh , err := NewStore (StoreOptions {Storage : "keyring" , Keyring : kr })
3575+ if err != nil {
3576+ t .Fatalf ("NewStore sFresh: %v" , err )
3577+ }
3578+ tok2 , ok2 , err2 := sFresh .Load (ProviderKey ("alpha" ))
3579+ if err2 != nil || ! ok2 || tok2 .AccessToken != "new-alpha-token" {
3580+ t .Fatalf ("Load(alpha) from fresh store: tok=%v, ok=%v, err=%v" , tok2 , ok2 , err2 )
35933581 }
35943582
3595- // 3. Mixed-version / downgraded reader check:
3596- // A downgraded binary reading the legacy account must NOT find github tokens after logout.
3597- if tok , ok := legacyReaderAuthenticate ("github" ); ok {
3598- t .Fatalf ("downgraded binary was able to authenticate with github token %v after logout!" , tok )
3583+ // 6. An unrelated Save of gamma must NOT re-trigger absence deletion of alpha.
3584+ if err := sFresh .Save (ProviderKey ("gamma" ), Token {AccessToken : "gamma-token" }); err != nil {
3585+ t .Fatalf ("Save(gamma): %v" , err )
35993586 }
36003587
3601- // The downgraded binary can still read the non-logged-out anthropic token.
3602- if tok , ok := legacyReaderAuthenticate ( "anthropic" ); ! ok || tok .AccessToken != "ant-secret -token" {
3603- t .Fatalf ("downgraded binary anthropic token = %v, ok = %v, want anthropic preserved " , tok , ok )
3588+ tok3 , ok3 , err3 := sFresh . Load ( ProviderKey ( "alpha" ))
3589+ if err3 != nil || ! ok3 || tok3 .AccessToken != "new-alpha -token" {
3590+ t .Fatalf ("Load(alpha) after unrelated Save(gamma): tok= %v, ok= %v, err=%v " , tok3 , ok3 , err3 )
36043591 }
3592+ }
36053593
3606- // Now logout of anthropic as well.
3607- removed , err = s .Delete (ProviderKey ("anthropic" ))
3608- if err != nil || ! removed {
3609- t .Fatalf ("Delete(anthropic): removed=%v err=%v" , removed , err )
3594+ // TestStoreKeyringKeyIndexGenerationConflictRejection is the regression for
3595+ // [P1] Do not treat a file-token read as a fencing guarantee:
3596+ // writeKeyIndex must detect when a concurrent process advanced the generation
3597+ // in the keyring and reject stale header publication.
3598+ func TestStoreKeyringKeyIndexGenerationConflictRejection (t * testing.T ) {
3599+ kr := newFakeKR ()
3600+ blob := keyringBlob {kr : kr , service : keyringService , indexAccount : keyringIndexAccount }
3601+
3602+ // 1. Initial generation 1 written.
3603+ _ , gen1 , err := blob .writeKeyIndex ([]string {ProviderKey ("p1" )}, 0 , 0 , nil , noLeaseLoss )
3604+ if err != nil || gen1 != 1 {
3605+ t .Fatalf ("initial writeKeyIndex: gen=%d, err=%v" , gen1 , err )
36103606 }
36113607
3612- // Downgraded binary sees no tokens at all now.
3613- if tok , ok := legacyReaderAuthenticate ("anthropic" ); ok {
3614- t .Fatalf ("downgraded binary was able to authenticate with anthropic token %v after all providers logged out!" , tok )
3608+ // 2. Another process advances generation to 2.
3609+ _ , gen2 , err := blob .writeKeyIndex ([]string {ProviderKey ("p1" ), ProviderKey ("p2" )}, 1 , 1 , nil , noLeaseLoss )
3610+ if err != nil || gen2 != 2 {
3611+ t .Fatalf ("second writeKeyIndex: gen=%d, err=%v" , gen2 , err )
36153612 }
3616- if _ , ok := kr .data [keyringService + "/" + keyringLegacyAccount ]; ok {
3617- t .Fatal ("legacy account still exists in keyring after all providers logged out" )
3613+
3614+ // 3. Stale writer trying to write with priorGeneration=1 must fail due to generation conflict.
3615+ _ , _ , err = blob .writeKeyIndex ([]string {ProviderKey ("p1" ), ProviderKey ("p3" )}, 1 , 1 , nil , noLeaseLoss )
3616+ if err == nil {
3617+ t .Fatal ("stale writeKeyIndex with priorGeneration=1 should fail due to generation conflict" )
36183618 }
36193619}
36203620
0 commit comments