Repository: NangoHQ/nango
Pull request #4006: test: refresh connections
Author: login: bodinsamuel
Created: 2025-05-07T10:42:01Z | Merged: 2025-05-12T09:08:44Z

## Changes

Fixes https://linear.app/nango/issue/NAN-3204/refresh-token-incident-follow-up

- Test refresh connections
  - Ensure unauthenticated doesn't do anything
  - Ensure api_key refresh fails or succeeds correctly
  - Ensure oauth2 refresh fails or succeeds correctly 
  
I have manually checked that the offending PR #3995 is now failing accordingly and found a few missing things
<!-- Summary by @propel-code-bot -->

---

This PR adds integration tests for the connection refresh functionality, ensuring that token refresh behaves correctly across different authentication types (unauthenticated, API key, OAuth2). The tests verify both success and failure scenarios, including database state validation after refresh operations.

**Key Changes:**
• Added comprehensive integration test suite for refreshOrTestCredentials
• Enhanced connection seeder to support custom connection properties
• Fixed timestamp update during connection refresh operations
• Added utility wait function for test timing consistency

**Affected Areas:**
• Connection credential refresh logic
• Connection seeder
• Test infrastructure

*This summary was automatically generated by @propel-code-bot*




Diff:
diff --git a/packages/shared/lib/seeders/connection.seeder.ts b/packages/shared/lib/seeders/connection.seeder.ts
index 8cbdb1a8d2..d534490153 100644
--- a/packages/shared/lib/seeders/connection.seeder.ts
+++ b/packages/shared/lib/seeders/connection.seeder.ts
@@ -36,7 +36,8 @@ export const createConnectionSeed = async ({
     endUser,
     connectionId,
     rawCredentials,
-    connectionConfig
+    connectionConfig,
+    ...rest
 }: {
     env: DBEnvironment;
     provider: string;
@@ -44,14 +45,17 @@ export const createConnectionSeed = async ({
     connectionId?: string;
     rawCredentials?: AuthCredentials;
     connectionConfig?: ConnectionConfig;
-}): Promise<DBConnection> => {
+} & Partial<
+    Omit<DBConnectionDecrypted, 'id' | 'end_user_id' | 'connection_id' | 'provider_config_key' | 'connection_config' | 'environment_id'>
+>): Promise<DBConnection> => {
     const name = connectionId ? connectionId : Math.random().toString(36).substring(7);
     const result = await connectionService.upsertConnection({
         connectionId: name,
         providerConfigKey: provider,
         parsedRawCredentials: rawCredentials || ({} as AuthCredentials),
         connectionConfig: connectionConfig || {},
-        environmentId: env.id
+        environmentId: env.id,
+        ...rest
     });
 
     if (!result || result[0] === undefined || !result[0].connection.id) {
diff --git a/packages/shared/lib/services/connection.service.ts b/packages/shared/lib/services/connection.service.ts
index 1dabc12d97..2ad3bdad26 100644
--- a/packages/shared/lib/services/connection.service.ts
+++ b/packages/shared/lib/services/connection.service.ts
@@ -471,7 +471,8 @@ class ConnectionService {
             .from<DBConnection>(`_nango_connections`)
             .where({ id })
             .update({
-                last_refresh_failure: new Date(),
+                updated_at: now,
+                last_refresh_failure: now,
                 last_refresh_success: null,
                 refresh_attempts: attempt,
                 refresh_exhausted: attempt >= MAX_CONSECUTIVE_DAYS_FAILED_REFRESH
diff --git a/packages/shared/lib/services/connections/credentials/refresh.integration.test.ts b/packages/shared/lib/services/connections/credentials/refresh.integration.test.ts
new file mode 100644
index 0000000000..ee1d39b34c
--- /dev/null
+++ b/packages/shared/lib/services/connections/credentials/refresh.integration.test.ts
@@ -0,0 +1,293 @@
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { multipleMigrations } from '@nangohq/database';
+import { logContextGetter, migrateLogsMapping } from '@nangohq/logs';
+import { Err, Ok, wait } from '@nangohq/utils';
+
+import { refreshOrTestCredentials } from './refresh.js';
+import { createConfigSeed, createConnectionSeed, seedAccountEnvAndUser } from '../../../seeders/index.js';
+import encryptionManager from '../../../utils/encryption.manager';
+import { NangoError } from '../../../utils/error.js';
+import connectionService from '../../connection.service.js';
+
+describe('refreshOrTestCredentials', () => {
+    beforeAll(async () => {
+        await multipleMigrations();
+        await migrateLogsMapping();
+    });
+
+    it('should not refresh if unauthenticated', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'unauthenticated', 'unauthenticated');
+        const connection = await createConnectionSeed({ env, provider: 'unauthenticated' });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        // wait just to make sure all timestamps are different
+        await wait(2);
+        const onFailed = vi.fn();
+        const onSuccess = vi.fn();
+        const onTest = vi.fn();
+        const res = await refreshOrTestCredentials({
+            account,
+            environment: env,
+            integration,
+            instantRefresh: false,
+            connection: decryptedConnection,
+            onRefreshFailed: onFailed,
+            onRefreshSuccess: onSuccess,
+            connectionTestHook: onTest,
+            logContextGetter: logContextGetter
+        });
+
+        const refreshed = res.unwrap();
+
+        // Nothing should have changed except last_fetched
+        expect(refreshed).toEqual({
+            ...decryptedConnection,
+            last_fetched_at: expect.any(Date)
+        });
+        expect(refreshed.last_fetched_at?.getTime()).toBeGreaterThan(decryptedConnection.last_fetched_at!.getTime());
+        expect(onFailed).not.toHaveBeenCalled();
+        expect(onSuccess).not.toHaveBeenCalled();
+        expect(onTest).not.toHaveBeenCalled();
+    });
+
+    it('should test if api key and succeed', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'algolia', 'algolia');
+        const connection = await createConnectionSeed({ env, provider: 'algolia', rawCredentials: { type: 'API_KEY', apiKey: 'foobar' } });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
+        const onFailed = vi.fn();
+        const onSuccess = vi.fn();
+        const onTest = vi.fn(() => Promise.resolve(Ok({ tested: true }))) as any;
+        const res = await refreshOrTestCredentials({
+            account,
+            environment: env,
+            integration,
+            instantRefresh: false,
+            connection: decryptedConnection,
+            onRefreshFailed: onFailed,
+            onRefreshSuccess: onSuccess,
+            connectionTestHook: onTest,
+            logContextGetter: logContextGetter
+        });
+
+        const refreshed = res.unwrap();
+
+        // Make sure all relevant fields are updated
+        expect(refreshed).toStrictEqual({
+            ...decryptedConnection,
+            last_fetched_at: expect.any(Date),
+            last_refresh_success: expect.any(Date),
+            credentials_expires_at: expect.any(Date),
+            updated_at: expect.any(Date),
+            credentials_iv: expect.any(String),
+            credentials_tag: expect.any(String)
+        });
+        expect(refreshed.last_fetched_at?.getTime()).toBeGreaterThan(decryptedConnection.last_fetched_at!.getTime());
+        expect(refreshed.credentials_expires_at?.getTime()).toBeGreaterThan(decryptedConnection.credentials_expires_at!.getTime());
+        expect(refreshed.last_refresh_success?.getTime()).toBeGreaterThan(decryptedConnection.last_refresh_success!.getTime());
+        expect(refreshed.updated_at?.getTime()).toBeGreaterThan(decryptedConnection.updated_at.getTime());
+        expect(refreshed.credentials_iv).not.toBe(decryptedConnection.credentials_iv);
+        // @ts-expect-error yes it's okay
+        expect(refreshed.credentials['apiKey']).toBe(decryptedConnection.credentials['apiKey']);
+
+        expect(onFailed).not.toHaveBeenCalled();
+        expect(onSuccess).toHaveBeenCalled();
+        expect(onTest).toHaveBeenCalled();
+
+        // Make sure we correctly reflected the update in the DB
+        const updatedConnection = await connectionService.getConnectionById(connection.id);
+        const decryptedUpdatedConnection = encryptionManager.decryptConnection(updatedConnection!);
+        if (!decryptedUpdatedConnection) {
+            throw new Error('Failed to decrypt updated connection');
+        }
+        expect(refreshed).toStrictEqual(decryptedUpdatedConnection);
+    });
+
+    it('should test if api key and fail', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'algolia', 'algolia');
+        const connection = await createConnectionSeed({ env, provider: 'algolia', rawCredentials: { type: 'API_KEY', apiKey: 'foobar' } });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
+        const onFailed = vi.fn();
+        const onSuccess = vi.fn();
+        const onTest = vi.fn(() => Promise.resolve(Err(new NangoError('test')))) as any;
+        const res = await refreshOrTestCredentials({
+            account,
+            environment: env,
+            integration,
+            instantRefresh: false,
+            connection: decryptedConnection,
+            onRefreshFailed: onFailed,
+            onRefreshSuccess: onSuccess,
+            connectionTestHook: onTest,
+            logContextGetter: logContextGetter
+        });
+
+        if (res.isOk()) {
+            throw new Error('should not succeed');
+        }
+
+        expect(onFailed).toHaveBeenCalled();
+        expect(onSuccess).not.toHaveBeenCalled();
+        expect(onTest).toHaveBeenCalled();
+
+        // Make sure we correctly reflected the update in the DB
+        const updatedConnection = await connectionService.getConnectionById(connection.id);
+        const decryptedUpdatedConnection = encryptionManager.decryptConnection(updatedConnection!);
+        if (!decryptedUpdatedConnection) {
+            throw new Error('Failed to decrypt updated connection');
+        }
+        expect(decryptedUpdatedConnection).toStrictEqual({
+            ...decryptedConnection,
+            last_fetched_at: expect.any(Date),
+            last_refresh_success: null,
+            last_refresh_failure: expect.any(Date),
+            updated_at: expect.any(Date),
+            refresh_attempts: 1
+        });
+        expect(decryptedUpdatedConnection.last_fetched_at?.getTime()).toBeGreaterThan(decryptedConnection.last_fetched_at!.getTime());
+        expect(decryptedUpdatedConnection.updated_at?.getTime()).toBeGreaterThan(decryptedConnection.updated_at.getTime());
+    });
+
+    it('should refresh if oauth2 and succeed (fake token)', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'airtable', 'airtable');
+        const connection = await createConnectionSeed({
+            env,
+            provider: 'airtable',
+            rawCredentials: { type: 'OAUTH2', access_token: 'foobar', refresh_token: 'barfoo', expires_at: new Date(Date.now() - 1000), raw: {} }
+        });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
+        const onFailed = vi.fn();
+        const onSuccess = vi.fn();
+        const spy = vi.spyOn(connectionService, 'getNewCredentials').mockImplementation(() => {
+            return Promise.resolve({
+                success: true,
+                error: null,
+                response: { type: 'OAUTH2', access_token: 'new1', refresh_token: 'new2', expires_at: new Date(Date.now() + 1000), raw: {} }
+            });
+        });
+        const res = await refreshOrTestCredentials({
+            account,
+            environment: env,
+            integration,
+            instantRefresh: false,
+            connection: decryptedConnection,
+            onRefreshFailed: onFailed,
+            onRefreshSuccess: onSuccess,
+            logContextGetter: logContextGetter
+        });
+
+        const refreshed = res.unwrap();
+
+        // Make sure all relevant fields are updated
+        expect(refreshed).toStrictEqual<typeof refreshed>({
+            ...decryptedConnection,
+            credentials: {
+                type: 'OAUTH2',
+                access_token: 'new1',
+                refresh_token: 'new2',
+                // @ts-expect-error todo: fix this type
+                expires_at: expect.toBeIsoDate(),
+                raw: {}
+            },
+            last_fetched_at: expect.any(Date),
+            last_refresh_success: expect.any(Date),
+            credentials_expires_at: expect.any(Date),
+            updated_at: expect.any(Date),
+            credentials_iv: expect.any(String),
+            credentials_tag: expect.any(String)
+        });
+        expect(refreshed.last_fetched_at?.getTime()).toBeGreaterThan(decryptedConnection.last_fetched_at!.getTime());
+        expect(refreshed.credentials_expires_at?.getTime()).toBeGreaterThan(decryptedConnection.credentials_expires_at!.getTime());
+        expect(refreshed.last_refresh_success?.getTime()).toBeGreaterThan(decryptedConnection.last_refresh_success!.getTime());
+        expect(refreshed.updated_at?.getTime()).toBeGreaterThan(decryptedConnection.updated_at.getTime());
+        expect(refreshed.credentials_iv).not.toBe(decryptedConnection.credentials_iv);
+        // @ts-expect-error yes it's okay
+        expect(refreshed.credentials['apiKey']).toBe(decryptedConnection.credentials['apiKey']);
+
+        expect(onFailed).not.toHaveBeenCalledOnce();
+        expect(onSuccess).toHaveBeenCalledOnce();
+        expect(spy).toHaveBeenCalledOnce();
+
+        // Make sure we correctly reflected the update in the DB
+        const updatedConnection = await connectionService.getConnectionById(connection.id);
+        const decryptedUpdatedConnection = encryptionManager.decryptConnection(updatedConnection!);
+        if (!decryptedUpdatedConnection) {
+            throw new Error('Failed to decrypt updated connection');
+        }
+        expect(refreshed).toStrictEqual(decryptedUpdatedConnection);
+    });
+
+    it('should refresh if oauth2 and fail (wrong token)', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'test', 'airtable');
+        const connection = await createConnectionSeed({
+            env,
+            provider: 'airtable',
+            rawCredentials: { type: 'OAUTH2', access_token: 'foobar', refresh_token: 'barfoo', raw: {} }
+        });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
+        const onFailed = vi.fn();
+        const onSuccess = vi.fn();
+        const res = await refreshOrTestCredentials({
+            account,
+            environment: env,
+            integration,
+            instantRefresh: false,
+            connection: decryptedConnection,
+            onRefreshFailed: onFailed,
+            onRefreshSuccess: onSuccess,
+            logContextGetter: logContextGetter
+        });
+
+        if (res.isOk()) {
+            throw new Error('should not succeed');
+        }
+
+        expect(onFailed).toHaveBeenCalled();
+        expect(onSuccess).not.toHaveBeenCalled();
+
+        // Make sure we correctly reflected the update in the DB
+        const updatedConnection = await connectionService.getConnectionById(connection.id);
+        const decryptedUpdatedConnection = encryptionManager.decryptConnection(updatedConnection!);
+        if (!decryptedUpdatedConnection) {
+            throw new Error('Failed to decrypt updated connection');
+        }
+        expect(decryptedUpdatedConnection).toStrictEqual({
+            ...decryptedConnection,
+            last_fetched_at: expect.any(Date),
+            last_refresh_success: null,
+            last_refresh_failure: expect.any(Date),
+            updated_at: expect.any(Date),
+            refresh_attempts: 1
+        });
+        expect(decryptedUpdatedConnection.last_fetched_at?.getTime()).toBeGreaterThan(decryptedConnection.last_fetched_at!.getTime());
+        expect(decryptedUpdatedConnection.updated_at?.getTime()).toBeGreaterThan(decryptedConnection.updated_at.getTime());
+    });
+});
diff --git a/packages/shared/lib/services/connections/credentials/refresh.ts b/packages/shared/lib/services/connections/credentials/refresh.ts
index de0a178c49..b7b1aa44a9 100644
--- a/packages/shared/lib/services/connections/credentials/refresh.ts
+++ b/packages/shared/lib/services/connections/credentials/refresh.ts
@@ -81,6 +81,7 @@ export async function refreshOrTestCredentials(props: RefreshProps): Promise<Res
 
         // TODO: remove this when cron is using other columns
         await connectionService.updateLastFetched(props.connection.id);
