import { createHmac, randomBytes } from 'node:crypto'; import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { HUBSPOT_REQUIRED_SCOPES } from '../../../packages/core/src/hubspot'; import { HubSpotCrmClient, HUBSPOT_READ_PROPERTIES } from '../src/integrations/hubspot/client'; import { buildHubSpotAuthorizationUrl, HubSpotOAuthClient, HubSpotTokenManager, HubSpotTokenVault, type LockedHubSpotCredential, } from '../src/integrations/hubspot/oauth'; import { normalizeHubSpotSignatureUri, verifyHubSpotV3Signature, } from '../src/integrations/hubspot/signature'; import { HubSpotSyncService } from '../src/integrations/hubspot/sync'; import { createHubSpotWebhookRoutes } from '../src/routes/hubspot-webhook'; const tokenPayload = { access_token: 'access-token', refresh_token: 'refresh-token', expires_in: 1_800, hub_id: 12345, scopes: [...HUBSPOT_REQUIRED_SCOPES], }; describe('HubSpot OAuth decisions', () => { it('requests only the three read scopes and binds state plus redirect URI', () => { const value = buildHubSpotAuthorizationUrl({ clientId: 'client-id', redirectUri: 'https://pig.example/api/integrations/hubspot/oauth/callback', }, 'state-value'); const url = new URL(value); assert.equal(url.origin + url.pathname, 'https://app.hubspot.com/oauth/authorize'); assert.equal(url.searchParams.get('state'), 'state-value'); assert.equal(url.searchParams.get('redirect_uri'), 'https://pig.example/api/integrations/hubspot/oauth/callback'); assert.deepEqual(url.searchParams.get('scope')?.split(' '), [...HUBSPOT_REQUIRED_SCOPES]); assert.equal(HUBSPOT_REQUIRED_SCOPES.some((scope) => scope.endsWith('.write')), false); }); it('uses the official form-encoded v3 token exchange', async () => { let request: Request | undefined; const oauth = new HubSpotOAuthClient({ clientId: 'client-id', clientSecret: 'client-secret', redirectUri: 'https://pig.example/callback', }, async (input, init) => { request = new Request(input, init); return Response.json(tokenPayload); }); const tokens = await oauth.exchangeAuthorizationCode('authorization-code'); assert.equal(request?.url, 'https://api.hubapi.com/oauth/v3/token'); assert.equal(request?.method, 'POST'); assert.equal(request?.headers.get('content-type'), 'application/x-www-form-urlencoded'); const form = new URLSearchParams(await request?.text()); assert.equal(form.get('grant_type'), 'authorization_code'); assert.equal(form.get('code'), 'authorization-code'); assert.equal(tokens.portalId, '12345'); }); it('purpose-binds token envelopes to connection and token kind', () => { const vault = new HubSpotTokenVault(randomBytes(32).toString('base64')); const envelope = vault.encrypt('connection-a', 'access', 'secret-token'); assert.equal(vault.decrypt('connection-a', 'access', envelope), 'secret-token'); assert.throws(() => vault.decrypt('connection-a', 'refresh', envelope)); assert.throws(() => vault.decrypt('connection-b', 'access', envelope)); }); it('refreshes an expired token while the connection lock is held', async () => { const vault = new HubSpotTokenVault(randomBytes(32).toString('base64')); const events: string[] = []; const credential: LockedHubSpotCredential = { id: 'connection-a', status: 'active', encryptedAccessToken: vault.encrypt('connection-a', 'access', 'expired'), encryptedRefreshToken: vault.encrypt('connection-a', 'refresh', 'stored-refresh'), accessTokenExpiresAt: new Date('2026-01-01T00:00:00Z'), updateTokens: async (input) => { events.push('update'); assert.equal(vault.decrypt('connection-a', 'access', input.encryptedAccessToken), 'new-access'); }, }; const manager = new HubSpotTokenManager({ withConnectionLock: async (_id, operation) => { events.push('lock'); const result = await operation(credential); events.push('unlock'); return result; }, }, { refreshAccessToken: async (token) => { events.push('refresh'); assert.equal(token, 'stored-refresh'); return { ...tokenPayload, accessToken: 'new-access', refreshToken: 'new-refresh', expiresInSeconds: 1_800, portalId: '12345' }; }, }, vault, () => new Date('2026-01-01T01:00:00Z')); assert.equal(await manager.getAccessToken('connection-a'), 'new-access'); assert.deepEqual(events, ['lock', 'refresh', 'update', 'unlock']); }); }); describe('HubSpot v3 request verification', () => { it('uses the exact raw body and only HubSpot-approved query decoding', () => { const clientSecret = 'client-secret'; const method = 'POST'; const publicUri = 'https://pig.example/api/webhooks/hubspot?next=%2Fcrm%3Fid%3D1'; const normalized = 'https://pig.example/api/webhooks/hubspot?next=/crm?id%3D1'; const rawBody = '[{"eventId":1}]'; const timestamp = '1786453200000'; const signature = createHmac('sha256', clientSecret) .update(`${method}${normalized}${rawBody}${timestamp}`) .digest('base64'); assert.equal(normalizeHubSpotSignatureUri(publicUri), normalized); assert.deepEqual(verifyHubSpotV3Signature({ clientSecret, method, publicUri, rawBody, signature, timestamp, now: new Date(Number(timestamp)), }), { valid: true }); assert.equal(verifyHubSpotV3Signature({ clientSecret, method, publicUri, rawBody: `${rawBody} `, signature, timestamp, now: new Date(Number(timestamp)), }).valid, false); }); it('rejects timestamps outside the five-minute window', () => { assert.deepEqual(verifyHubSpotV3Signature({ clientSecret: 'secret', method: 'POST', publicUri: 'https://pig.example/api/webhooks/hubspot', rawBody: '[]', signature: 'not-used', timestamp: '1000', now: new Date(301_001), }), { valid: false, reason: 'stale_timestamp' }); }); }); describe('read-only CRM and resumable sync', () => { it('lists explicit official properties and follows the opaque after cursor', async () => { let request: Request | undefined; const client = new HubSpotCrmClient(async (input, init) => { request = new Request(input, init); return Response.json({ results: [], paging: { next: { after: 'next-page' } } }); }); const page = await client.listObjects('token', 'companies', { after: 'current-page' }); const url = new URL(request?.url ?? 'https://invalid'); assert.equal(request?.method, 'GET'); assert.equal(url.pathname, '/crm/objects/2026-03/companies'); assert.equal(url.searchParams.get('after'), 'current-page'); assert.equal(url.searchParams.get('properties'), HUBSPOT_READ_PROPERTIES.companies.join(',')); assert.equal(request?.headers.get('authorization'), 'Bearer token'); assert.equal(page.nextAfter, 'next-page'); }); it('commits records and the next cursor as one page decision', async () => { const commits: unknown[] = []; const service = new HubSpotSyncService({ getCursor: async () => ({ after: '17', phase: 'initial' }), commitPage: async (input) => { commits.push(input); }, }, { getAccessToken: async () => 'access-token', }, { listObjects: async (_token, type, options) => { assert.equal(type, 'contacts'); assert.equal(options?.after, '17'); return { results: [{ id: '42', properties: { email: 'person@example.com' }, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-02T00:00:00.000Z', archived: false, }], nextAfter: '18', }; }, }, () => new Date('2026-01-03T00:00:00.000Z')); const result = await service.syncNextPage('connection-a', 'contacts'); assert.equal(result.complete, false); assert.equal(result.nextAfter, '18'); assert.equal(commits.length, 1); assert.deepEqual( Object.assign({}, commits[0], { records: undefined, completedAt: undefined }), { connectionId: 'connection-a', objectType: 'contacts', phase: 'initial', expectedAfter: '17', nextAfter: '18', records: undefined, completedAt: undefined, }, ); }); }); describe('HubSpot webhook boundary', () => { it('verifies, bounds and durably hands off a batch before returning 204', async () => { const body = JSON.stringify([{ eventId: 1, subscriptionId: 2, portalId: 3, appId: 4, occurredAt: 1_786_453_200_000, objectId: 5, subscriptionType: 'contact.creation', attemptNumber: 0, }]); const timestamp = '1786453200000'; const publicUri = 'https://pig.example/api/webhooks/hubspot'; const signature = createHmac('sha256', 'client-secret') .update(`POST${publicUri}${body}${timestamp}`) .digest('base64'); let received = 0; const routes = createHubSpotWebhookRoutes({ clientSecret: 'client-secret', publicUri, appId: '4', now: () => new Date(Number(timestamp)), store: { enqueueVerifiedBatch: async ({ events }) => { received = events.length; } }, }); const response = await routes.request(publicUri, { method: 'POST', headers: { 'content-type': 'application/json', 'x-hubspot-signature-v3': signature, 'x-hubspot-request-timestamp': timestamp, }, body, }); assert.equal(response.status, 204); assert.equal(received, 1); }); });