@friggframework/api-module-hubspot 2.0.0-next.3 → 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 +90 -1
- package/extensions/webhooks/handlers.js +105 -0
- package/extensions/webhooks/index.js +39 -0
- package/extensions/webhooks/lookup.js +48 -0
- package/extensions/webhooks/signature-verifier.js +181 -0
- package/index.js +5 -1
- package/package.json +3 -3
- package/tests/extensions/webhooks.test.js +590 -0
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.
|
|
@@ -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.
|
|
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.
|
|
23
|
+
"@friggframework/core": "^2.0.0-next.88"
|
|
24
24
|
},
|
|
25
|
-
"gitHead": "
|
|
25
|
+
"gitHead": "deffa87c388208ae48833b5d5bd716143bfc5953"
|
|
26
26
|
}
|
|
@@ -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
|
+
});
|