mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
fix(billing): read the payload that was actually sent
Deliveries arrived intact, verified correctly, and applied to nobody. Checking the signature here rather than through the SDK means the body is exactly what was sent, and what is sent is snake_case. The SDK's parser renames fields to camelCase on the way through, so the reader written against it looked for `externalId` in a payload that says `external_id` — found nothing, decided the delivery was about a customer we did not create, and acknowledged it. That is the worst shape a bug of this kind can take. Every visible signal was healthy: a 200 back to the provider, no retries, no errors, and a plan that silently never changed. Both spellings are now read, so neither path can regress the other.
This commit is contained in:
@@ -172,6 +172,36 @@ describe('Both signing schemes', () => {
|
||||
expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' });
|
||||
});
|
||||
|
||||
test('a raw snake_case payload is read, not just the SDK\u2019s camelCase', async () => {
|
||||
// Verifying the signature ourselves hands back exactly what was sent,
|
||||
// which is snake_case; the SDK's parser renames fields on the way
|
||||
// through. Reading one spelling made every delivery verify correctly and
|
||||
// then apply to nobody, which looks identical to working.
|
||||
const { Webhook } = await import('standardwebhooks');
|
||||
const secret = `whsec_${Buffer.from('b'.repeat(32)).toString('base64')}`;
|
||||
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||
|
||||
const body = JSON.stringify({
|
||||
type: 'subscription.revoked',
|
||||
data: { customer: { external_id: 'tem_snake' }, product_id: PAID }
|
||||
});
|
||||
const id = 'msg_snake';
|
||||
const timestamp = new Date();
|
||||
const signature = new Webhook(secret).sign(id, timestamp, body);
|
||||
|
||||
const delivery = Polar.receive({
|
||||
body,
|
||||
headers: {
|
||||
'webhook-id': id,
|
||||
'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(),
|
||||
'webhook-signature': signature
|
||||
}
|
||||
});
|
||||
|
||||
expect(delivery.teamId).toBe('tem_snake');
|
||||
expect(delivery.standing).toEqual({ plan: 'free', status: 'revoked' });
|
||||
});
|
||||
|
||||
test('a tampered body under a valid-looking signature is refused', () => {
|
||||
const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`;
|
||||
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||
|
||||
@@ -341,18 +341,27 @@ export namespace Polar {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Both spellings, for both paths. The SDK's parser renames fields to
|
||||
// camelCase on the way through; verifying the signature ourselves
|
||||
// hands back exactly what was sent, which is snake_case. Reading only
|
||||
// one spelling makes every delivery arrive intact, verify correctly,
|
||||
// and then quietly apply to nobody.
|
||||
const data = event.data ?? {};
|
||||
const customer = data.customer as { externalId?: string | null } | undefined;
|
||||
const customer = data.customer as
|
||||
| { externalId?: string | null; external_id?: string | null }
|
||||
| undefined;
|
||||
// `externalId` is the team id we put on the customer. A delivery
|
||||
// without one is about a customer created some other way — by hand in
|
||||
// their dashboard, most likely — and there is nothing here it can
|
||||
// change.
|
||||
const teamId = customer?.externalId ?? null;
|
||||
const teamId = customer?.externalId ?? customer?.external_id ?? null;
|
||||
|
||||
// Both spellings, because which one a payload carries depends on
|
||||
// whether the product was expanded into it.
|
||||
const product = data.product as { id?: string } | undefined;
|
||||
const productId = (data.productId as string | undefined) ?? product?.id ?? null;
|
||||
const productId =
|
||||
(data.productId as string | undefined) ??
|
||||
(data.product_id as string | undefined) ??
|
||||
product?.id ??
|
||||
null;
|
||||
|
||||
return { type: event.type, teamId, standing: standingFor(event.type, productId) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user