diff --git a/backend/internal/application/account/credential_refresh_test.go b/backend/internal/application/account/credential_refresh_test.go index 53a41da94..87c1384bc 100644 --- a/backend/internal/application/account/credential_refresh_test.go +++ b/backend/internal/application/account/credential_refresh_test.go @@ -406,6 +406,57 @@ func TestCredentialRefreshFailureDistinguishesTransientAndPermanent(t *testing.T } } +func TestRefreshTokenRetriesRecoveredCredentialDecryptFailure(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + + if err := service.accounts.UpdateCredentialRefreshFailure(ctx, credential.ID, 1, now, "credential_decrypt_failed", true); err != nil { + t.Fatal(err) + } + if err := service.MarkReauthRequired(ctx, credential.ID, "OAuth refresh failed: credential_decrypt_failed"); err != nil { + t.Fatal(err) + } + + if _, err := service.ensureCredential(ctx, credential, true, true, false); err != nil { + t.Fatal(err) + } + updated, err := service.accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if adapter.refreshCount.Load() != 1 || updated.AuthStatus != accountdomain.AuthStatusActive || updated.RefreshPermanent || updated.LastRefreshErrorCode != "" { + t.Fatalf("recovered credential state = %#v, refreshes = %d", updated, adapter.refreshCount.Load()) + } +} + +func TestRefreshAllTokensRetriesRecoveredCredentialDecryptFailures(t *testing.T) { + ctx := context.Background() + now := time.Date(2026, 7, 18, 12, 0, 0, 0, time.UTC) + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + + if err := service.accounts.UpdateCredentialRefreshFailure(ctx, credential.ID, 1, now, "credential_decrypt_failed", true); err != nil { + t.Fatal(err) + } + if err := service.MarkReauthRequired(ctx, credential.ID, "OAuth refresh failed: credential_decrypt_failed"); err != nil { + t.Fatal(err) + } + + succeeded, failed, skipped, err := service.RefreshAllTokensWithProgress(ctx, nil) + if err != nil { + t.Fatal(err) + } + updated, err := service.accounts.Get(ctx, credential.ID) + if err != nil { + t.Fatal(err) + } + if succeeded != 1 || failed != 0 || skipped != 0 || adapter.refreshCount.Load() != 1 || updated.AuthStatus != accountdomain.AuthStatusActive || updated.RefreshPermanent { + t.Fatalf("result=%d/%d/%d credential=%#v refreshes=%d", succeeded, failed, skipped, updated, adapter.refreshCount.Load()) + } +} + func TestRefreshAllTokensSkipsUnrefreshableAccounts(t *testing.T) { ctx := context.Background() now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) diff --git a/backend/internal/application/account/service.go b/backend/internal/application/account/service.go index c22b8f86e..29f6aa053 100644 --- a/backend/internal/application/account/service.go +++ b/backend/internal/application/account/service.go @@ -1676,6 +1676,9 @@ func (s *Service) resolvePermanentRefreshFailure(ctx context.Context, credential if !credential.RefreshPermanent { return accountdomain.Credential{}, nil, false } + if force && credential.LastRefreshErrorCode == "credential_decrypt_failed" { + return accountdomain.Credential{}, nil, false + } accessTokenAlive := credential.EncryptedAccessToken != "" && !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(now) if accessTokenAlive && !force { return credential, nil, true diff --git a/backend/internal/infra/persistence/relational/account_repository.go b/backend/internal/infra/persistence/relational/account_repository.go index 7cff7c1d8..859d0e986 100644 --- a/backend/internal/infra/persistence/relational/account_repository.go +++ b/backend/internal/infra/persistence/relational/account_repository.go @@ -319,11 +319,17 @@ func (r *AccountRepository) ListEnabledAccountIDs(ctx context.Context, provider query := r.db.db.WithContext(ctx). Table("provider_accounts AS account"). Select("account.id"). - Where("account.provider = ? AND account.enabled = ? AND account.auth_status = ?", provider, true, account.AuthStatusActive) + Where("account.provider = ? AND account.enabled = ?", provider, true) if refreshableOnly { query = query. Joins("JOIN account_credentials AS credential ON credential.account_id = account.id"). - Where("credential.encrypted_refresh <> ''") + Where("credential.encrypted_refresh <> ''"). + Where( + "account.auth_status = ? OR (account.auth_status = ? AND credential.refresh_permanent = ? AND credential.last_refresh_error = ?)", + account.AuthStatusActive, account.AuthStatusReauthRequired, true, "credential_decrypt_failed", + ) + } else { + query = query.Where("account.auth_status = ?", account.AuthStatusActive) } var ids []uint64 err := query.Order("account.priority DESC, account.id ASC").Scan(&ids).Error