285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
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> = {}): IntegrationRow => ({
|
|
id: 11,
|
|
provider: 'linear',
|
|
externalWorkspaceId: 'ws_1',
|
|
displayName: 'Acme',
|
|
scopes: ['read'],
|
|
tokenExpiresAt: null,
|
|
...overrides,
|
|
});
|
|
|
|
describe('oauth.service', () => {
|
|
const originalEnv = new Map<string, string | undefined>();
|
|
|
|
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/
|
|
);
|
|
});
|
|
});
|
|
});
|