Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions backend/internal/application/account/credential_refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/application/account/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down