Infrastructure build to support third-party app integrations
This commit is contained in:
271
backend/tests/integrations.dao.test.ts
Normal file
271
backend/tests/integrations.dao.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user