Files
pig/apps/api/test/hubspot-foundation.test.ts
karti 74e37f3e76 Lay the HubSpot and customer-lifecycle foundation
Work in progress from the Codex session, committed so nothing sits undeployed.
Verified before committing: typecheck clean across all packages, 139 unit tests
and the e2e suite green, migrations apply to an empty Postgres.

Adds the HubSpot integration boundary (OAuth, client, contracts, webhook
signature verification, sync), a growth route, customer-lifecycle service,
Piggy lifecycle tools, a Growth page, and shared lifecycle/hubspot types.

Two things are deliberately incomplete and should not be mistaken for finished:

`packages/db/src/schema/hubspot.ts` is NOT exported from the schema index, so it
is inert — no tables, no migration. That is the correct order (the shape can
settle before it becomes a migration), but it does mean the HubSpot routes have
no persistence behind them yet.

`pnpm-workspace.yaml` and `pnpm-lock.yaml` are left uncommitted on purpose. The
workspace file contains a literal unanswered placeholder — "esbuild: set this
to true or false" — and this repository installs with npm, which is also what
CI runs. Committing a second package manager's lockfile would make the install
ambiguous. If the move to pnpm is intended it should be a deliberate change
that updates CI and the Dockerfile together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 03:50:18 -07:00

245 lines
9.5 KiB
TypeScript

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);
});
});