integrations: webhooks and API tokens
API tokens (Settings -> API tokens): Authorization: Bearer sp_<40 base62>, stored as sha256, shown once. A token acts as its admin with their current role and stops when they're disabled; Bearer requests skip the CSRF header and never fall back to the cookie. A token can't manage tokens. Webhooks (Settings -> Webhooks): call.started/answered/ended, voicemail.new, emergency.call, trunk/extension registration changes, from the live state; queued per webhook, sent every 10 s with X-SelfPhoneNG-Signature (HMAC of timestamp.body), retried after 1 m, 5 m, 30 m, 2 h, 12 h, then failed. Private/loopback targets are refused in the connection's own DNS lookup (no rebinding), unless allowed. Send test and a delivery log per webhook. The e2e found voicemail.new missing for mailboxes without a user: the voicemail scan now queues it for every mailbox. vmpin.test.ts gets 180 s: Git Bash forks make it ~85 s on Windows (the earlier flaky gate run). Task: M11-T5
This commit is contained in:
1 parent
d229ed2c45
commit
60846afddd
17 files changed
+1319
-8
No files matched your search
@@ -0,0 +1,249 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M11-T5: API tokens (04 §2.3) and webhooks (04 §9).
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { createServer, type IncomingHttpHeaders } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users, webhookDeliveries } from '../src/db/schema.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { deliverDue, isPrivateAddress, watchWebhooks } from '../src/services/webhooks.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
it('isPrivateAddress', () => {
|
||||
for (const ip of [
|
||||
'127.0.0.1',
|
||||
'10.1.2.3',
|
||||
'172.20.0.1',
|
||||
'192.168.1.1',
|
||||
'169.254.169.254',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'::',
|
||||
'fe80::1',
|
||||
'fd00::1',
|
||||
'::ffff:7f00:1',
|
||||
])
|
||||
expect(isPrivateAddress(ip), ip).toBe(true);
|
||||
for (const ip of ['8.8.8.8', '203.0.113.9', '2001:4860:4860::8888', '::ffff:8.8.8.8'])
|
||||
expect(isPrivateAddress(ip), ip).toBe(false);
|
||||
});
|
||||
|
||||
describe.skipIf(!hasTestDb)('API tokens and webhooks', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let admin: string;
|
||||
let req: (
|
||||
m: string,
|
||||
url: string,
|
||||
payload?: unknown,
|
||||
headers?: Record<string, string>,
|
||||
) => Promise<LightMyRequestResponse>;
|
||||
// The receiving end: answers with `answer`, keeps what it got.
|
||||
const got: { headers: IncomingHttpHeaders; body: string }[] = [];
|
||||
let answer = 200;
|
||||
const server = createServer((r, res) => {
|
||||
let body = '';
|
||||
r.on('data', (c) => (body += c));
|
||||
r.on('end', () => {
|
||||
got.push({ headers: r.headers, body });
|
||||
res.writeHead(answer).end();
|
||||
});
|
||||
});
|
||||
let url: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
admin = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, u, payload, headers = { ...H, cookie: admin }) =>
|
||||
t.app.inject({ method: method as 'GET', url: u, payload: payload as object, headers });
|
||||
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
|
||||
url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/hook`;
|
||||
});
|
||||
afterAll(async () => {
|
||||
server.close();
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it('a token is shown once and acts as its admin, without the CSRF header', async () => {
|
||||
const r = await req('POST', '/api/api-tokens', { name: 'CRM' });
|
||||
expect(r.statusCode).toBe(201);
|
||||
const { token, id } = r.json();
|
||||
expect(token).toMatch(/^sp_[A-Za-z0-9]{40}$/);
|
||||
const list = (await req('GET', '/api/api-tokens')).json().items;
|
||||
expect(list).toEqual([
|
||||
expect.objectContaining({
|
||||
id,
|
||||
name: 'CRM',
|
||||
tokenPrefix: token.slice(0, 7),
|
||||
createdBy: ADMIN.email,
|
||||
lastUsedAt: null,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(list)).not.toContain(token);
|
||||
|
||||
const bearer = { authorization: `Bearer ${token}` };
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(200);
|
||||
const made = await req('POST', '/api/blocked-numbers', { number: '3125550100' }, bearer);
|
||||
expect(made.statusCode).toBe(201);
|
||||
expect((await req('GET', '/api/api-tokens')).json().items[0].lastUsedAt).not.toBeNull();
|
||||
// A token can't manage tokens.
|
||||
expect((await req('POST', '/api/api-tokens', { name: 'more' }, bearer)).statusCode).toBe(403);
|
||||
expect((await req('GET', '/api/api-tokens', undefined, bearer)).statusCode).toBe(403);
|
||||
// Wrong, malformed or with a cookie that would work: the token decides.
|
||||
for (const a of [`Bearer sp_${'x'.repeat(40)}`, 'Bearer nope', `Bearer ${token}x`])
|
||||
expect(
|
||||
(await req('GET', '/api/extensions', undefined, { authorization: a, cookie: admin }))
|
||||
.statusCode,
|
||||
).toBe(401);
|
||||
|
||||
expect((await req('DELETE', `/api/api-tokens/${id}`)).statusCode).toBe(200);
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(401);
|
||||
expect((await req('GET', '/api/api-tokens')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
it("a token stops working when its admin is disabled, and is a user's when demoted", async () => {
|
||||
const other = await t.addUser('ops@example.com', 'ops-password-123', 'admin');
|
||||
const c = await t.login('ops@example.com', 'ops-password-123');
|
||||
const { token } = (
|
||||
await req('POST', '/api/api-tokens', { name: 'ops' }, { ...H, cookie: c })
|
||||
).json();
|
||||
const bearer = { authorization: `Bearer ${token}` };
|
||||
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, other.id));
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(403);
|
||||
await t.db.update(users).set({ isActive: false }).where(eq(users.id, other.id));
|
||||
expect((await req('GET', '/api/extensions', undefined, bearer)).statusCode).toBe(401);
|
||||
// Users can't make tokens.
|
||||
const u = await t.addUser('pat@example.com', 'pat-password-123');
|
||||
void u;
|
||||
const pc = await t.login('pat@example.com', 'pat-password-123');
|
||||
expect(
|
||||
(await req('POST', '/api/api-tokens', { name: 'x' }, { ...H, cookie: pc })).statusCode,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it('webhooks: validation, private targets refused by default, signed test', async () => {
|
||||
expect(
|
||||
(await req('POST', '/api/webhooks', { url: 'ftp://x.example/', events: ['call.ended'] }))
|
||||
.statusCode,
|
||||
).toBe(400);
|
||||
expect((await req('POST', '/api/webhooks', { url, events: [] })).statusCode).toBe(400);
|
||||
expect((await req('POST', '/api/webhooks', { url, events: ['nope'] })).statusCode).toBe(400);
|
||||
const h = (
|
||||
await req('POST', '/api/webhooks', { url, events: ['call.started', 'call.ended'] })
|
||||
).json();
|
||||
expect(h.secret).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
let r = (await req('POST', `/api/webhooks/${h.id}/test`)).json();
|
||||
expect(r).toMatchObject({ ok: false, error: expect.stringContaining('private address') });
|
||||
// A name that resolves to loopback is refused at connect time too.
|
||||
await req('PATCH', `/api/webhooks/${h.id}`, { url: url.replace('127.0.0.1', 'localhost') });
|
||||
r = (await req('POST', `/api/webhooks/${h.id}/test`)).json();
|
||||
expect(r).toMatchObject({ ok: false, error: expect.stringContaining('private address') });
|
||||
expect(got).toEqual([]);
|
||||
|
||||
await req('PATCH', `/api/webhooks/${h.id}`, { url });
|
||||
await setSetting(t.db, 'webhooks.allowPrivate', true);
|
||||
r = (await req('POST', `/api/webhooks/${h.id}/test`)).json();
|
||||
expect(r).toEqual({ ok: true, status: 200 });
|
||||
const [m] = got;
|
||||
expect(m!.headers['x-selfphoneng-event']).toBe('webhook.test');
|
||||
const ts = m!.headers['x-selfphoneng-timestamp'] as string;
|
||||
expect(m!.headers['x-selfphoneng-signature']).toBe(
|
||||
`sha256=${createHmac('sha256', h.secret).update(`${ts}.${m!.body}`).digest('hex')}`,
|
||||
);
|
||||
expect(JSON.parse(m!.body)).toMatchObject({
|
||||
event: 'webhook.test',
|
||||
data: { message: 'Test from SelfPhoneNG' },
|
||||
});
|
||||
});
|
||||
|
||||
it('live events are queued, delivered and retried 1m, 5m, 30m, 2h, 12h, then failed', async () => {
|
||||
got.length = 0;
|
||||
watchWebhooks(t.db, t.live, t.app.log);
|
||||
const call = {
|
||||
linkedid: 'L1',
|
||||
startedAt: new Date().toISOString(),
|
||||
direction: 'inbound' as const,
|
||||
from: { number: '3125550100' },
|
||||
to: { number: '201' },
|
||||
state: 'ringing' as const,
|
||||
extensions: ['201'],
|
||||
channels: [],
|
||||
recording: false,
|
||||
};
|
||||
t.live.emit('message', { type: 'call.updated', data: call });
|
||||
t.live.emit('message', {
|
||||
type: 'call.updated',
|
||||
data: { ...call, state: 'talking', answeredAt: call.startedAt },
|
||||
});
|
||||
t.live.emit('message', { type: 'call.removed', data: { linkedid: 'L1' } });
|
||||
await new Promise((r) => setTimeout(r, 200)); // the inserts are fire-and-forget
|
||||
await deliverDue(t.db);
|
||||
// call.answered isn't subscribed.
|
||||
expect(got.map((g) => JSON.parse(g.body).event)).toEqual(['call.started', 'call.ended']);
|
||||
expect(JSON.parse(got[1]!.body).data).toMatchObject({
|
||||
linkedid: 'L1',
|
||||
endedAt: expect.any(String),
|
||||
});
|
||||
|
||||
answer = 500;
|
||||
got.length = 0;
|
||||
t.live.emit('message', { type: 'call.updated', data: { ...call, linkedid: 'L2' } });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
let now = Date.now();
|
||||
const gaps: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await deliverDue(t.db, new Date(now));
|
||||
const [d] = await t.db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.event, 'call.started'))
|
||||
.orderBy(webhookDeliveries.id)
|
||||
.offset(1);
|
||||
if (d!.nextAttemptAt) {
|
||||
gaps.push(Math.round((d!.nextAttemptAt.getTime() - now) / 60_000));
|
||||
now = d!.nextAttemptAt.getTime();
|
||||
} else expect(d).toMatchObject({ status: 'failed', attempt: 6, responseCode: 500 });
|
||||
}
|
||||
expect(gaps).toEqual([1, 5, 30, 120, 720]);
|
||||
expect(got).toHaveLength(6);
|
||||
await deliverDue(t.db, new Date(now + 86_400_000));
|
||||
expect(got).toHaveLength(6);
|
||||
answer = 200;
|
||||
|
||||
const del = (
|
||||
await req(
|
||||
'GET',
|
||||
`/api/webhooks/${(await req('GET', '/api/webhooks')).json().items[0].id}/deliveries`,
|
||||
)
|
||||
).json().items;
|
||||
expect(del.map((d: { status: string }) => d.status)).toEqual(['failed', 'done', 'done']);
|
||||
});
|
||||
|
||||
it('voicemail.new fires for a mailbox without a user too', async () => {
|
||||
await req('POST', '/api/webhooks', { url, events: ['voicemail.new'] });
|
||||
await req('POST', '/api/extensions', { number: '205', name: 'Front desk' });
|
||||
const inbox = join(t.spoolDir, 'default', '205', 'INBOX');
|
||||
mkdirSync(inbox, { recursive: true });
|
||||
writeFileSync(join(inbox, 'msg0000.wav'), '');
|
||||
writeFileSync(
|
||||
join(inbox, 'msg0000.txt'),
|
||||
'[message]\ncallerid="Bo" <3125550111>\norigtime=1792300385\nmsg_id=1792300385-205\nduration=4\n',
|
||||
);
|
||||
await t.app.voicemail.scan('205');
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const [d] = await t.db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.event, 'voicemail.new'));
|
||||
expect(d!.payload).toMatchObject({
|
||||
event: 'voicemail.new',
|
||||
data: { extension: '205', callerNumber: '3125550111', durationS: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user