integrations: webhooks and API tokens
ci / check (push) Successful in 54s
ci / images (push) Successful in 21s

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:
iamdoubz committed 2026-10-19 19:40:47 -05:00
1 parent d229ed2c45
commit 60846afddd
17 files changed
+1319 -8

No files matched your search

+5 -2
View File
@@ -36,6 +36,7 @@ import { blockedRoutes } from './routes/blocked.ts';
import { callRoutes } from './routes/calls.ts';
import { recordingRoutes } from './routes/recordings.ts';
import { liveCallRoutes } from './routes/livecalls.ts';
import { integrationRoutes } from './routes/integrations.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -78,12 +79,13 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
// ponytail: required on every unsafe /api request, not only cookie-authenticated ones; the
// Bearer-token exemption arrives with API tokens in M11.
// API-token requests (Authorization: Bearer) don't need it: requireAuth then ignores the cookie,
// and a cross-site page can't set Authorization either.
app.addHook('onRequest', async (req, reply) => {
if (
req.url.startsWith('/api/') &&
!SAFE_METHODS.has(req.method) &&
!req.headers.authorization?.startsWith('Bearer ') &&
req.headers['x-requested-with'] !== 'selfphoneng'
)
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
@@ -144,6 +146,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(callRoutes);
app.register(recordingRoutes);
app.register(liveCallRoutes);
app.register(integrationRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
+18 -1
View File
@@ -4,6 +4,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
import { getTokenUser } from './tokens.ts';
export type CurrentUser = typeof users.$inferSelect;
@@ -11,11 +12,27 @@ declare module 'fastify' {
interface FastifyRequest {
user: CurrentUser | null;
sessionId: string | null;
/** Set when the request came with an API token instead of a session. */
apiTokenId?: string;
}
}
/** Sets req.user from the session cookie. Enforces must_change_password outside /api/auth/*. */
/**
* Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie.
* Enforces must_change_password outside /api/auth/*.
*/
export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise<void> {
const auth = req.headers.authorization;
if (auth?.startsWith('Bearer ')) {
const t = await getTokenUser(req.server.db, auth.slice(7).trim());
if (!t) throw unauthorized();
req.user = t.user;
req.sessionId = null;
req.apiTokenId = t.tokenId;
if (t.user.mustChangePassword)
throw forbidden('Change your password first', 'password_change_required');
return;
}
const token = req.cookies[COOKIE];
const found = token ? await getSessionUser(req.server.db, token) : null;
if (!found) throw unauthorized();
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// API tokens (04 §2.3): `Authorization: Bearer sp_<40 base62>`, stored as sha256, acting as the
// admin who made them.
import { createHash, randomInt } from 'node:crypto';
import { and, eq, isNull } from 'drizzle-orm';
import type { Db } from '../db/client.ts';
import { apiTokens, users } from '../db/schema.ts';
const BASE62 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
export const TOKEN_RE = /^sp_[A-Za-z0-9]{40}$/;
export const hashToken = (t: string) => createHash('sha256').update(t).digest('hex');
export function generateToken(): string {
let s = 'sp_';
for (let i = 0; i < 40; i++) s += BASE62[randomInt(62)];
return s;
}
/** The token's (active) user, or null for an unknown, revoked or malformed token. */
export async function getTokenUser(db: Db, token: string) {
if (!TOKEN_RE.test(token)) return null;
const [row] = await db
.select({ id: apiTokens.id, user: users })
.from(apiTokens)
.innerJoin(users, eq(users.id, apiTokens.createdBy))
.where(
and(
eq(apiTokens.tokenHash, hashToken(token)),
isNull(apiTokens.revokedAt),
eq(users.isActive, true),
),
);
if (!row) return null;
await db.update(apiTokens).set({ lastUsedAt: new Date() }).where(eq(apiTokens.id, row.id));
return { user: row.user, tokenId: row.id };
}
+2
View File
@@ -18,6 +18,7 @@ import { watchCdr } from './services/cdr.ts';
import { watchQueueCalls } from './services/queuestats.ts';
import { watchPinChanges } from './services/vmpin.ts';
import { startJobs } from './jobs.ts';
import { watchWebhooks } from './services/webhooks.ts';
let config;
try {
@@ -77,6 +78,7 @@ const voicemail = watchVoicemail(
);
app.decorate('voicemail', voicemail);
app.decorate('live', live);
watchWebhooks(db, live, app.log);
await runMigrations(db);
app.log.info('database migrated');
+6 -1
View File
@@ -21,6 +21,7 @@ import type { LiveState } from './live/state.ts';
import { cleanupRecordings } from './routes/recordings.ts';
import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -232,5 +233,9 @@ export function startJobs(d: JobDeps): () => void {
};
tick();
const timer = setInterval(tick, 60_000);
return () => clearInterval(timer);
const hooks = setInterval(() => run('webhookDelivery', () => deliverDue(d.db)), 10_000);
return () => {
clearInterval(timer);
clearInterval(hooks);
};
}
+221
View File
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/webhooks (04 §9) and /api/api-tokens (04 §2.3). Admin only.
import { randomBytes } from 'node:crypto';
import { and, desc, eq, isNull } from 'drizzle-orm';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import {
ApiTokenInputSchema,
WebhookInputSchema,
type ApiTokenDto,
type ListResponse,
type WebhookDeliveryDto,
type WebhookDto,
type WebhookEvent,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import { generateToken, hashToken } from '../auth/tokens.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { apiTokens, users, webhookDeliveries, webhooks } from '../db/schema.ts';
import { forbidden, notFound } from '../errors.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { postSigned } from '../services/webhooks.ts';
type Hook = typeof webhooks.$inferSelect;
const toDto = (h: Hook): WebhookDto => ({
id: h.id,
url: h.url,
events: h.events as WebhookEvent[],
enabled: h.enabled,
secret: decrypt(h.secretEnc),
createdAt: h.createdAt.toISOString(),
});
const IdParams = z.object({ id: z.uuid() });
export async function integrationRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip });
async function getHook(id: string) {
const [h] = await db.select().from(webhooks).where(eq(webhooks.id, id));
if (!h) throw notFound('Webhook not found');
return h;
}
app.get('/api/webhooks', async (): Promise<ListResponse<WebhookDto>> => ({
items: (await db.select().from(webhooks).orderBy(webhooks.createdAt)).map(toDto),
nextCursor: null,
}));
app.post('/api/webhooks', async (req, reply) => {
const body = WebhookInputSchema.parse(req.body);
const [h] = (await db
.insert(webhooks)
.values({ ...body, secretEnc: encrypt(randomBytes(32).toString('hex')) })
.returning()) as [Hook];
await audit(db, {
...act(req),
action: 'create',
entityType: 'webhook',
entityId: h.id,
summary: `Added webhook ${h.url}`,
diff: { after: body },
});
return reply.code(201).send(toDto(h));
});
app.patch('/api/webhooks/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const before = await getHook(id);
const body = WebhookInputSchema.partial().parse(req.body);
const [h] = (await db
.update(webhooks)
.set({ ...body, updatedAt: new Date() })
.where(eq(webhooks.id, id))
.returning()) as [Hook];
await audit(db, {
...act(req),
action: 'update',
entityType: 'webhook',
entityId: id,
summary: `Changed webhook ${h.url}`,
diff: {
before: { url: before.url, events: before.events, enabled: before.enabled },
after: body,
},
});
return toDto(h);
});
app.delete('/api/webhooks/:id', async (req) => {
const { id } = IdParams.parse(req.params);
const h = await getHook(id);
await db.delete(webhooks).where(eq(webhooks.id, id));
await audit(db, {
...act(req),
action: 'delete',
entityType: 'webhook',
entityId: id,
summary: `Removed webhook ${h.url}`,
});
return { ok: true };
});
/** Sends a `webhook.test` event now, not queued: the answer shows right away. */
app.post('/api/webhooks/:id/test', async (req) => {
const h = await getHook(IdParams.parse(req.params).id);
const body = JSON.stringify({
id: crypto.randomUUID(),
event: 'webhook.test',
createdAt: new Date().toISOString(),
data: { message: 'Test from SelfPhoneNG' },
});
try {
const status = await postSigned(
h.url,
'webhook.test',
body,
decrypt(h.secretEnc),
await getSetting(db, 'webhooks.allowPrivate'),
);
return { ok: status >= 200 && status < 300, status };
} catch (e) {
return { ok: false, status: null, error: (e as Error).message };
}
});
app.get(
'/api/webhooks/:id/deliveries',
async (req): Promise<ListResponse<WebhookDeliveryDto>> => {
const h = await getHook(IdParams.parse(req.params).id);
const rows = await db
.select()
.from(webhookDeliveries)
.where(eq(webhookDeliveries.webhookId, h.id))
.orderBy(desc(webhookDeliveries.id))
.limit(50);
return {
items: rows.map((d) => ({
id: d.id,
event: d.event,
status: d.status as WebhookDeliveryDto['status'],
attempt: d.attempt,
responseCode: d.responseCode,
createdAt: d.createdAt.toISOString(),
nextAttemptAt: d.nextAttemptAt?.toISOString() ?? null,
})),
nextCursor: null,
};
},
);
// A token can't make or revoke tokens: a leaked one shouldn't be able to dig in deeper.
const noToken = (req: FastifyRequest) => {
if (req.apiTokenId) throw forbidden('Manage API tokens from the web page');
};
app.get('/api/api-tokens', async (req): Promise<ListResponse<ApiTokenDto>> => {
noToken(req);
const rows = await db
.select({ t: apiTokens, by: users.email })
.from(apiTokens)
.innerJoin(users, eq(users.id, apiTokens.createdBy))
.where(isNull(apiTokens.revokedAt))
.orderBy(apiTokens.createdAt);
return {
items: rows.map(({ t, by }) => ({
id: t.id,
name: t.name,
tokenPrefix: t.tokenPrefix,
createdBy: by,
createdAt: t.createdAt.toISOString(),
lastUsedAt: t.lastUsedAt?.toISOString() ?? null,
})),
nextCursor: null,
};
});
/** The only time the token is shown. */
app.post('/api/api-tokens', async (req, reply) => {
noToken(req);
const { name } = ApiTokenInputSchema.parse(req.body);
const token = generateToken();
const [t] = await db
.insert(apiTokens)
.values({
name,
tokenPrefix: token.slice(0, 7),
tokenHash: hashToken(token),
createdBy: req.user!.id,
})
.returning();
await audit(db, {
...act(req),
action: 'create',
entityType: 'api_token',
entityId: t!.id,
summary: `Created API token ${name}`,
});
return reply.code(201).send({ id: t!.id, name, token });
});
app.delete('/api/api-tokens/:id', async (req) => {
noToken(req);
const { id } = IdParams.parse(req.params);
const [t] = await db
.update(apiTokens)
.set({ revokedAt: new Date() })
.where(and(eq(apiTokens.id, id), isNull(apiTokens.revokedAt)))
.returning();
if (!t) throw notFound('Token not found');
await audit(db, {
...act(req),
action: 'delete',
entityType: 'api_token',
entityId: id,
summary: `Revoked API token ${t.name}`,
});
return { ok: true };
});
}
+1 -1
View File
@@ -36,5 +36,5 @@ describe.skipIf(!hasSh)('sp-vm-pincheck', () => {
];
for (const box of ['101', '2000', '55'])
for (const pin of pins) expect(script(box, pin), `${box} ${pin}`).toBe(api(box, pin));
}, 60_000); // 54 shells: slow on Windows under a full parallel run
}, 180_000); // ponytail: 54 runs; Git Bash forks make that ~85 s on Windows (instant on Linux)
});
+6
View File
@@ -19,6 +19,7 @@ import type { Db } from '../db/client.ts';
import { extensions, users, voicemailMessages } from '../db/schema.ts';
import { MailNotConfigured, type SendMail } from './mail.ts';
import { getSetting } from './settings.ts';
import { enqueueWebhook } from './webhooks.ts';
export type VoicemailDeps = {
db: Db;
@@ -99,6 +100,11 @@ type Box = {
};
async function announce(d: VoicemailDeps, box: Box, row: Row) {
// Every mailbox, with or without a user (the live message is only for the user's own).
void enqueueWebhook(d.db, 'voicemail.new', {
...toVoicemailDto(row),
extension: box.number,
}).catch((err: unknown) => d.log.error({ err }, 'webhook enqueue failed'));
if (box.userId)
d.live.emit('message', {
type: 'voicemail.new',
+209
View File
@@ -0,0 +1,209 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Webhooks (04 §9): events from the live state are queued in webhook_deliveries, then a job POSTs
// them, signed, with retries. Private/loopback targets are refused unless webhooks.allowPrivate.
import { createHmac, randomUUID } from 'node:crypto';
import { lookup } from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import { BlockList, isIP, type LookupFunction } from 'node:net';
import { and, arrayContains, asc, eq, lte } from 'drizzle-orm';
import type { LiveMessage, WebhookEvent } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { webhookDeliveries, webhooks } from '../db/schema.ts';
import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from './settings.ts';
const blocked = new BlockList();
for (const [net, bits] of [
['0.0.0.0', 8],
['10.0.0.0', 8],
['100.64.0.0', 10],
['127.0.0.0', 8],
['169.254.0.0', 16],
['172.16.0.0', 12],
['192.168.0.0', 16],
['224.0.0.0', 3], // multicast and reserved
] as const)
blocked.addSubnet(net, bits, 'ipv4');
for (const [net, bits] of [
['::', 127], // :: and ::1 (IPv4-mapped addresses match the IPv4 rules)
['fc00::', 7],
['fe80::', 10],
['ff00::', 8],
] as const)
blocked.addSubnet(net, bits, 'ipv6');
export const isPrivateAddress = (ip: string) => blocked.check(ip, isIP(ip) === 6 ? 'ipv6' : 'ipv4');
export class BlockedTarget extends Error {
constructor(ip: string) {
super(`${ip} is a private address (Settings → Webhooks allows them)`);
}
}
/** DNS lookup that refuses private addresses: checked at connect time, so rebinding can't dodge it. */
const publicLookup: LookupFunction = (host, opts, cb) =>
lookup(host, opts, (err, addr, family) => {
if (err) return cb(err, addr, family);
const all = Array.isArray(addr) ? addr : [{ address: addr, family }];
const bad = all.find((a) => isPrivateAddress(a.address));
if (bad) return cb(new BlockedTarget(bad.address), addr, family);
cb(null, addr, family);
});
/** POSTs a signed body; resolves to the HTTP status. No redirects are followed. */
export function postSigned(
url: string,
event: string,
body: string,
secret: string,
allowPrivate: boolean,
): Promise<number> {
const u = new URL(url);
const host = u.hostname.replace(/^\[|\]$/g, '');
if (!allowPrivate && isIP(host) && isPrivateAddress(host))
return Promise.reject(new BlockedTarget(host)); // literal IPs skip the lookup
const ts = String(Math.floor(Date.now() / 1000));
const sig = createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex');
return new Promise((resolve, reject) => {
const req = (u.protocol === 'https:' ? https : http).request(
u,
{
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
'user-agent': 'SelfPhoneNG-Webhook',
'x-selfphoneng-event': event,
'x-selfphoneng-timestamp': ts,
'x-selfphoneng-signature': `sha256=${sig}`,
},
signal: AbortSignal.timeout(10_000),
...(allowPrivate ? {} : { lookup: publicLookup }),
},
(res) => {
res.resume();
resolve(res.statusCode ?? 0);
},
);
req.on('error', reject);
req.end(body);
});
}
/** Queues one delivery per enabled webhook that wants this event. */
export async function enqueueWebhook(db: Db, event: WebhookEvent, data: unknown) {
const hooks = await db
.select({ id: webhooks.id })
.from(webhooks)
.where(and(eq(webhooks.enabled, true), arrayContains(webhooks.events, [event])));
if (!hooks.length) return;
const payload = { id: randomUUID(), event, createdAt: new Date().toISOString(), data };
await db.insert(webhookDeliveries).values(
hooks.map((h) => ({
webhookId: h.id,
event,
payload,
status: 'pending',
nextAttemptAt: new Date(),
})),
);
}
/** Waits after a failed attempt 1, 2, … 5; after the 6th try it's failed (04 §9). */
const RETRY_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 12 * 3600_000];
/** Sends what's due (the webhookDelivery job, every 10 s). */
export async function deliverDue(db: Db, now = new Date()) {
const due = await db
.select({ d: webhookDeliveries, url: webhooks.url, secretEnc: webhooks.secretEnc })
.from(webhookDeliveries)
.innerJoin(webhooks, eq(webhooks.id, webhookDeliveries.webhookId))
.where(
and(
eq(webhookDeliveries.status, 'pending'),
eq(webhooks.enabled, true),
lte(webhookDeliveries.nextAttemptAt, now),
),
)
.orderBy(asc(webhookDeliveries.id))
.limit(20);
const allowPrivate = await getSetting(db, 'webhooks.allowPrivate');
await Promise.all(
due.map(async ({ d, url, secretEnc }) => {
const code = await postSigned(
url,
d.event,
JSON.stringify(d.payload),
decrypt(secretEnc),
allowPrivate,
).catch(() => null);
const attempt = d.attempt + 1;
const ok = code !== null && code >= 200 && code < 300;
const retryIn = RETRY_MS[attempt - 1];
await db
.update(webhookDeliveries)
.set({
attempt,
responseCode: code,
status: ok ? 'done' : retryIn ? 'pending' : 'failed',
nextAttemptAt: ok || !retryIn ? null : new Date(now.getTime() + retryIn),
})
.where(eq(webhookDeliveries.id, d.id));
}),
);
}
/** Turns live-state messages into webhook events (voicemail.new comes from services/voicemail.ts). */
export function watchWebhooks(
db: Db,
live: { on(e: 'message', fn: (m: LiveMessage) => void): unknown },
log: { error(o: object, m: string): void },
) {
const calls = new Map<string, { answered: boolean; call: unknown }>();
const trunks = new Map<string, string>();
const online = new Map<string, boolean>();
const send = (event: WebhookEvent, data: unknown) =>
void enqueueWebhook(db, event, data).catch((err: unknown) =>
log.error({ err, event }, 'webhook enqueue failed'),
);
live.on('message', (m) => {
switch (m.type) {
case 'call.updated': {
const known = calls.get(m.data.linkedid);
if (!known) send('call.started', m.data);
if (m.data.answeredAt && !known?.answered) send('call.answered', m.data);
calls.set(m.data.linkedid, { answered: !!m.data.answeredAt, call: m.data });
break;
}
case 'call.removed': {
const known = calls.get(m.data.linkedid);
calls.delete(m.data.linkedid);
if (known)
send('call.ended', { ...(known.call as object), endedAt: new Date().toISOString() });
break;
}
case 'emergency.call':
send('emergency.call', m.data);
break;
case 'trunk.updated': {
const prev = trunks.get(m.data.trunkId);
trunks.set(m.data.trunkId, m.data.registration);
if (prev && prev !== m.data.registration)
send('trunk.registration_changed', { ...m.data, previous: prev });
break;
}
case 'extension.updated': {
const prev = online.get(m.data.number);
online.set(m.data.number, m.data.online);
if (prev !== undefined && prev !== m.data.online)
send('extension.registration_changed', {
number: m.data.number,
name: m.data.name,
online: m.data.online,
});
break;
}
}
});
}
+249
View File
@@ -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 },
});
});
});