|
1 | 1 | import { generateKeyPairSync, verify } from 'node:crypto' |
2 | 2 | import { account, credential } from '@sim/db/schema' |
3 | | -import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' |
| 3 | +import { |
| 4 | + createDeferred, |
| 5 | + dbChainMockFns, |
| 6 | + flushMicrotasks, |
| 7 | + queueTableRows, |
| 8 | + resetDbChainMock, |
| 9 | +} from '@sim/testing' |
4 | 10 | import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' |
5 | 11 | import { getMockLogger } from '@sim/testing/mocks/logger.mock' |
6 | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
@@ -435,14 +441,77 @@ describe('OAuth access-token refresh headroom', () => { |
435 | 441 |
|
436 | 442 | describe.each(readers)('$name', ({ read, throwsOnFailure }) => { |
437 | 443 | it.each([ |
438 | | - { remainingMs: 300_000, refresh: true }, |
439 | | - { remainingMs: 300_001, refresh: false }, |
440 | | - { remainingMs: null, refresh: false }, |
441 | | - ])('handles $remainingMs milliseconds remaining', async ({ remainingMs, refresh }) => { |
442 | | - await expect(read(createOAuthAccount(remainingMs))).resolves.toBe( |
443 | | - refresh ? 'refreshed-access-token' : 'original-access-token' |
| 444 | + { providerId: 'google-drive', remainingMs: 120_000, refresh: 'wait' }, |
| 445 | + { providerId: 'google-drive', remainingMs: 240_000, refresh: 'ahead' }, |
| 446 | + { providerId: 'google-drive', remainingMs: 300_001, refresh: 'none' }, |
| 447 | + { providerId: 'google-drive', remainingMs: null, refresh: 'none' }, |
| 448 | + { providerId: 'confluence', remainingMs: 300_000, refresh: 'wait' }, |
| 449 | + { providerId: 'confluence', remainingMs: 300_001, refresh: 'none' }, |
| 450 | + ])( |
| 451 | + 'handles $providerId with $remainingMs milliseconds remaining', |
| 452 | + async ({ providerId, remainingMs, refresh }) => { |
| 453 | + await expect(read({ ...createOAuthAccount(remainingMs), providerId })).resolves.toBe( |
| 454 | + refresh === 'wait' ? 'refreshed-access-token' : 'original-access-token' |
| 455 | + ) |
| 456 | + if (refresh === 'ahead') { |
| 457 | + await vi.waitFor(() => expect(dbChainMockFns.set).toHaveBeenCalled()) |
| 458 | + } |
| 459 | + expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(refresh === 'none' ? 0 : 1) |
| 460 | + } |
| 461 | + ) |
| 462 | + |
| 463 | + it('uses a still-valid Google token while its refresh is still with the provider', async () => { |
| 464 | + const provider = createDeferred<unknown>() |
| 465 | + mocks.refreshOAuthToken.mockReturnValue(provider.promise) |
| 466 | + await expect(read(createOAuthAccount(240_000))).resolves.toBe('original-access-token') |
| 467 | + await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce()) |
| 468 | + expect(dbChainMockFns.set).not.toHaveBeenCalled() |
| 469 | + provider.resolve({ |
| 470 | + ok: true, |
| 471 | + accessToken: 'refreshed-access-token', |
| 472 | + refreshToken: 'rotated-refresh-token', |
| 473 | + expiresIn: 3600, |
| 474 | + }) |
| 475 | + await vi.waitFor(() => |
| 476 | + expect(dbChainMockFns.set).toHaveBeenCalledWith( |
| 477 | + expect.objectContaining({ |
| 478 | + accessToken: 'refreshed-access-token', |
| 479 | + refreshToken: 'rotated-refresh-token', |
| 480 | + }) |
| 481 | + ) |
444 | 482 | ) |
445 | | - expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(refresh ? 1 : 0) |
| 483 | + const guard = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)) |
| 484 | + expect(guard).toContain('original-refresh-token') |
| 485 | + }) |
| 486 | + |
| 487 | + it.each([ |
| 488 | + { |
| 489 | + label: 'a provider not verified for refresh-ahead', |
| 490 | + providerId: 'confluence', |
| 491 | + remainingMs: 240_000, |
| 492 | + }, |
| 493 | + { |
| 494 | + label: 'a Google token under the refresh floor', |
| 495 | + providerId: 'google-drive', |
| 496 | + remainingMs: 60_000, |
| 497 | + }, |
| 498 | + ])('waits for the provider with $label', async ({ providerId, remainingMs }) => { |
| 499 | + const provider = createDeferred<unknown>() |
| 500 | + mocks.refreshOAuthToken.mockReturnValue(provider.promise) |
| 501 | + let settled = false |
| 502 | + const pending = read({ ...createOAuthAccount(remainingMs), providerId }).finally(() => { |
| 503 | + settled = true |
| 504 | + }) |
| 505 | + await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce()) |
| 506 | + await flushMicrotasks(20) |
| 507 | + expect(settled).toBe(false) |
| 508 | + provider.resolve({ |
| 509 | + ok: true, |
| 510 | + accessToken: 'refreshed-access-token', |
| 511 | + refreshToken: 'rotated-refresh-token', |
| 512 | + expiresIn: 3600, |
| 513 | + }) |
| 514 | + await expect(pending).resolves.toBe('refreshed-access-token') |
446 | 515 | }) |
447 | 516 |
|
448 | 517 | it('still refreshes a missing access token without a known expiry', async () => { |
@@ -614,7 +683,40 @@ describe('OAuth access-token refresh headroom', () => { |
614 | 683 | accessToken: 'original-access-token', |
615 | 684 | refreshed: false, |
616 | 685 | }) |
617 | | - expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(2) |
| 686 | + await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledTimes(2)) |
| 687 | + }) |
| 688 | + |
| 689 | + it('stops a cancelled caller waiting while a concurrent caller still gets the refreshed token', async () => { |
| 690 | + const { coalesceLocally } = await vi.importActual< |
| 691 | + typeof import('@/lib/concurrency/singleflight') |
| 692 | + >('@/lib/concurrency/singleflight') |
| 693 | + mocks.coalesceLocally.mockImplementation(coalesceLocally) |
| 694 | + const provider = createDeferred<unknown>() |
| 695 | + mocks.refreshOAuthToken.mockReturnValue(provider.promise) |
| 696 | + const controller = new AbortController() |
| 697 | + const reason = new DOMException('Search deadline', 'TimeoutError') |
| 698 | + queueCredentialAccount(createOAuthAccount()) |
| 699 | + queueCredentialAccount(createOAuthAccount()) |
| 700 | + const cancelled = resolveCredentialTokenBundle( |
| 701 | + RAW_CREDENTIAL_ID, |
| 702 | + RAW_USER_ID, |
| 703 | + 'test', |
| 704 | + undefined, |
| 705 | + undefined, |
| 706 | + { signal: controller.signal } |
| 707 | + ) |
| 708 | + const concurrent = resolveCredentialTokenBundle(RAW_CREDENTIAL_ID, RAW_USER_ID, 'test') |
| 709 | + await vi.waitFor(() => expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce()) |
| 710 | + controller.abort(reason) |
| 711 | + await expect(cancelled).rejects.toBe(reason) |
| 712 | + provider.resolve({ |
| 713 | + ok: true, |
| 714 | + accessToken: 'refreshed-access-token', |
| 715 | + refreshToken: 'rotated-refresh-token', |
| 716 | + expiresIn: 3600, |
| 717 | + }) |
| 718 | + await expect(concurrent).resolves.toEqual({ accessToken: 'refreshed-access-token' }) |
| 719 | + expect(mocks.refreshOAuthToken).toHaveBeenCalledOnce() |
618 | 720 | }) |
619 | 721 | }) |
620 | 722 |
|
|
0 commit comments