Files
kongruity/backend/tests/integrations.dao.test.ts

272 lines
8.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const { mockQuery, mockConnect } = vi.hoisted(() => ({
mockQuery: vi.fn(),
mockConnect: vi.fn(),
}));
vi.mock('../db/index.js', () => ({
query: mockQuery,
getPool: () => ({ connect: mockConnect }),
}));
import {
findByApiKeyHash,
findByProviderWorkspace,
upsertInstall,
updateTokens,
getAccessToken,
getRefreshToken,
getSigningSecret,
} from '../db/integrations.dao.js';
import { encryptSecret } from '../lib/crypto.js';
const TEST_KEY = Buffer.alloc(32, 0x2b).toString('base64');
const RAW_ROW = {
id: 3,
provider: 'slack',
external_workspace_id: 'T123',
display_name: 'Acme',
api_key_hash: 'abc123',
access_token_ciphertext: 'iv:tag:payload',
refresh_token_ciphertext: 'iv:tag:payload',
signing_secret_ciphertext: 'iv:tag:payload',
token_expires_at: new Date('2030-01-01T00:00:00.000Z'),
scopes: ['channels:history'],
};
describe('integrations.dao', () => {
let originalKey: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
originalKey = process.env.TOKEN_ENCRYPTION_KEY;
process.env.TOKEN_ENCRYPTION_KEY = TEST_KEY;
});
afterEach(() => {
if (originalKey === undefined) {
delete process.env.TOKEN_ENCRYPTION_KEY;
} else {
process.env.TOKEN_ENCRYPTION_KEY = originalKey;
}
});
describe('findByApiKeyHash', () => {
it('should return the integration mapped to camelCase fields', async () => {
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
const result = await findByApiKeyHash('abc123');
expect(result).toEqual({
id: 3,
provider: 'slack',
externalWorkspaceId: 'T123',
displayName: 'Acme',
scopes: ['channels:history'],
tokenExpiresAt: new Date('2030-01-01T00:00:00.000Z'),
});
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('WHERE api_key_hash = $1');
expect(params).toEqual(['abc123']);
});
it('should not expose any ciphertext or token field on the returned row', async () => {
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
const result = await findByApiKeyHash('abc123');
expect(result).not.toBeNull();
expect(result).not.toHaveProperty('access_token_ciphertext');
expect(result).not.toHaveProperty('refresh_token_ciphertext');
expect(result).not.toHaveProperty('signing_secret_ciphertext');
expect(result).not.toHaveProperty('accessToken');
expect(result).not.toHaveProperty('api_key_hash');
expect(Object.keys(result ?? {}).sort()).toEqual([
'displayName',
'externalWorkspaceId',
'id',
'provider',
'scopes',
'tokenExpiresAt',
]);
});
it('should default scopes to an empty array when the column is null', async () => {
mockQuery.mockResolvedValue({ rows: [{ ...RAW_ROW, scopes: null }] });
const result = await findByApiKeyHash('abc123');
expect(result?.scopes).toEqual([]);
});
it('should return null when no integration matches the hash', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await findByApiKeyHash('nope');
expect(result).toBeNull();
});
});
describe('findByProviderWorkspace', () => {
it('should bind the provider and external workspace id', async () => {
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
const result = await findByProviderWorkspace('slack', 'T123');
expect(result?.id).toBe(3);
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('WHERE provider = $1 AND external_workspace_id = $2');
expect(params).toEqual(['slack', 'T123']);
});
it('should return null when the workspace has no integration', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await findByProviderWorkspace('slack', 'T999');
expect(result).toBeNull();
});
});
describe('upsertInstall', () => {
it('should encrypt the signing secret before binding it', async () => {
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
await upsertInstall({
provider: 'slack',
externalWorkspaceId: 'T123',
displayName: 'Acme',
signingSecret: 'super-secret',
});
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('INSERT INTO integrations');
expect(sql).toContain('ON CONFLICT (provider, external_workspace_id) DO UPDATE SET');
const stored = params[4];
expect(typeof stored).toBe('string');
expect(stored).not.toBe('super-secret');
expect(String(stored).split(':')).toHaveLength(3);
});
it('should bind null when no signing secret is supplied', async () => {
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
await upsertInstall({ provider: 'slack', externalWorkspaceId: 'T123' });
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(params[4]).toBeNull();
expect(params[2]).toBeNull();
expect(params[3]).toBeNull();
expect(params[5]).toEqual([]);
});
it('should return the public row for the upserted integration', async () => {
mockQuery.mockResolvedValue({ rows: [RAW_ROW] });
const result = await upsertInstall({ provider: 'slack', externalWorkspaceId: 'T123' });
expect(result.externalWorkspaceId).toBe('T123');
expect(result).not.toHaveProperty('signing_secret_ciphertext');
});
});
describe('updateTokens', () => {
it('should encrypt the access token rather than storing it in plaintext', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await updateTokens({ id: 3, accessToken: 'at-plain', refreshToken: 'rt-plain' });
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('access_token_ciphertext = $2');
expect(params[0]).toBe(3);
expect(params[1]).not.toBe('at-plain');
expect(String(params[1]).split(':')).toHaveLength(3);
expect(params[2]).not.toBe('rt-plain');
});
it('should bind null for an absent refresh token and expiry', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await updateTokens({ id: 3, accessToken: 'at-plain' });
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(params[2]).toBeNull();
expect(params[3]).toBeNull();
});
it('should bind the supplied expiry date', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const expiresAt = new Date('2031-05-05T10:00:00.000Z');
await updateTokens({ id: 3, accessToken: 'at-plain', expiresAt });
const [, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(params[3]).toEqual(expiresAt);
});
});
describe('getAccessToken', () => {
it('should decrypt the stored ciphertext back to the original token', async () => {
mockQuery.mockResolvedValue({ rows: [{ value: encryptSecret('at-plain') }] });
const result = await getAccessToken(3);
expect(result).toBe('at-plain');
const [sql, params] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('SELECT access_token_ciphertext AS value');
expect(params).toEqual([3]);
});
it('should return null when no access token is stored', async () => {
mockQuery.mockResolvedValue({ rows: [{ value: null }] });
const result = await getAccessToken(3);
expect(result).toBeNull();
});
it('should return null when the integration does not exist', async () => {
mockQuery.mockResolvedValue({ rows: [] });
const result = await getAccessToken(404);
expect(result).toBeNull();
});
});
describe('getRefreshToken', () => {
it('should decrypt the refresh token column', async () => {
mockQuery.mockResolvedValue({ rows: [{ value: encryptSecret('rt-plain') }] });
const result = await getRefreshToken(3);
expect(result).toBe('rt-plain');
const [sql] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('SELECT refresh_token_ciphertext AS value');
});
});
describe('getSigningSecret', () => {
it('should decrypt the signing secret column', async () => {
mockQuery.mockResolvedValue({ rows: [{ value: encryptSecret('shh') }] });
const result = await getSigningSecret(3);
expect(result).toBe('shh');
const [sql] = mockQuery.mock.calls[0] as [string, unknown[]];
expect(sql).toContain('SELECT signing_secret_ciphertext AS value');
});
it('should return null when no signing secret is stored', async () => {
mockQuery.mockResolvedValue({ rows: [{ value: null }] });
const result = await getSigningSecret(3);
expect(result).toBeNull();
});
});
});