+        props.connection = { ...props.connection, last_fetched_at: new Date() };
 
         // short-circuit if we know the refresh will fail
         // we can't return an error because it would a breaking change in GET /connection
@@ -146,7 +147,8 @@ export async function refreshOrTestCredentials(props: RefreshProps): Promise<Res
                 last_refresh_success: new Date(),
                 last_refresh_failure: null,
                 refresh_attempts: null,
-                refresh_exhausted: false
+                refresh_exhausted: false,
+                updated_at: new Date()
             });
         }
 
@@ -309,7 +311,7 @@ async function testCredentials(
     return Ok(oldConnection);
 }
 
-async function refreshCredentialsIfNeeded({
+export async function refreshCredentialsIfNeeded({
     connectionId,
     environmentId,
     providerConfig,
diff --git a/packages/utils/lib/index.ts b/packages/utils/lib/index.ts
index a54f6e0785..fb2c648eb0 100644
--- a/packages/utils/lib/index.ts
+++ b/packages/utils/lib/index.ts
@@ -22,3 +22,4 @@ export * from './auth.js';
 export * from './once.js';
 export * from './http.js';
 export * from './version.js';
+export * from './wait.js';
diff --git a/packages/utils/lib/wait.ts b/packages/utils/lib/wait.ts
new file mode 100644
index 0000000000..6640e884f6
--- /dev/null
+++ b/packages/utils/lib/wait.ts
@@ -0,0 +1,5 @@
+import { setTimeout } from 'node:timers/promises';
+
+export async function wait(ms: number): Promise<void> {
+    await setTimeout(ms);
+}


Reviews:
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T12:43:47Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T12:44:59Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T12:45:19Z
user: login: propel-code-bot[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T12:47:08Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T12:48:02Z
user: login: TBonnin
body: thank you for doing this
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-07T13:22:09Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:52:33Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:55:28Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:56:47Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:58:39Z
user: login: TBonnin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T21:19:29Z
user: login: kaposke
body: Tests 🎉
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T14:36:23Z

Review comments:
diff_hunk: @@ -36,22 +36,26 @@ export const createConnectionSeed = async ({
     endUser,
     connectionId,
     rawCredentials,
-    connectionConfig
+    connectionConfig,
+    ...rest
 }: {
     env: DBEnvironment;
     provider: string;
     endUser?: EndUser;
     connectionId?: string;
     rawCredentials?: AuthCredentials;
     connectionConfig?: ConnectionConfig;
-}): Promise<DBConnection> => {
+} & Partial<
+    Omit<DBConnectionDecrypted, 'id' | 'end_user_id' | 'connection_id' | 'provider_config_key' | 'connection_config' | 'environment_id'>
+>): Promise<DBConnection> => {
     const name = connectionId ? connectionId : Math.random().toString(36).substring(7);
     const result = await connectionService.upsertConnection({
         connectionId: name,
         providerConfigKey: provider,
         parsedRawCredentials: rawCredentials || ({} as AuthCredentials),
         connectionConfig: connectionConfig || {},
-        environmentId: env.id
+        environmentId: env.id,
+        ...rest
     });
path: packages/shared/lib/seeders/connection.seeder.ts
user: login: bodinsamuel
body: I initially needed that but turns out no, but can be handy
created_at: 2025-05-07T12:43:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +81,7 @@ export async function refreshOrTestCredentials(props: RefreshProps): Promise<Res
 
         // TODO: remove this when cron is using other columns
         await connectionService.updateLastFetched(props.connection.id);
+        props.connection = { ...props.connection, last_fetched_at: new Date() };
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: bodinsamuel
body: Either that or we return the new connection and decrypt, wdyt?
created_at: 2025-05-07T12:44:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+import { setTimeout } from 'node:timers/promises';
+
+export async function wait(ms: number): Promise<void> {
+    await setTimeout(ms);
+}
path: packages/utils/lib/wait.ts
user: login: bodinsamuel
body: Got sick of vscode not suggesting setTimeout import
created_at: 2025-05-07T12:45:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -471,6 +471,7 @@ class ConnectionService {
             .from<DBConnection>(`_nango_connections`)
             .where({ id })
             .update({
+                updated_at: new Date(),
                 last_refresh_failure: new Date(),
                 last_refresh_success: null,
                 refresh_attempts: attempt,
path: packages/shared/lib/services/connection.service.ts
user: login: propel-code-bot[bot]
body: [**BestPractice**]

The `updateRefreshFailure` method in `connection.service.ts` updates several database fields including `last_refresh_failure`, `last_refresh_success`, and `refresh_attempts`, but doesn't update the `updated_at` timestamp. This means the record timestamp might not accurately reflect when its state actually changed.

```suggestion
            .from<DBConnection>(`_nango_connections`)
            .where({ id })
            .update({
                updated_at: new Date(),
                last_refresh_failure: new Date(),
                last_refresh_success: null,
                refresh_attempts: attempt,
```

⚡ **Committable suggestion**

Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.
created_at: 2025-05-07T12:47:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -471,6 +471,7 @@ class ConnectionService {
             .from<DBConnection>(`_nango_connections`)
             .where({ id })
             .update({
+                updated_at: new Date(),
                 last_refresh_failure: new Date(),
                 last_refresh_success: null,
                 refresh_attempts: attempt,
path: packages/shared/lib/services/connection.service.ts
user: login: bodinsamuel
body: so you are suggesting the same code as mine? 🤡 

created_at: 2025-05-07T12:48:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -471,6 +471,7 @@ class ConnectionService {
             .from<DBConnection>(`_nango_connections`)
             .where({ id })
             .update({
+                updated_at: new Date(),
path: packages/shared/lib/services/connection.service.ts
user: login: TBonnin
body: nit: let's use the same date for updated_at and last_refresh_failure
created_at: 2025-05-07T12:59:34Z
author_association: COLLABORATOR
diff_hunk: @@ -471,6 +471,7 @@ class ConnectionService {
             .from<DBConnection>(`_nango_connections`)
             .where({ id })
             .update({
+                updated_at: new Date(),
                 last_refresh_failure: new Date(),
                 last_refresh_success: null,
                 refresh_attempts: attempt,
path: packages/shared/lib/services/connection.service.ts
user: login: TBonnin
body: propel, you need to review the code after the changes, not before!!!!
created_at: 2025-05-07T13:00:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,5 @@
+import { setTimeout } from 'node:timers/promises';
+
+export async function wait(ms: number): Promise<void> {
+    await setTimeout(ms);
+}
path: packages/utils/lib/wait.ts
user: login: TBonnin
body: not sure about this one.
created_at: 2025-05-07T13:01:44Z
author_association: COLLABORATOR
diff_hunk: @@ -81,6 +81,7 @@ export async function refreshOrTestCredentials(props: RefreshProps): Promise<Res
 
         // TODO: remove this when cron is using other columns
         await connectionService.updateLastFetched(props.connection.id);
+        props.connection = { ...props.connection, last_fetched_at: new Date() };
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: TBonnin
body: I like the idea of the db being the source of truth but the decrypting step seems unnecessary. How close of implementing the TODO are we? if we are gonna remove the code anyway I will keep it as it is
created_at: 2025-05-07T13:08:48Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,292 @@
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { multipleMigrations } from '@nangohq/database';
+import { logContextGetter, migrateLogsMapping } from '@nangohq/logs';
+import { Err, Ok, wait } from '@nangohq/utils';
+
+import { refreshOrTestCredentials } from './refresh.js';
+import { createConfigSeed, createConnectionSeed, seedAccountEnvAndUser } from '../../../seeders/index.js';
+import encryptionManager from '../../../utils/encryption.manager';
+import { NangoError } from '../../../utils/error.js';
+import connectionService from '../../connection.service.js';
+
+describe('refreshOrTestCredentials', () => {
+    beforeAll(async () => {
+        await multipleMigrations();
+        await migrateLogsMapping();
+    });
+
+    it('should not refresh if unauthenticated', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'unauthenticated', 'unauthenticated');
+        const connection = await createConnectionSeed({ env, provider: 'unauthenticated' });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
path: packages/shared/lib/services/connections/credentials/refresh.integration.test.ts
user: login: TBonnin
body: why waiting?
created_at: 2025-05-07T13:12:50Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,292 @@
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { multipleMigrations } from '@nangohq/database';
+import { logContextGetter, migrateLogsMapping } from '@nangohq/logs';
+import { Err, Ok, wait } from '@nangohq/utils';
+
+import { refreshOrTestCredentials } from './refresh.js';
+import { createConfigSeed, createConnectionSeed, seedAccountEnvAndUser } from '../../../seeders/index.js';
+import encryptionManager from '../../../utils/encryption.manager';
+import { NangoError } from '../../../utils/error.js';
+import connectionService from '../../connection.service.js';
+
+describe('refreshOrTestCredentials', () => {
+    beforeAll(async () => {
+        await multipleMigrations();
+        await migrateLogsMapping();
+    });
+
+    it('should not refresh if unauthenticated', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'unauthenticated', 'unauthenticated');
+        const connection = await createConnectionSeed({ env, provider: 'unauthenticated' });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
+        const onFailed = vi.fn();
+        const onSuccess = vi.fn();
+        const onTest = vi.fn();
+        const res = await refreshOrTestCredentials({
+            account,
+            environment: env,
+            integration,
+            instantRefresh: false,
+            connection: decryptedConnection,
+            onRefreshFailed: onFailed,
+            onRefreshSuccess: onSuccess,
+            connectionTestHook: onTest,
+            logContextGetter: logContextGetter
+        });
+
+        const value = res.unwrap();
+
+        // Nothing should have changed except last_fetched
+        expect(value).toEqual({
+            ...decryptedConnection,
+            last_fetched_at: expect.any(Date)
+        });
+        expect(value.last_fetched_at?.getTime()).toBeGreaterThan(decryptedConnection.last_fetched_at!.getTime());
+        expect(onFailed).not.toHaveBeenCalled();
+        expect(onSuccess).not.toHaveBeenCalled();
+        expect(onTest).not.toHaveBeenCalled();
+    });
+
+    it('should test if api key and succeed', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'algolia', 'algolia');
+        const connection = await createConnectionSeed({ env, provider: 'algolia', rawCredentials: { type: 'API_KEY', apiKey: 'foobar' } });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
+        const onFailed = vi.fn();
+        const onSuccess = vi.fn();
+        const onTest = vi.fn(() => Promise.resolve(Ok({ tested: true }))) as any;
+        const res = await refreshOrTestCredentials({
+            account,
+            environment: env,
+            integration,
+            instantRefresh: false,
+            connection: decryptedConnection,
+            onRefreshFailed: onFailed,
+            onRefreshSuccess: onSuccess,
+            connectionTestHook: onTest,
+            logContextGetter: logContextGetter
+        });
+
+        const value = res.unwrap();
path: packages/shared/lib/services/connections/credentials/refresh.integration.test.ts
user: login: TBonnin
body: nit: I'll call it `refreshed` or something like this. It is a bit more meaningful than 'value'
created_at: 2025-05-07T13:19:46Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,292 @@
+import { beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { multipleMigrations } from '@nangohq/database';
+import { logContextGetter, migrateLogsMapping } from '@nangohq/logs';
+import { Err, Ok, wait } from '@nangohq/utils';
+
+import { refreshOrTestCredentials } from './refresh.js';
+import { createConfigSeed, createConnectionSeed, seedAccountEnvAndUser } from '../../../seeders/index.js';
+import encryptionManager from '../../../utils/encryption.manager';
+import { NangoError } from '../../../utils/error.js';
+import connectionService from '../../connection.service.js';
+
+describe('refreshOrTestCredentials', () => {
+    beforeAll(async () => {
+        await multipleMigrations();
+        await migrateLogsMapping();
+    });
+
+    it('should not refresh if unauthenticated', async () => {
+        const { env, account } = await seedAccountEnvAndUser();
+        const integration = await createConfigSeed(env, 'unauthenticated', 'unauthenticated');
+        const connection = await createConnectionSeed({ env, provider: 'unauthenticated' });
+        const decryptedConnection = encryptionManager.decryptConnection(connection);
+        if (!decryptedConnection) {
+            throw new Error('Failed to decrypt connection');
+        }
+
+        await wait(2);
path: packages/shared/lib/services/connections/credentials/refresh.integration.test.ts
user: login: bodinsamuel
body: because otherwise it executes too fast, and some dates can have the same seconds. I'll add a comment
created_at: 2025-05-07T13:52:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -471,6 +471,7 @@ class ConnectionService {
             .from<DBConnection>(`_nango_connections`)
             .where({ id })
             .update({
+                updated_at: new Date(),
path: packages/shared/lib/services/connection.service.ts
user: login: bodinsamuel
body: right, I already had a variable now
created_at: 2025-05-07T13:55:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+import { setTimeout } from 'node:timers/promises';
+
+export async function wait(ms: number): Promise<void> {
+    await setTimeout(ms);
+}
path: packages/utils/lib/wait.ts
user: login: bodinsamuel
body: why? it's pretty [common](https://github.com/search?q=%22wait%28ms%3A+number%29%22+language%3ATypeScript&type=code&l=TypeScript) actually :D 
created_at: 2025-05-07T13:56:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -81,6 +81,7 @@ export async function refreshOrTestCredentials(props: RefreshProps): Promise<Res
 
         // TODO: remove this when cron is using other columns
         await connectionService.updateLastFetched(props.connection.id);
+        props.connection = { ...props.connection, last_fetched_at: new Date() };
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: bodinsamuel
body: not sure tbh, I still feel it could get removed but we need to store the refresh token expiration date to finish the migration
created_at: 2025-05-07T13:58:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,5 @@
+import { setTimeout } from 'node:timers/promises';
+
+export async function wait(ms: number): Promise<void> {
+    await setTimeout(ms);
+}
path: packages/utils/lib/wait.ts
user: login: TBonnin
body: I won't fight it but 4 lines of code to replace 2 doesn't seem necessary ;)
created_at: 2025-05-07T21:19:29Z
author_association: COLLABORATOR

PR comments:
user: login: linear[bot]
created_at: 2025-05-07T12:39:18Z
body: <p><a href="https://linear.app/nango/issue/NAN-3204/refresh-token-incident-follow-up">NAN-3204 [Refresh token incident] Follow up</a></p>
author_association: NONE