@friggframework/api-module-hubspot 2.0.0-next.2 → 2.0.0-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,4 +2,93 @@
2
2
 
3
3
  This is the API Module for hubspot that allows the [Frigg](https://friggframework.org) code to talk to the hubspot API.
4
4
 
5
- Read more on the [Frigg documentation site](https://docs.friggframework.org/api-modules/list/hubspot
5
+ Read more on the [Frigg documentation site](https://docs.friggframework.org/api-modules/list/hubspot).
6
+
7
+ ## Webhooks (Integration Extension)
8
+
9
+ HubSpot webhooks are **app-level**, not account-level: one HubSpot app has a single
10
+ webhook target URL, and every connected portal (account) fires events to that same
11
+ URL. A Frigg app, by contrast, runs many per-portal integration records behind one
12
+ deployment. Bridging the two means every integration that wants HubSpot webhooks has
13
+ to write the same plumbing — an HTTP receiver, signature verification, a portal-ID
14
+ lookup to find the right integration record, and a hand-off to a queue worker.
15
+
16
+ This module ships that plumbing once as a **Tier 3 Integration Extension** —
17
+ `hubspot.extensions.webhooks` — so an integration just plugs it in and writes the
18
+ business logic. The extension is a reusable bundle of `{ routes, events }` that the
19
+ Frigg framework merges into the consuming integration's definition. See the framework
20
+ [EXTENSIONS.md](https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md)
21
+ for the full Tier 3 contract.
22
+
23
+ ### How the app-level / account-level split is handled
24
+
25
+ | Concern | Where it lives | Identity used |
26
+ |---|---|---|
27
+ | Signature verification | Extension receiver (`HUBSPOT_WEBHOOK_RECEIVED`) | App client secret (`HUBSPOT_CLIENT_SECRET`) |
28
+ | Portal → integration routing | `findIntegrationByEntityExternalId` via friggCommands | Inbound `portalId` from the payload |
29
+ | Per-account business logic | Your bound `HUBSPOT_WEBHOOK` handler | Per-portal OAuth credentials (loaded by the worker) |
30
+
31
+ At request time HubSpot POSTs the whole app's events to the receiver. The receiver
32
+ verifies the v3 signature, then resolves each event's `portalId` to the owning Frigg
33
+ integration and enqueues a per-event `HUBSPOT_WEBHOOK` job. The queue worker hydrates
34
+ that integration with its own per-portal credentials and runs your handler.
35
+
36
+ ### Enabling it on an integration
37
+
38
+ Bind the extension on your integration's `static Definition.extensions` and map the
39
+ `HUBSPOT_WEBHOOK` event to a method on your class:
40
+
41
+ ```javascript
42
+ const { IntegrationBase, createFriggCommands } = require('@friggframework/core');
43
+ const hubspot = require('@friggframework/api-module-hubspot');
44
+
45
+ class HubSpotIntegration extends IntegrationBase {
46
+ static Definition = {
47
+ name: 'hubspot',
48
+ modules: { hubspot: { definition: hubspot.Definition } },
49
+ extensions: {
50
+ hubspotWebhooks: {
51
+ extension: hubspot.extensions.webhooks,
52
+ handlers: { HUBSPOT_WEBHOOK: 'onHubSpotEvent' },
53
+ },
54
+ },
55
+ };
56
+
57
+ constructor(params) {
58
+ super(params);
59
+ // Required: the receiver resolves portalId → integration through commands.
60
+ this.commands = createFriggCommands({ integrationClass: HubSpotIntegration });
61
+ }
62
+
63
+ async onHubSpotEvent({ data }) {
64
+ // Pure business logic. Signature verification, portalId lookup, and
65
+ // queue dispatch are already done — `data.body` is the HubSpot event.
66
+ const { subscriptionType, objectId } = data.body;
67
+ if (subscriptionType === 'contact.creation') {
68
+ await this.syncContact(objectId);
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ The framework auto-mounts the receiver route at the integration's base path
75
+ (`POST /<integration-base>/webhooks`) — register that URL as the app's webhook target
76
+ in your HubSpot app settings.
77
+
78
+ ### Configuration
79
+
80
+ | Env var | Purpose |
81
+ |---|---|
82
+ | `HUBSPOT_CLIENT_SECRET` | App client secret used to verify the `X-HubSpot-Signature-V3` header. The receiver rejects with `401` if it is unset or the signature does not match. |
83
+
84
+ ### What the bundle contributes
85
+
86
+ - **Route:** `POST /webhooks` → `HUBSPOT_WEBHOOK_RECEIVED`
87
+ - **`HUBSPOT_WEBHOOK_RECEIVED`** — default receiver: verifies the signature, resolves
88
+ each event's `portalId`, and queues matched events. Events whose portal does not map
89
+ to any integration are skipped (HubSpot broadcasts every portal's events to the app).
90
+ Events are resolved and enqueued in parallel; if a `portalId` resolves ambiguously
91
+ (one external ID owned by multiple integrations) the whole batch is rejected rather
92
+ than risk cross-tenant routing.
93
+ - **`HUBSPOT_WEBHOOK`** — default no-op; override it via `binding.handlers.HUBSPOT_WEBHOOK`
94
+ to run your per-event logic.
package/definition.js CHANGED
@@ -18,7 +18,7 @@ const Definition = {
18
18
  getEntityDetails: async function (api, callbackParams, tokenResponse, userId) {
19
19
  const userDetails = await api.getUserDetails();
20
20
  return {
21
- identifiers: {externalId: userDetails.portalId, userId},
21
+ identifiers: {externalId: String(userDetails.portalId), userId},
22
22
  details: {name: userDetails.hub_domain},
23
23
  }
24
24
  },
@@ -31,7 +31,7 @@ const Definition = {
31
31
  getCredentialDetails: async function (api, userId) {
32
32
  const userDetails = await api.getUserDetails();
33
33
  return {
34
- identifiers: {externalId: userDetails.portalId, userId},
34
+ identifiers: {externalId: String(userDetails.portalId), userId},
35
35
  details: {}
36
36
  };
37
37
  },
@@ -0,0 +1,105 @@
1
+ const { verifyHubSpotSignature } = require('./signature-verifier');
2
+ const { findIntegrationByPortalId } = require('./lookup');
3
+
4
+ /**
5
+ * Receiver handler for `POST /webhooks`.
6
+ *
7
+ * Verifies HubSpot's v3 signature using `process.env.HUBSPOT_CLIENT_SECRET`
8
+ * (the same env var the api-module already reads for OAuth), iterates the
9
+ * inbound batch, resolves each event's `portalId` to a Frigg integration via
10
+ * the platform-neutral reverse lookup, and enqueues a per-event
11
+ * `HUBSPOT_WEBHOOK` job for the matched integration. Events whose portal
12
+ * does not map to any integration are silently skipped (HubSpot sends events
13
+ * for the whole app, not per-account).
14
+ *
15
+ * Per the Tier 3 contract, this function is bound as a plain function on the
16
+ * integration instance — `this` is the IntegrationBase instance and exposes
17
+ * `commands.findIntegrationByEntityExternalId` and `queueWebhook`.
18
+ *
19
+ * @this {import('@friggframework/core').IntegrationBase}
20
+ * @param {Object} args
21
+ * @param {import('express').Request} args.req
22
+ * @param {import('express').Response} args.res
23
+ * @returns {Promise<void>}
24
+ */
25
+ async function onHubSpotWebhookReceived({ req, res }) {
26
+ const verification = verifyHubSpotSignature({
27
+ req,
28
+ clientSecret: process.env.HUBSPOT_CLIENT_SECRET,
29
+ });
30
+ if (!verification.valid) {
31
+ console.warn(
32
+ `[hubspot-webhooks] rejecting webhook: ${verification.reason}`
33
+ );
34
+ res.status(401).json({ error: 'invalid signature' });
35
+ return;
36
+ }
37
+
38
+ const events = Array.isArray(req.body) ? req.body : [];
39
+
40
+ // Phase 1 — resolve every portalId in parallel before queueing anything.
41
+ // Two reasons for two-phase: (a) HubSpot batches can be large and each
42
+ // lookup is an independent DB round-trip; running them serially blows
43
+ // the HubSpot response budget; (b) if any lookup is ambiguous, the core
44
+ // command throws by design — we want that throw to land BEFORE any
45
+ // queueWebhook fires so we never leave the batch in a partial-enqueue
46
+ // state that HubSpot's retry would then duplicate.
47
+ const resolutions = await Promise.all(
48
+ events.map(async (evt, i) => {
49
+ const portalId = evt && evt.portalId;
50
+ if (portalId === undefined || portalId === null) {
51
+ console.warn(
52
+ `[hubspot-webhooks] event[${i}] missing portalId ` +
53
+ `(subscriptionType=${evt && evt.subscriptionType}); skipping`
54
+ );
55
+ return null;
56
+ }
57
+ const integrationId = await findIntegrationByPortalId(
58
+ this,
59
+ portalId
60
+ );
61
+ if (!integrationId) return null;
62
+ return { integrationId, evt };
63
+ })
64
+ );
65
+
66
+ // Phase 2 — enqueue matched events in parallel. Every match resolved
67
+ // cleanly above, so any failure here is genuinely SQS-side and should
68
+ // surface to HubSpot for retry.
69
+ const matches = resolutions.filter(Boolean);
70
+ await Promise.all(
71
+ matches.map(({ integrationId, evt }) =>
72
+ this.queueWebhook({
73
+ integrationId,
74
+ body: evt,
75
+ event: 'HUBSPOT_WEBHOOK',
76
+ })
77
+ )
78
+ );
79
+
80
+ res.status(200).json({
81
+ received: events.length,
82
+ queued: matches.length,
83
+ skipped: events.length - matches.length,
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Default per-event handler. Integration consumers override this via
89
+ * `binding.handlers.HUBSPOT_WEBHOOK = 'methodName'`; the default is a no-op
90
+ * so a misconfigured binding fails predictably rather than crashing the
91
+ * queue worker.
92
+ *
93
+ * @this {import('@friggframework/core').IntegrationBase}
94
+ * @param {Object} args
95
+ * @param {Object} args.data
96
+ * @returns {Promise<void>}
97
+ */
98
+ async function onHubSpotWebhook({ data }) {
99
+ // intentional no-op — override via binding.handlers.HUBSPOT_WEBHOOK
100
+ }
101
+
102
+ module.exports = {
103
+ onHubSpotWebhookReceived,
104
+ onHubSpotWebhook,
105
+ };
@@ -0,0 +1,39 @@
1
+ const {
2
+ onHubSpotWebhookReceived,
3
+ onHubSpotWebhook,
4
+ } = require('./handlers');
5
+
6
+ /**
7
+ * HubSpot Webhooks — Tier 3 Integration Extension bundle.
8
+ *
9
+ * Contributes a single receiver route (`POST /webhooks`) plus two events:
10
+ *
11
+ * HUBSPOT_WEBHOOK_RECEIVED — bound to the route; verifies signature,
12
+ * resolves portalId → integrationId, queues
13
+ * one HUBSPOT_WEBHOOK per matched event.
14
+ * HUBSPOT_WEBHOOK — default no-op; integrations override via
15
+ * `binding.handlers.HUBSPOT_WEBHOOK`.
16
+ *
17
+ * See the binding contract at:
18
+ * https://github.com/friggframework/frigg/blob/next/packages/core/integrations/EXTENSIONS.md
19
+ */
20
+ module.exports = {
21
+ name: 'hubspot-webhooks',
22
+ routes: [
23
+ {
24
+ path: '/webhooks',
25
+ method: 'POST',
26
+ event: 'HUBSPOT_WEBHOOK_RECEIVED',
27
+ },
28
+ ],
29
+ events: {
30
+ HUBSPOT_WEBHOOK_RECEIVED: {
31
+ type: 'LIFE_CYCLE_EVENT',
32
+ handler: onHubSpotWebhookReceived,
33
+ },
34
+ HUBSPOT_WEBHOOK: {
35
+ type: 'LIFE_CYCLE_EVENT',
36
+ handler: onHubSpotWebhook,
37
+ },
38
+ },
39
+ };
@@ -0,0 +1,48 @@
1
+ const { Definition } = require('../../definition');
2
+
3
+ /**
4
+ * HubSpot-vocabulary wrapper around the platform-neutral friggCommand
5
+ * `findIntegrationByEntityExternalId`. The command is the canonical access
6
+ * point for cross-cutting reverse lookups; this wrapper exists so the
7
+ * api-module's webhook code reads in HubSpot terms ("portalId") rather than
8
+ * the generic "externalId / moduleName" pair.
9
+ *
10
+ * Lives inside the api-module per the Tier 3 contract: "portalId" is HubSpot
11
+ * vocabulary and does not belong in core. The wrapper takes the integration
12
+ * instance explicitly so it can be called from extension default handlers
13
+ * (where `this` is the integration) or from worker code that holds a handle
14
+ * to an instance.
15
+ *
16
+ * The module name is sourced from `definition.js` so a rename of the api
17
+ * module flows through automatically.
18
+ *
19
+ * Intentionally does not catch ambiguous-resolution errors — those signal a
20
+ * cross-tenant routing risk that callers should not paper over.
21
+ *
22
+ * @param {Object} integration - An IntegrationBase instance with `commands`
23
+ * wired (see `createFriggCommands` in @friggframework/core).
24
+ * @param {string|number} portalId - The HubSpot portal/hub ID from the
25
+ * webhook event.
26
+ * @returns {Promise<string|null>} The Frigg integration id, or null if no
27
+ * matching integration exists for that portal.
28
+ */
29
+ async function findIntegrationByPortalId(integration, portalId) {
30
+ if (
31
+ !integration ||
32
+ !integration.commands ||
33
+ typeof integration.commands.findIntegrationByEntityExternalId !==
34
+ 'function'
35
+ ) {
36
+ throw new Error(
37
+ 'findIntegrationByPortalId: integration instance must expose commands.findIntegrationByEntityExternalId() — wire up createFriggCommands in your integration constructor.'
38
+ );
39
+ }
40
+ return integration.commands.findIntegrationByEntityExternalId(
41
+ portalId,
42
+ Definition.moduleName
43
+ );
44
+ }
45
+
46
+ module.exports = {
47
+ findIntegrationByPortalId,
48
+ };
@@ -0,0 +1,181 @@
1
+ const crypto = require('crypto');
2
+
3
+ const SIGNATURE_HEADER = 'x-hubspot-signature-v3';
4
+ const TIMESTAMP_HEADER = 'x-hubspot-request-timestamp';
5
+ // HubSpot's documented skew window: reject if older than 5 minutes.
6
+ const MAX_TIMESTAMP_SKEW_MS = 5 * 60 * 1000;
7
+
8
+ /**
9
+ * Read a header value case-insensitively from a request-like object.
10
+ * Express normalises header keys to lowercase, but extension authors may
11
+ * hand us raw objects in tests; tolerate both.
12
+ *
13
+ * @param {Object} headers - The headers map from the incoming request.
14
+ * @param {string} name - The header name (any case).
15
+ * @returns {string|undefined} The header value, or undefined.
16
+ */
17
+ function getHeader(headers, name) {
18
+ if (!headers || typeof headers !== 'object') return undefined;
19
+ if (headers[name] !== undefined) return headers[name];
20
+ const lower = name.toLowerCase();
21
+ if (headers[lower] !== undefined) return headers[lower];
22
+ for (const key of Object.keys(headers)) {
23
+ if (key.toLowerCase() === lower) return headers[key];
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * Reconstruct the request body string as it left HubSpot's servers.
30
+ * If middleware preserved the raw body bytes (req.rawBody), use those —
31
+ * any whitespace or key ordering tweak from JSON.parse → JSON.stringify
32
+ * will break the HMAC. Otherwise fall back to JSON.stringify(parsed body),
33
+ * which matches HubSpot's own Node.js sample.
34
+ *
35
+ * @param {import('express').Request|Object} req - The Express request.
36
+ * @returns {string} The body string to feed into the HMAC.
37
+ */
38
+ function extractBodyString(req) {
39
+ if (typeof req.rawBody === 'string') return req.rawBody;
40
+ if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString('utf8');
41
+ if (req.body === undefined || req.body === null) return '';
42
+ if (typeof req.body === 'string') return req.body;
43
+ if (Buffer.isBuffer(req.body)) return req.body.toString('utf8');
44
+ return JSON.stringify(req.body);
45
+ }
46
+
47
+ /**
48
+ * Reconstruct the full request URL HubSpot signed.
49
+ *
50
+ * HubSpot signs `https://<host><originalUrl>` — protocol, host, path, and
51
+ * query string. Behind a load balancer Express sees `http`, so we honour
52
+ * `X-Forwarded-Proto` and `X-Forwarded-Host` when present. Callers can
53
+ * also set `HUBSPOT_WEBHOOK_BASE_URL` to override entirely (useful for
54
+ * tunnels or custom domains).
55
+ *
56
+ * @param {import('express').Request|Object} req - The Express request.
57
+ * @returns {string} The fully-qualified URL string to feed into the HMAC.
58
+ */
59
+ function reconstructUrl(req) {
60
+ const override = process.env.HUBSPOT_WEBHOOK_BASE_URL;
61
+ if (override) {
62
+ const trimmed = override.replace(/\/$/, '');
63
+ return `${trimmed}${req.originalUrl || req.url || ''}`;
64
+ }
65
+ const forwardedProto = getHeader(req.headers, 'x-forwarded-proto');
66
+ const proto =
67
+ (forwardedProto && forwardedProto.split(',')[0].trim()) ||
68
+ req.protocol ||
69
+ 'https';
70
+ const forwardedHost = getHeader(req.headers, 'x-forwarded-host');
71
+ const host =
72
+ (forwardedHost && forwardedHost.split(',')[0].trim()) ||
73
+ getHeader(req.headers, 'host') ||
74
+ '';
75
+ const path = req.originalUrl || req.url || '';
76
+ return `${proto}://${host}${path}`;
77
+ }
78
+
79
+ /**
80
+ * Validate that two base64 signatures are equal in constant time.
81
+ *
82
+ * Decodes both into Buffers so timingSafeEqual sees same-length inputs.
83
+ * Returns false on any decode/length mismatch.
84
+ *
85
+ * @param {string} expected - The signature we computed.
86
+ * @param {string} provided - The signature HubSpot sent.
87
+ * @returns {boolean}
88
+ */
89
+ function safeCompare(expected, provided) {
90
+ if (typeof expected !== 'string' || typeof provided !== 'string') {
91
+ return false;
92
+ }
93
+ try {
94
+ const a = Buffer.from(expected, 'base64');
95
+ const b = Buffer.from(provided, 'base64');
96
+ if (a.length !== b.length) return false;
97
+ return crypto.timingSafeEqual(a, b);
98
+ } catch (_err) {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Verify a HubSpot v3 webhook signature against the configured client secret.
105
+ *
106
+ * Algorithm (per HubSpot docs):
107
+ * message = method + uri + body + timestamp
108
+ * signature = base64(HMAC-SHA256(client_secret, utf8(message)))
109
+ *
110
+ * Returns a structured result instead of throwing so the receiver can map
111
+ * different failures to the right log line and HTTP status. Never throws
112
+ * for normal "untrusted request" cases.
113
+ *
114
+ * @param {Object} args
115
+ * @param {import('express').Request|Object} args.req - The Express request.
116
+ * @param {string} args.clientSecret - The HubSpot app's client secret.
117
+ * @param {number} [args.maxSkewMs=300000] - Allowed timestamp skew in milliseconds.
118
+ * @param {Function} [args.now=Date.now] - Clock source for tests.
119
+ * @returns {{ valid: boolean, reason?: string }}
120
+ */
121
+ function verifyHubSpotSignature({
122
+ req,
123
+ clientSecret,
124
+ maxSkewMs = MAX_TIMESTAMP_SKEW_MS,
125
+ now = Date.now,
126
+ }) {
127
+ if (!clientSecret || typeof clientSecret !== 'string') {
128
+ return { valid: false, reason: 'missing client secret' };
129
+ }
130
+ if (!req || typeof req !== 'object') {
131
+ return { valid: false, reason: 'missing request' };
132
+ }
133
+
134
+ const headers = req.headers || {};
135
+ const signature = getHeader(headers, SIGNATURE_HEADER);
136
+ const timestamp = getHeader(headers, TIMESTAMP_HEADER);
137
+
138
+ if (!signature) {
139
+ return { valid: false, reason: `missing ${SIGNATURE_HEADER} header` };
140
+ }
141
+ if (!timestamp) {
142
+ return { valid: false, reason: `missing ${TIMESTAMP_HEADER} header` };
143
+ }
144
+
145
+ const tsNumber = Number(timestamp);
146
+ if (!Number.isFinite(tsNumber)) {
147
+ return { valid: false, reason: 'malformed timestamp header' };
148
+ }
149
+ const skew = Math.abs(now() - tsNumber);
150
+ if (skew > maxSkewMs) {
151
+ return {
152
+ valid: false,
153
+ reason: `timestamp skew ${skew}ms exceeds max ${maxSkewMs}ms`,
154
+ };
155
+ }
156
+
157
+ const method = (req.method || 'POST').toUpperCase();
158
+ const uri = reconstructUrl(req);
159
+ const body = extractBodyString(req);
160
+ const message = `${method}${uri}${body}${timestamp}`;
161
+
162
+ const expected = crypto
163
+ .createHmac('sha256', clientSecret)
164
+ .update(message, 'utf8')
165
+ .digest('base64');
166
+
167
+ if (!safeCompare(expected, signature)) {
168
+ return { valid: false, reason: 'signature mismatch' };
169
+ }
170
+ return { valid: true };
171
+ }
172
+
173
+ module.exports = {
174
+ verifyHubSpotSignature,
175
+ extractBodyString,
176
+ reconstructUrl,
177
+ safeCompare,
178
+ SIGNATURE_HEADER,
179
+ TIMESTAMP_HEADER,
180
+ MAX_TIMESTAMP_SKEW_MS,
181
+ };
package/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  const {Api} = require('./api');
2
2
  const {Definition} = require('./definition');
3
3
  const Config = require('./defaultConfig');
4
+ const webhooks = require('./extensions/webhooks');
4
5
 
5
6
  module.exports = {
6
7
  Api,
7
8
  Config,
8
- Definition
9
+ Definition,
10
+ extensions: {
11
+ webhooks,
12
+ },
9
13
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@friggframework/api-module-hubspot",
3
- "version": "2.0.0-next.2",
3
+ "version": "2.0.0-next.4",
4
4
  "prettier": "@friggframework/prettier-config",
5
5
  "description": "HubSpot API module that lets the Frigg Framework interact with HubSpot",
6
6
  "main": "index.js",
@@ -20,7 +20,7 @@
20
20
  "prettier": "^2.7.1"
21
21
  },
22
22
  "dependencies": {
23
- "@friggframework/core": "^2.0.0-next.16"
23
+ "@friggframework/core": "^2.0.0-next.88"
24
24
  },
25
- "gitHead": "6976db7ea821bb6e15e171d51c3e1938b6c566f9"
25
+ "gitHead": "deffa87c388208ae48833b5d5bd716143bfc5953"
26
26
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Definition unit tests — assert the shape of identifiers handed to
3
+ * Frigg core during the auth handshake. The Postgres schema in
4
+ * @friggframework/core ≥ 2.0.0-next types Credential.externalId as
5
+ * String?, so portalId (Int from HubSpot's /access-tokens response)
6
+ * must be stringified at the api-module boundary.
7
+ */
8
+
9
+ const { Definition } = require('../definition');
10
+
11
+ const baseUserDetails = {
12
+ portalId: 111111111, // HubSpot returns this as an integer
13
+ hub_domain: 'Testing Object Things-dev-44613847.com',
14
+ hub_id: 111111111,
15
+ };
16
+
17
+ function makeStubApi(userDetailsOverride = {}) {
18
+ return {
19
+ getUserDetails: async () =>
20
+ Object.assign({}, baseUserDetails, userDetailsOverride),
21
+ };
22
+ }
23
+
24
+ describe('HubSpot Definition externalId coercion', () => {
25
+ describe('getEntityDetails()', () => {
26
+ it('returns externalId as a string, not an integer', async () => {
27
+ const api = makeStubApi();
28
+ const result =
29
+ await Definition.requiredAuthMethods.getEntityDetails(
30
+ api,
31
+ null,
32
+ null,
33
+ 42
34
+ );
35
+
36
+ expect(typeof result.identifiers.externalId).toBe('string');
37
+ expect(result.identifiers.externalId).toBe('111111111');
38
+ });
39
+
40
+ it('stringifies a very large portalId without losing precision', async () => {
41
+ const api = makeStubApi({ portalId: 9999999999 });
42
+ const result =
43
+ await Definition.requiredAuthMethods.getEntityDetails(
44
+ api,
45
+ null,
46
+ null,
47
+ 42
48
+ );
49
+
50
+ expect(result.identifiers.externalId).toBe('9999999999');
51
+ });
52
+ });
53
+
54
+ describe('getCredentialDetails()', () => {
55
+ it('returns externalId as a string, not an integer', async () => {
56
+ const api = makeStubApi();
57
+ const result =
58
+ await Definition.requiredAuthMethods.getCredentialDetails(
59
+ api,
60
+ 42
61
+ );
62
+
63
+ expect(typeof result.identifiers.externalId).toBe('string');
64
+ expect(result.identifiers.externalId).toBe('111111111');
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,590 @@
1
+ const crypto = require('crypto');
2
+ const webhooksExtension = require('../../extensions/webhooks');
3
+ const {
4
+ verifyHubSpotSignature,
5
+ reconstructUrl,
6
+ extractBodyString,
7
+ safeCompare,
8
+ } = require('../../extensions/webhooks/signature-verifier');
9
+ const {
10
+ findIntegrationByPortalId,
11
+ } = require('../../extensions/webhooks/lookup');
12
+ const {
13
+ onHubSpotWebhookReceived,
14
+ onHubSpotWebhook,
15
+ } = require('../../extensions/webhooks/handlers');
16
+
17
+ const CLIENT_SECRET = 'test-client-secret';
18
+
19
+ const buildSignedRequest = ({
20
+ method = 'POST',
21
+ url = '/api/hubspot-integration/webhooks',
22
+ host = 'app.example.com',
23
+ proto = 'https',
24
+ body = [{ portalId: 111, subscriptionType: 'contact.creation', objectId: 1 }],
25
+ timestamp = Date.now(),
26
+ clientSecret = CLIENT_SECRET,
27
+ extraHeaders = {},
28
+ overrideSignature,
29
+ overrideTimestamp,
30
+ rawBody,
31
+ } = {}) => {
32
+ const bodyString = rawBody !== undefined
33
+ ? rawBody
34
+ : (typeof body === 'string' ? body : JSON.stringify(body));
35
+ const fullUrl = `${proto}://${host}${url}`;
36
+ const message = `${method}${fullUrl}${bodyString}${timestamp}`;
37
+ const signature = crypto
38
+ .createHmac('sha256', clientSecret)
39
+ .update(message, 'utf8')
40
+ .digest('base64');
41
+
42
+ return {
43
+ method,
44
+ url,
45
+ originalUrl: url,
46
+ protocol: proto,
47
+ body: typeof body === 'string' ? body : body,
48
+ rawBody: rawBody,
49
+ headers: {
50
+ host,
51
+ 'x-hubspot-signature-v3': overrideSignature ?? signature,
52
+ 'x-hubspot-request-timestamp':
53
+ overrideTimestamp ?? String(timestamp),
54
+ ...extraHeaders,
55
+ },
56
+ };
57
+ };
58
+
59
+ const makeRes = () => {
60
+ const res = {};
61
+ res.status = jest.fn((code) => {
62
+ res.statusCode = code;
63
+ return res;
64
+ });
65
+ res.json = jest.fn((payload) => {
66
+ res.body = payload;
67
+ return res;
68
+ });
69
+ return res;
70
+ };
71
+
72
+ describe('hubspot-webhooks extension bundle shape', () => {
73
+ it('conforms to the Tier 3 contract', () => {
74
+ expect(webhooksExtension).toEqual(
75
+ expect.objectContaining({
76
+ name: 'hubspot-webhooks',
77
+ routes: expect.any(Array),
78
+ events: expect.any(Object),
79
+ })
80
+ );
81
+ expect(webhooksExtension.routes).toHaveLength(1);
82
+ expect(webhooksExtension.routes[0]).toEqual({
83
+ path: '/webhooks',
84
+ method: 'POST',
85
+ event: 'HUBSPOT_WEBHOOK_RECEIVED',
86
+ });
87
+ });
88
+
89
+ it('every route event references a declared event', () => {
90
+ for (const route of webhooksExtension.routes) {
91
+ expect(webhooksExtension.events).toHaveProperty(route.event);
92
+ }
93
+ });
94
+
95
+ it('declares both HUBSPOT_WEBHOOK_RECEIVED and HUBSPOT_WEBHOOK with function handlers', () => {
96
+ expect(typeof webhooksExtension.events.HUBSPOT_WEBHOOK_RECEIVED.handler).toBe(
97
+ 'function'
98
+ );
99
+ expect(typeof webhooksExtension.events.HUBSPOT_WEBHOOK.handler).toBe(
100
+ 'function'
101
+ );
102
+ });
103
+ });
104
+
105
+ describe('verifyHubSpotSignature', () => {
106
+ it('returns valid:true for a correctly-signed request', () => {
107
+ const req = buildSignedRequest();
108
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
109
+ expect(result.valid).toBe(true);
110
+ });
111
+
112
+ it('returns valid:false on signature mismatch', () => {
113
+ const req = buildSignedRequest({ overrideSignature: 'AAAA' });
114
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
115
+ expect(result.valid).toBe(false);
116
+ expect(result.reason).toMatch(/mismatch/);
117
+ });
118
+
119
+ it('returns valid:false when the signature header is missing', () => {
120
+ const req = buildSignedRequest();
121
+ delete req.headers['x-hubspot-signature-v3'];
122
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
123
+ expect(result.valid).toBe(false);
124
+ expect(result.reason).toMatch(/x-hubspot-signature-v3/);
125
+ });
126
+
127
+ it('returns valid:false when the timestamp header is missing', () => {
128
+ const req = buildSignedRequest();
129
+ delete req.headers['x-hubspot-request-timestamp'];
130
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
131
+ expect(result.valid).toBe(false);
132
+ expect(result.reason).toMatch(/timestamp/);
133
+ });
134
+
135
+ it('rejects timestamps older than the skew window', () => {
136
+ const stale = Date.now() - 10 * 60 * 1000;
137
+ const req = buildSignedRequest({ timestamp: stale });
138
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
139
+ expect(result.valid).toBe(false);
140
+ expect(result.reason).toMatch(/timestamp skew/);
141
+ });
142
+
143
+ it('rejects timestamps too far in the future', () => {
144
+ const future = Date.now() + 10 * 60 * 1000;
145
+ const req = buildSignedRequest({ timestamp: future });
146
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
147
+ expect(result.valid).toBe(false);
148
+ expect(result.reason).toMatch(/timestamp skew/);
149
+ });
150
+
151
+ it('rejects non-numeric timestamp headers', () => {
152
+ const req = buildSignedRequest({ overrideTimestamp: 'not-a-number' });
153
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
154
+ expect(result.valid).toBe(false);
155
+ expect(result.reason).toMatch(/malformed/);
156
+ });
157
+
158
+ it('returns valid:false when client secret is missing', () => {
159
+ const req = buildSignedRequest();
160
+ const result = verifyHubSpotSignature({ req, clientSecret: undefined });
161
+ expect(result.valid).toBe(false);
162
+ expect(result.reason).toMatch(/client secret/);
163
+ });
164
+
165
+ it('honours X-Forwarded-Proto/Host headers when present', () => {
166
+ const timestamp = Date.now();
167
+ const body = [{ portalId: 1 }];
168
+ const bodyString = JSON.stringify(body);
169
+ const fullUrl = `https://public.example.com/api/hubspot-integration/webhooks`;
170
+ const signature = crypto
171
+ .createHmac('sha256', CLIENT_SECRET)
172
+ .update(`POST${fullUrl}${bodyString}${timestamp}`, 'utf8')
173
+ .digest('base64');
174
+
175
+ const req = {
176
+ method: 'POST',
177
+ url: '/api/hubspot-integration/webhooks',
178
+ originalUrl: '/api/hubspot-integration/webhooks',
179
+ protocol: 'http',
180
+ body,
181
+ headers: {
182
+ host: 'internal-lb.local',
183
+ 'x-forwarded-proto': 'https',
184
+ 'x-forwarded-host': 'public.example.com',
185
+ 'x-hubspot-signature-v3': signature,
186
+ 'x-hubspot-request-timestamp': String(timestamp),
187
+ },
188
+ };
189
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
190
+ expect(result.valid).toBe(true);
191
+ });
192
+
193
+ it('uses HUBSPOT_WEBHOOK_BASE_URL override when set', () => {
194
+ const previous = process.env.HUBSPOT_WEBHOOK_BASE_URL;
195
+ process.env.HUBSPOT_WEBHOOK_BASE_URL = 'https://override.example.com';
196
+
197
+ const timestamp = Date.now();
198
+ const body = [{ portalId: 1 }];
199
+ const bodyString = JSON.stringify(body);
200
+ const fullUrl = `https://override.example.com/api/hubspot-integration/webhooks`;
201
+ const signature = crypto
202
+ .createHmac('sha256', CLIENT_SECRET)
203
+ .update(`POST${fullUrl}${bodyString}${timestamp}`, 'utf8')
204
+ .digest('base64');
205
+
206
+ const req = {
207
+ method: 'POST',
208
+ originalUrl: '/api/hubspot-integration/webhooks',
209
+ protocol: 'http',
210
+ body,
211
+ headers: {
212
+ host: 'something-else.local',
213
+ 'x-hubspot-signature-v3': signature,
214
+ 'x-hubspot-request-timestamp': String(timestamp),
215
+ },
216
+ };
217
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
218
+ expect(result.valid).toBe(true);
219
+
220
+ if (previous === undefined) {
221
+ delete process.env.HUBSPOT_WEBHOOK_BASE_URL;
222
+ } else {
223
+ process.env.HUBSPOT_WEBHOOK_BASE_URL = previous;
224
+ }
225
+ });
226
+
227
+ it('uses req.rawBody when present so JSON re-serialization cannot break the HMAC', () => {
228
+ const timestamp = Date.now();
229
+ const rawBody = '[{"portalId":1,"foo": "bar" }]'; // intentional weird spacing
230
+ const fullUrl = `https://app.example.com/api/hubspot-integration/webhooks`;
231
+ const signature = crypto
232
+ .createHmac('sha256', CLIENT_SECRET)
233
+ .update(`POST${fullUrl}${rawBody}${timestamp}`, 'utf8')
234
+ .digest('base64');
235
+
236
+ const req = {
237
+ method: 'POST',
238
+ originalUrl: '/api/hubspot-integration/webhooks',
239
+ protocol: 'https',
240
+ rawBody,
241
+ body: JSON.parse(rawBody),
242
+ headers: {
243
+ host: 'app.example.com',
244
+ 'x-hubspot-signature-v3': signature,
245
+ 'x-hubspot-request-timestamp': String(timestamp),
246
+ },
247
+ };
248
+ const result = verifyHubSpotSignature({ req, clientSecret: CLIENT_SECRET });
249
+ expect(result.valid).toBe(true);
250
+ });
251
+ });
252
+
253
+ describe('signature-verifier helpers', () => {
254
+ it('safeCompare returns false on length mismatch', () => {
255
+ // 4 chars (3 bytes) vs 8 chars (6 bytes) — clearly different decoded lengths
256
+ expect(safeCompare('AAAA', 'AAAAAAAA')).toBe(false);
257
+ });
258
+
259
+ it('safeCompare returns true on identical inputs', () => {
260
+ expect(safeCompare('hello', 'hello')).toBe(true);
261
+ });
262
+
263
+ it('safeCompare returns false on non-string inputs', () => {
264
+ expect(safeCompare(null, 'abc')).toBe(false);
265
+ expect(safeCompare('abc', undefined)).toBe(false);
266
+ });
267
+
268
+ it('extractBodyString returns empty string for null body', () => {
269
+ expect(extractBodyString({ body: null })).toBe('');
270
+ });
271
+
272
+ it('extractBodyString prefers rawBody string', () => {
273
+ expect(extractBodyString({ rawBody: 'raw', body: { a: 1 } })).toBe('raw');
274
+ });
275
+
276
+ it('extractBodyString prefers rawBody buffer', () => {
277
+ expect(
278
+ extractBodyString({ rawBody: Buffer.from('hello'), body: {} })
279
+ ).toBe('hello');
280
+ });
281
+
282
+ it('extractBodyString JSON-stringifies object bodies', () => {
283
+ expect(extractBodyString({ body: { a: 1 } })).toBe('{"a":1}');
284
+ });
285
+
286
+ it('reconstructUrl falls back to req.protocol + host when no forwarded headers', () => {
287
+ const req = {
288
+ protocol: 'https',
289
+ originalUrl: '/abc',
290
+ headers: { host: 'foo.local' },
291
+ };
292
+ expect(reconstructUrl(req)).toBe('https://foo.local/abc');
293
+ });
294
+ });
295
+
296
+ describe('findIntegrationByPortalId wrapper', () => {
297
+ it('delegates to commands.findIntegrationByEntityExternalId with the module name from definition.js', async () => {
298
+ const integration = {
299
+ commands: {
300
+ findIntegrationByEntityExternalId: jest
301
+ .fn()
302
+ .mockResolvedValue('integration-abc'),
303
+ },
304
+ };
305
+ const result = await findIntegrationByPortalId(integration, 42);
306
+ expect(
307
+ integration.commands.findIntegrationByEntityExternalId
308
+ ).toHaveBeenCalledWith(42, 'hubspot');
309
+ expect(result).toBe('integration-abc');
310
+ });
311
+
312
+ it('throws when the integration does not expose commands.findIntegrationByEntityExternalId', async () => {
313
+ await expect(findIntegrationByPortalId({}, 42)).rejects.toThrow(
314
+ /commands\.findIntegrationByEntityExternalId/
315
+ );
316
+ await expect(
317
+ findIntegrationByPortalId({ commands: {} }, 42)
318
+ ).rejects.toThrow(/commands\.findIntegrationByEntityExternalId/);
319
+ });
320
+
321
+ it('propagates ambiguous-resolution errors instead of swallowing them', async () => {
322
+ const ambiguous = new Error(
323
+ 'ambiguous resolution — externalId=42 matched 2 entities'
324
+ );
325
+ const integration = {
326
+ commands: {
327
+ findIntegrationByEntityExternalId: jest
328
+ .fn()
329
+ .mockRejectedValue(ambiguous),
330
+ },
331
+ };
332
+ await expect(
333
+ findIntegrationByPortalId(integration, 42)
334
+ ).rejects.toThrow(/ambiguous/);
335
+ });
336
+ });
337
+
338
+ describe('onHubSpotWebhookReceived (default receiver handler)', () => {
339
+ let previousClientSecret;
340
+
341
+ beforeAll(() => {
342
+ previousClientSecret = process.env.HUBSPOT_CLIENT_SECRET;
343
+ process.env.HUBSPOT_CLIENT_SECRET = CLIENT_SECRET;
344
+ });
345
+
346
+ afterAll(() => {
347
+ if (previousClientSecret === undefined) {
348
+ delete process.env.HUBSPOT_CLIENT_SECRET;
349
+ } else {
350
+ process.env.HUBSPOT_CLIENT_SECRET = previousClientSecret;
351
+ }
352
+ });
353
+
354
+ const makeIntegration = (overrides = {}) => {
355
+ const commandsOverride = overrides.commands;
356
+ delete overrides.commands;
357
+ const integration = {
358
+ commands: {
359
+ findIntegrationByEntityExternalId: jest.fn(async (portalId) => {
360
+ if (portalId === 999) return null; // simulate unknown portal
361
+ return `integration-for-portal-${portalId}`;
362
+ }),
363
+ ...commandsOverride,
364
+ },
365
+ queueWebhook: jest.fn().mockResolvedValue(undefined),
366
+ ...overrides,
367
+ };
368
+ return integration;
369
+ };
370
+
371
+ it('returns 401 on invalid signature', async () => {
372
+ const integration = makeIntegration();
373
+ const req = buildSignedRequest({ overrideSignature: 'AAAA' });
374
+ const res = makeRes();
375
+ await onHubSpotWebhookReceived.call(integration, { req, res });
376
+ expect(res.statusCode).toBe(401);
377
+ expect(integration.queueWebhook).not.toHaveBeenCalled();
378
+ });
379
+
380
+ it('returns 401 on missing signature header', async () => {
381
+ const integration = makeIntegration();
382
+ const req = buildSignedRequest();
383
+ delete req.headers['x-hubspot-signature-v3'];
384
+ const res = makeRes();
385
+ await onHubSpotWebhookReceived.call(integration, { req, res });
386
+ expect(res.statusCode).toBe(401);
387
+ });
388
+
389
+ it('iterates events, queues each matched event, and returns 200 with counts', async () => {
390
+ const integration = makeIntegration();
391
+ const body = [
392
+ { portalId: 111, subscriptionType: 'contact.creation', objectId: 1 },
393
+ { portalId: 222, subscriptionType: 'deal.creation', objectId: 2 },
394
+ ];
395
+ const req = buildSignedRequest({ body });
396
+ const res = makeRes();
397
+
398
+ await onHubSpotWebhookReceived.call(integration, { req, res });
399
+
400
+ expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledTimes(
401
+ 2
402
+ );
403
+ expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledWith(
404
+ 111,
405
+ 'hubspot'
406
+ );
407
+ expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledWith(
408
+ 222,
409
+ 'hubspot'
410
+ );
411
+ expect(integration.queueWebhook).toHaveBeenCalledTimes(2);
412
+ expect(integration.queueWebhook).toHaveBeenCalledWith({
413
+ integrationId: 'integration-for-portal-111',
414
+ body: body[0],
415
+ event: 'HUBSPOT_WEBHOOK',
416
+ });
417
+ expect(integration.queueWebhook).toHaveBeenCalledWith({
418
+ integrationId: 'integration-for-portal-222',
419
+ body: body[1],
420
+ event: 'HUBSPOT_WEBHOOK',
421
+ });
422
+ expect(res.statusCode).toBe(200);
423
+ expect(res.body).toEqual({ received: 2, queued: 2, skipped: 0 });
424
+ });
425
+
426
+ it('skips events whose portalId does not resolve to an integration', async () => {
427
+ const integration = makeIntegration();
428
+ const body = [
429
+ { portalId: 111, subscriptionType: 'contact.creation', objectId: 1 },
430
+ { portalId: 999, subscriptionType: 'contact.creation', objectId: 2 }, // null lookup
431
+ ];
432
+ const req = buildSignedRequest({ body });
433
+ const res = makeRes();
434
+
435
+ await onHubSpotWebhookReceived.call(integration, { req, res });
436
+
437
+ expect(integration.queueWebhook).toHaveBeenCalledTimes(1);
438
+ expect(integration.queueWebhook).toHaveBeenCalledWith({
439
+ integrationId: 'integration-for-portal-111',
440
+ body: body[0],
441
+ event: 'HUBSPOT_WEBHOOK',
442
+ });
443
+ expect(res.statusCode).toBe(200);
444
+ expect(res.body).toEqual({ received: 2, queued: 1, skipped: 1 });
445
+ });
446
+
447
+ it('skips events missing a portalId entirely', async () => {
448
+ const integration = makeIntegration();
449
+ const body = [
450
+ { subscriptionType: 'contact.creation', objectId: 1 }, // no portalId
451
+ { portalId: 222, subscriptionType: 'deal.creation', objectId: 2 },
452
+ ];
453
+ const req = buildSignedRequest({ body });
454
+ const res = makeRes();
455
+
456
+ await onHubSpotWebhookReceived.call(integration, { req, res });
457
+
458
+ expect(integration.commands.findIntegrationByEntityExternalId).toHaveBeenCalledTimes(
459
+ 1
460
+ );
461
+ expect(integration.queueWebhook).toHaveBeenCalledTimes(1);
462
+ expect(res.statusCode).toBe(200);
463
+ expect(res.body).toEqual({ received: 2, queued: 1, skipped: 1 });
464
+ });
465
+
466
+ it('returns 200 with zero counts on an empty event array', async () => {
467
+ const integration = makeIntegration();
468
+ const req = buildSignedRequest({ body: [] });
469
+ const res = makeRes();
470
+ await onHubSpotWebhookReceived.call(integration, { req, res });
471
+ expect(res.statusCode).toBe(200);
472
+ expect(res.body).toEqual({ received: 0, queued: 0, skipped: 0 });
473
+ expect(integration.queueWebhook).not.toHaveBeenCalled();
474
+ });
475
+
476
+ it('propagates ambiguous-resolution errors instead of catching them', async () => {
477
+ const integration = makeIntegration({
478
+ commands: {
479
+ findIntegrationByEntityExternalId: jest
480
+ .fn()
481
+ .mockRejectedValue(new Error('ambiguous resolution')),
482
+ },
483
+ });
484
+ const req = buildSignedRequest({ body: [{ portalId: 111 }] });
485
+ const res = makeRes();
486
+ await expect(
487
+ onHubSpotWebhookReceived.call(integration, { req, res })
488
+ ).rejects.toThrow(/ambiguous/);
489
+ expect(integration.queueWebhook).not.toHaveBeenCalled();
490
+ });
491
+
492
+ it('queues zero events when any one resolution is ambiguous (all-or-nothing)', async () => {
493
+ // Multi-event batch where event[1] resolves ambiguous. With the
494
+ // two-phase implementation, NO queueWebhook calls should fire for
495
+ // any event — including event[0] which would have resolved cleanly.
496
+ // Prevents partial-enqueue + HubSpot-retry-duplication.
497
+ const integration = makeIntegration({
498
+ commands: {
499
+ findIntegrationByEntityExternalId: jest.fn(async (portalId) => {
500
+ if (portalId === 222)
501
+ throw new Error('ambiguous resolution');
502
+ return `integration-for-portal-${portalId}`;
503
+ }),
504
+ },
505
+ });
506
+ const req = buildSignedRequest({
507
+ body: [
508
+ { portalId: 111, subscriptionType: 'contact.creation' },
509
+ { portalId: 222, subscriptionType: 'contact.creation' },
510
+ { portalId: 333, subscriptionType: 'contact.creation' },
511
+ ],
512
+ });
513
+ const res = makeRes();
514
+ await expect(
515
+ onHubSpotWebhookReceived.call(integration, { req, res })
516
+ ).rejects.toThrow(/ambiguous/);
517
+ expect(integration.queueWebhook).not.toHaveBeenCalled();
518
+ });
519
+
520
+ it('resolves and queues events in parallel rather than serially', async () => {
521
+ // Each lookup takes a tick; if dispatch were sequential, the second
522
+ // lookup would only start after the first lookup AND the first
523
+ // queueWebhook had resolved. We assert all three lookups have begun
524
+ // before any of them complete.
525
+ const inFlightLookups = jest.fn();
526
+ const inFlightQueueWrites = jest.fn();
527
+ let outstandingLookups = 0;
528
+ let outstandingQueueWrites = 0;
529
+ const integration = makeIntegration({
530
+ commands: {
531
+ findIntegrationByEntityExternalId: jest.fn(async (portalId) => {
532
+ outstandingLookups += 1;
533
+ inFlightLookups(outstandingLookups);
534
+ await new Promise((resolve) => setImmediate(resolve));
535
+ outstandingLookups -= 1;
536
+ return `integration-for-portal-${portalId}`;
537
+ }),
538
+ },
539
+ queueWebhook: jest.fn(async () => {
540
+ outstandingQueueWrites += 1;
541
+ inFlightQueueWrites(outstandingQueueWrites);
542
+ await new Promise((resolve) => setImmediate(resolve));
543
+ outstandingQueueWrites -= 1;
544
+ }),
545
+ });
546
+ const req = buildSignedRequest({
547
+ body: [
548
+ { portalId: 111, subscriptionType: 'contact.creation' },
549
+ { portalId: 222, subscriptionType: 'contact.creation' },
550
+ { portalId: 333, subscriptionType: 'contact.creation' },
551
+ ],
552
+ });
553
+ const res = makeRes();
554
+ await onHubSpotWebhookReceived.call(integration, { req, res });
555
+
556
+ // At some point all three lookups (and all three queue writes)
557
+ // should have been in flight simultaneously — that's what
558
+ // distinguishes parallel from serial dispatch.
559
+ expect(Math.max(...inFlightLookups.mock.calls.map((c) => c[0]))).toBe(
560
+ 3
561
+ );
562
+ expect(
563
+ Math.max(...inFlightQueueWrites.mock.calls.map((c) => c[0]))
564
+ ).toBe(3);
565
+ expect(res.body).toEqual({ received: 3, queued: 3, skipped: 0 });
566
+ });
567
+
568
+ it('rejects with 401 when HUBSPOT_CLIENT_SECRET is not set', async () => {
569
+ const saved = process.env.HUBSPOT_CLIENT_SECRET;
570
+ delete process.env.HUBSPOT_CLIENT_SECRET;
571
+ try {
572
+ const integration = makeIntegration();
573
+ const req = buildSignedRequest();
574
+ const res = makeRes();
575
+ await onHubSpotWebhookReceived.call(integration, { req, res });
576
+ expect(res.statusCode).toBe(401);
577
+ expect(integration.queueWebhook).not.toHaveBeenCalled();
578
+ } finally {
579
+ process.env.HUBSPOT_CLIENT_SECRET = saved;
580
+ }
581
+ });
582
+ });
583
+
584
+ describe('onHubSpotWebhook (default per-event handler)', () => {
585
+ it('is a no-op that does not throw', async () => {
586
+ await expect(
587
+ onHubSpotWebhook.call({}, { data: { body: { portalId: 1 } } })
588
+ ).resolves.toBeUndefined();
589
+ });
590
+ });