import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockRequestJson, mockFindByProviderWorkspace, mockGetAccessToken, mockGetRefreshToken, mockUpdateTokens, } = vi.hoisted(() => ({ mockRequestJson: vi.fn(), mockFindByProviderWorkspace: vi.fn(), mockGetAccessToken: vi.fn(), mockGetRefreshToken: vi.fn(), mockUpdateTokens: vi.fn(), })); vi.mock('../lib/httpClient.js', () => ({ requestJson: mockRequestJson, })); vi.mock('../db/integrations.dao.js', () => ({ findByProviderWorkspace: mockFindByProviderWorkspace, getAccessToken: mockGetAccessToken, getRefreshToken: mockGetRefreshToken, updateTokens: mockUpdateTokens, })); import { buildAuthorizeUrl, exchangeCode, refreshAccessToken, getValidAccessToken, OAuthError, } from '../services/oauth.service.js'; import type { IntegrationRow } from '../types/integration.js'; const ENV_KEYS = [ 'LINEAR_CLIENT_ID', 'LINEAR_CLIENT_SECRET', 'SLACK_CLIENT_ID', 'SLACK_CLIENT_SECRET', ] as const; const integration = (overrides: Partial = {}): IntegrationRow => ({ id: 11, provider: 'linear', externalWorkspaceId: 'ws_1', displayName: 'Acme', scopes: ['read'], tokenExpiresAt: null, ...overrides, }); describe('oauth.service', () => { const originalEnv = new Map(); beforeEach(() => { vi.clearAllMocks(); for (const key of ENV_KEYS) originalEnv.set(key, process.env[key]); process.env.LINEAR_CLIENT_ID = 'client-id-1'; process.env.LINEAR_CLIENT_SECRET = 'client-secret-1'; process.env.SLACK_CLIENT_ID = 'slack-id'; process.env.SLACK_CLIENT_SECRET = 'slack-secret'; }); afterEach(() => { for (const key of ENV_KEYS) { const value = originalEnv.get(key); if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); describe('buildAuthorizeUrl', () => { it('should build an authorize url against the provider endpoint with all required params', () => { const url = new URL( buildAuthorizeUrl('linear', { redirectUri: 'https://app.test/cb', state: 'st_1' }) ); expect(`${url.origin}${url.pathname}`).toBe('https://linear.app/oauth/authorize'); expect(url.searchParams.get('client_id')).toBe('client-id-1'); expect(url.searchParams.get('redirect_uri')).toBe('https://app.test/cb'); expect(url.searchParams.get('response_type')).toBe('code'); expect(url.searchParams.get('state')).toBe('st_1'); expect(url.searchParams.get('scope')).toBe('read'); }); it('should join multiple configured scopes with spaces', () => { const url = new URL( buildAuthorizeUrl('slack', { redirectUri: 'https://app.test/cb', state: 'st_2' }) ); expect(url.searchParams.get('scope')).toBe('channels:history reactions:read users:read'); }); it('should throw an OAuthError when client credentials are not configured', () => { delete process.env.LINEAR_CLIENT_SECRET; expect(() => buildAuthorizeUrl('linear', { redirectUri: 'https://app.test/cb', state: 'st_1' }) ).toThrow(OAuthError); }); it('should throw an OAuthError for a provider without OAuth support', () => { expect(() => buildAuthorizeUrl('rest', { redirectUri: 'https://app.test/cb', state: 'st_1' }) ).toThrow(/does not support OAuth/); }); }); describe('exchangeCode', () => { it('should post a form-encoded authorization_code grant to the token endpoint', async () => { mockRequestJson.mockResolvedValue({ access_token: 'at_1' }); await exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb' }); const [url, init] = mockRequestJson.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://api.linear.app/oauth/token'); expect(init.method).toBe('POST'); expect(init.headers).toMatchObject({ 'content-type': 'application/x-www-form-urlencoded', }); const form = new URLSearchParams(String(init.body)); expect(form.get('grant_type')).toBe('authorization_code'); expect(form.get('code')).toBe('code_1'); expect(form.get('redirect_uri')).toBe('https://app.test/cb'); expect(form.get('client_id')).toBe('client-id-1'); expect(form.get('client_secret')).toBe('client-secret-1'); }); it('should map the token response into a TokenSet', async () => { mockRequestJson.mockResolvedValue({ access_token: 'at_1', refresh_token: 'rt_1', expires_in: 3600, scope: 'read write', }); const before = Date.now(); const tokens = await exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb', }); expect(tokens.accessToken).toBe('at_1'); expect(tokens.refreshToken).toBe('rt_1'); expect(tokens.scopes).toEqual(['read', 'write']); expect(tokens.expiresAt).toBeInstanceOf(Date); expect(tokens.expiresAt?.getTime()).toBeGreaterThanOrEqual(before + 3_600_000); expect(tokens.expiresAt?.getTime()).toBeLessThanOrEqual(Date.now() + 3_600_000); }); it('should leave expiresAt undefined and scopes empty when the response omits them', async () => { mockRequestJson.mockResolvedValue({ access_token: 'at_1' }); const tokens = await exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb', }); expect(tokens.expiresAt).toBeUndefined(); expect(tokens.scopes).toEqual([]); }); it('should throw an OAuthError when the payload carries no usable access token', async () => { mockRequestJson.mockResolvedValue({ token_type: 'bearer' }); await expect( exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb' }) ).rejects.toThrow(OAuthError); }); it('should throw an OAuthError when credentials are missing', async () => { delete process.env.LINEAR_CLIENT_ID; await expect( exchangeCode('linear', { code: 'code_1', redirectUri: 'https://app.test/cb' }) ).rejects.toThrow(/LINEAR_CLIENT_ID/); expect(mockRequestJson).not.toHaveBeenCalled(); }); }); describe('refreshAccessToken', () => { it('should post a refresh_token grant carrying the stored refresh token', async () => { mockRequestJson.mockResolvedValue({ access_token: 'at_2' }); const tokens = await refreshAccessToken('linear', 'rt_1'); expect(tokens.accessToken).toBe('at_2'); const [, init] = mockRequestJson.mock.calls[0] as [string, RequestInit]; const form = new URLSearchParams(String(init.body)); expect(form.get('grant_type')).toBe('refresh_token'); expect(form.get('refresh_token')).toBe('rt_1'); }); }); describe('getValidAccessToken', () => { it('should return the stored access token when it is not near expiry', async () => { mockFindByProviderWorkspace.mockResolvedValue( integration({ tokenExpiresAt: new Date(Date.now() + 3_600_000) }) ); mockGetAccessToken.mockResolvedValue('at_stored'); const result = await getValidAccessToken('linear', 'ws_1'); expect(result).toBe('at_stored'); expect(mockRequestJson).not.toHaveBeenCalled(); expect(mockUpdateTokens).not.toHaveBeenCalled(); }); it('should return the stored access token when no expiry is recorded', async () => { mockFindByProviderWorkspace.mockResolvedValue(integration()); mockGetAccessToken.mockResolvedValue('at_stored'); const result = await getValidAccessToken('linear', 'ws_1'); expect(result).toBe('at_stored'); expect(mockGetAccessToken).toHaveBeenCalledWith(11); }); it('should refresh and persist the new token when expiry is within the refresh margin', async () => { mockFindByProviderWorkspace.mockResolvedValue( integration({ tokenExpiresAt: new Date(Date.now() + 30_000) }) ); mockGetRefreshToken.mockResolvedValue('rt_1'); mockRequestJson.mockResolvedValue({ access_token: 'at_refreshed', refresh_token: 'rt_2', expires_in: 3600, }); const result = await getValidAccessToken('linear', 'ws_1'); expect(result).toBe('at_refreshed'); expect(mockUpdateTokens).toHaveBeenCalledWith( expect.objectContaining({ id: 11, accessToken: 'at_refreshed', refreshToken: 'rt_2' }) ); expect(mockGetAccessToken).not.toHaveBeenCalled(); }); it('should refresh when the token has already expired', async () => { mockFindByProviderWorkspace.mockResolvedValue( integration({ tokenExpiresAt: new Date(Date.now() - 1_000) }) ); mockGetRefreshToken.mockResolvedValue('rt_1'); mockRequestJson.mockResolvedValue({ access_token: 'at_refreshed' }); const result = await getValidAccessToken('linear', 'ws_1'); expect(result).toBe('at_refreshed'); }); it('should throw an OAuthError when the workspace has no integration', async () => { mockFindByProviderWorkspace.mockResolvedValue(null); await expect(getValidAccessToken('linear', 'ws_missing')).rejects.toThrow(OAuthError); await expect(getValidAccessToken('linear', 'ws_missing')).rejects.toThrow( /No linear integration for workspace ws_missing/ ); }); it('should throw an OAuthError when a refresh is needed but no refresh token is stored', async () => { mockFindByProviderWorkspace.mockResolvedValue( integration({ tokenExpiresAt: new Date(Date.now() + 1_000) }) ); mockGetRefreshToken.mockResolvedValue(null); await expect(getValidAccessToken('linear', 'ws_1')).rejects.toThrow( /no refresh token is stored/ ); expect(mockUpdateTokens).not.toHaveBeenCalled(); }); it('should throw an OAuthError when no access token is stored', async () => { mockFindByProviderWorkspace.mockResolvedValue(integration()); mockGetAccessToken.mockResolvedValue(null); await expect(getValidAccessToken('linear', 'ws_1')).rejects.toThrow( /No access token stored for linear/ ); }); }); });