emergency: 911 dialplan, notifications, test button, settings, warnings
Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext; sp-emergency has no class, channel-limit or DND check, dials every enabled trunk in name order with the E911 caller ID, and raises UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches) for the ring-out. api: SpEmergency -> emergency_events row, live emergency.call (admins and notify users only), email via nodemailer (SMTP from settings) and an Originate to every notify extension, each outcome recorded. Test button runs the same with TEST in front, records and dials nothing. GET/PATCH /api/settings (smtp password write-only, references checked, read-only system keys), SMTP test, /api/system/warnings. Fix: clearing a nullable setting stored SQL NULL (now JSON null). UI: Settings page (Email, Emergency with the 06 §6 notice), user Emergency tab, dashboard warnings banner, red 911 banner. Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a caller with no class to the fake provider (E911 caller ID in RPID, SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs (SMTP sink receives the test email, 101 rings, banner shows). nodemailer pinned to 10.0.11 (newest release over two weeks old). Task: M3-T4
This commit is contained in:
1 parent
4496f6b89a
commit
409724bc55
45 files changed
+1781
-139
No files matched your search
@@ -16,6 +16,7 @@ import { EventEmitter } from 'node:events';
|
||||
import { createGenerator } from '../src/generator/index.ts';
|
||||
import { LiveState } from '../src/live/state.ts';
|
||||
import type { AmiMessage } from '../src/ami/events.ts';
|
||||
import type { Mail } from '../src/services/mail.ts';
|
||||
import { createTestDb } from './db.ts';
|
||||
|
||||
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
|
||||
@@ -70,6 +71,14 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
const genDir = mkdtempSync(join(tmpdir(), 'sp-gen-'));
|
||||
const live = new LiveState(t.db, ami);
|
||||
app.decorate('live', live);
|
||||
app.decorate('ami', ami);
|
||||
/** Every email the app sent; set mail.fail to make sending throw. */
|
||||
const mails: Mail[] = [];
|
||||
const mail = { fail: null as string | null };
|
||||
app.decorate('sendMail', async (m: Mail) => {
|
||||
if (mail.fail) throw new Error(mail.fail);
|
||||
mails.push(m);
|
||||
});
|
||||
app.decorate(
|
||||
'generator',
|
||||
createGenerator({
|
||||
@@ -116,6 +125,8 @@ export async function createTestApp(extend?: (app: ReturnType<typeof buildApp>)
|
||||
app,
|
||||
db: t.db,
|
||||
ami,
|
||||
mails,
|
||||
mail,
|
||||
live,
|
||||
genDir,
|
||||
login,
|
||||
|
||||
@@ -418,6 +418,56 @@ async function main() {
|
||||
log('class check: ss-noservice');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// M3-T4: 911 from a caller with no class at all still goes out, with the E911 caller ID, and
|
||||
// the dialplan tells the api. (The fake provider answers it; nothing real is dialed.)
|
||||
const userEvents: Record<string, string | string[]>[] = [];
|
||||
ami.on('event:UserEvent', (m) => userEvents.push(m));
|
||||
await ami.command('channel originate Local/911@sp-from-ext application Wait 5');
|
||||
await waitFor(
|
||||
'the provider to receive 911',
|
||||
async () =>
|
||||
/LAST_IN=to=911 /.test(
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out,
|
||||
),
|
||||
15,
|
||||
);
|
||||
const e911 = /LAST_IN=(.*)/.exec(
|
||||
docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out,
|
||||
)?.[1];
|
||||
log(`provider received: ${e911}`);
|
||||
if (!new RegExp(`rpid=.*sip:${DID}@`).test(e911 ?? ''))
|
||||
problems.push(`911 without the E911 caller ID ${DID}: ${e911}`);
|
||||
// AMI delivers the UserEvent asynchronously: it can arrive after the trunk call has started.
|
||||
await waitFor(
|
||||
'SpEmergency on AMI',
|
||||
async () => userEvents.some((m) => m.UserEvent === 'SpEmergency'),
|
||||
10,
|
||||
).catch(() => {});
|
||||
const ev = userEvents.find((m) => m.UserEvent === 'SpEmergency');
|
||||
if (!ev || ev.CallerId !== DID || ev.Dialed !== '911')
|
||||
problems.push(`no SpEmergency UserEvent with CallerId ${DID}: ${JSON.stringify(userEvents)}`);
|
||||
await ami.command('channel request hangup all');
|
||||
// The alert ring-out the api originates: 101 answers and hears beeps and the extension.
|
||||
await ami.action({
|
||||
Action: 'Originate',
|
||||
Channel: 'PJSIP/101',
|
||||
Context: 'sp-emergency-alert',
|
||||
Exten: '102',
|
||||
Priority: '1',
|
||||
CallerID: '"EMERGENCY 911" <102>',
|
||||
Async: 'true',
|
||||
});
|
||||
await waitFor(
|
||||
'the alert to play on 101',
|
||||
async () =>
|
||||
/sp-emergency-alert!102!.*!Up!(Playback|SayDigits|Wait)/.test(
|
||||
await ami.command('core show channels concise'),
|
||||
),
|
||||
15,
|
||||
);
|
||||
log('911: out with the E911 caller ID, SpEmergency raised, alert plays on 101');
|
||||
await ami.command('channel request hangup all');
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
|
||||
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const errors = plainLog.split('\n').filter((l) => /ERROR\[/.test(l));
|
||||
@@ -441,7 +491,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
'ok trunks (registration, rejection, qualify, inbound DID → 101, outbound with DID caller ID, class check)',
|
||||
'ok trunks (registration, rejection, qualify, inbound DID → 101, outbound with DID caller ID, class check, 911)',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M3-T4: 911 notifications (04 §5.3), the test button, settings and dashboard warnings.
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { LiveMessage } from '@selfphoneng/shared';
|
||||
import { emergencyEvents, settings } from '../src/db/schema.ts';
|
||||
import { decrypt } from '../src/crypto/secrets.ts';
|
||||
import { watchEmergencies } from '../src/services/emergency.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
describe.skipIf(!hasTestDb)('emergency notifications', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let dan: { id: string; number: string };
|
||||
let desk: { id: string; number: string };
|
||||
let didId: string;
|
||||
const live: LiveMessage[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestApp();
|
||||
await t.clearMustChange(ADMIN.email);
|
||||
const cookie = await t.login(ADMIN.email, ADMIN.password);
|
||||
req = (method, url, payload) =>
|
||||
t.app.inject({
|
||||
method: method as 'GET',
|
||||
url,
|
||||
payload: payload as object,
|
||||
headers: { ...H, cookie },
|
||||
});
|
||||
dan = (await req('POST', '/api/extensions', { number: '101', name: 'Dan Doe' })).json();
|
||||
await req('PATCH', `/api/extensions/${dan.id}`, { emergencyLocation: '2nd floor, room 210' });
|
||||
desk = (await req('POST', '/api/extensions', { number: '100', name: 'Front desk' })).json();
|
||||
const trunkId = (
|
||||
await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms',
|
||||
serverHost: '127.0.0.1',
|
||||
username: '123456_pbx',
|
||||
password: 'pw',
|
||||
})
|
||||
).json().id;
|
||||
didId = (
|
||||
await req('POST', '/api/dids', {
|
||||
number: '3125551234',
|
||||
trunkId,
|
||||
destination: { type: 'hangup' },
|
||||
})
|
||||
).json().id;
|
||||
t.live.on('message', (m) => live.push(m));
|
||||
watchEmergencies(
|
||||
{ db: t.db, ami: t.ami, live: t.live, send: t.app.sendMail, log: t.app.log },
|
||||
t.ami.events,
|
||||
);
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('dashboard warnings: E911 caller ID missing, email missing', async () => {
|
||||
const codes = (await req('GET', '/api/system/warnings'))
|
||||
.json()
|
||||
.items.map((w: { code: string }) => w.code);
|
||||
expect(codes).toEqual(['e911_cid_missing', 'smtp_missing']);
|
||||
});
|
||||
|
||||
it('settings: SMTP password is write-only; references are checked', async () => {
|
||||
const r = await req('PATCH', '/api/settings', {
|
||||
smtp: {
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: 'pbx',
|
||||
password: 'mail-secret',
|
||||
from: 'PBX <pbx@example.com>',
|
||||
},
|
||||
'emergency.defaultCallerIdDidId': didId,
|
||||
'emergency.notifyEmails': ['security@example.com'],
|
||||
'emergency.notifyExtensionIds': [desk.id],
|
||||
});
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(r.json().smtp).toEqual({
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
username: 'pbx',
|
||||
from: 'PBX <pbx@example.com>',
|
||||
hasPassword: true,
|
||||
});
|
||||
expect(JSON.stringify(r.json())).not.toContain('mail-secret');
|
||||
const [row] = await t.db.select().from(settings).where(eq(settings.key, 'smtp'));
|
||||
expect(decrypt((row!.value as { passwordEnc: string }).passwordEnc)).toBe('mail-secret');
|
||||
// Saving again without a password keeps it.
|
||||
await req('PATCH', '/api/settings', {
|
||||
smtp: { ...r.json().smtp, hasPassword: undefined, port: 465, secure: true },
|
||||
});
|
||||
const [again] = await t.db.select().from(settings).where(eq(settings.key, 'smtp'));
|
||||
expect(decrypt((again!.value as { passwordEnc: string }).passwordEnc)).toBe('mail-secret');
|
||||
|
||||
for (const bad of [
|
||||
{ 'system.restartRequired': false },
|
||||
{ 'no.such.key': 1 },
|
||||
{ 'emergency.defaultCallerIdDidId': '00000000-0000-4000-8000-000000000000' },
|
||||
{ 'emergency.notifyExtensionIds': ['00000000-0000-4000-8000-000000000000'] },
|
||||
{ 'emergency.notifyEmails': ['not-an-email'] },
|
||||
])
|
||||
expect((await req('PATCH', '/api/settings', bad)).statusCode).toBe(400);
|
||||
|
||||
const codes = (await req('GET', '/api/system/warnings'))
|
||||
.json()
|
||||
.items.map((w: { code: string }) => w.code);
|
||||
expect(codes).toEqual(['e911_address_unconfirmed']);
|
||||
await req('PATCH', `/api/dids/${didId}`, { e911Address: '1 Main St, Chicago IL' });
|
||||
expect((await req('GET', '/api/system/warnings')).json().items).toEqual([]);
|
||||
});
|
||||
|
||||
it('a 911 call records the event, emails, rings the notify phone and tells the dashboards', async () => {
|
||||
t.ami.events.emit('event', {
|
||||
Event: 'UserEvent',
|
||||
UserEvent: 'SpEmergency',
|
||||
Ext: '101',
|
||||
CallerId: '13125551234',
|
||||
Dialed: '911',
|
||||
});
|
||||
for (let i = 0; i < 40 && !(await t.db.select().from(emergencyEvents))[0]?.notified; i++)
|
||||
await tick();
|
||||
await tick();
|
||||
const [ev] = await t.db.select().from(emergencyEvents);
|
||||
expect(ev).toMatchObject({
|
||||
extensionNumber: '101',
|
||||
dialed: '911',
|
||||
callerIdUsed: '13125551234',
|
||||
location: '2nd floor, room 210',
|
||||
});
|
||||
expect(ev!.notified).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'email', to: 'security@example.com', ok: true },
|
||||
{ kind: 'ring', to: '100', ok: true },
|
||||
]),
|
||||
);
|
||||
const mail = t.mails.at(-1)!;
|
||||
expect(mail.subject).toBe('EMERGENCY: 911 dialed from ext 101 (Dan Doe)');
|
||||
expect(mail.text).toContain('Location: 2nd floor, room 210');
|
||||
expect(t.ami.actions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
Action: 'Originate',
|
||||
Channel: 'PJSIP/100',
|
||||
Context: 'sp-emergency-alert',
|
||||
Exten: '101',
|
||||
}),
|
||||
);
|
||||
expect(live).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'emergency.call',
|
||||
data: expect.objectContaining({ ext: '101', test: false, location: '2nd floor, room 210' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('a failing email is recorded and the phone still rings', async () => {
|
||||
t.mail.fail = 'connection refused';
|
||||
t.ami.actions.length = 0;
|
||||
t.ami.events.emit('event', {
|
||||
Event: 'UserEvent',
|
||||
UserEvent: 'SpEmergency',
|
||||
Ext: '101',
|
||||
CallerId: '13125551234',
|
||||
Dialed: '911',
|
||||
});
|
||||
for (let i = 0; i < 40 && (await t.db.select().from(emergencyEvents)).length < 2; i++)
|
||||
await tick();
|
||||
await tick();
|
||||
const rows = await t.db.select().from(emergencyEvents);
|
||||
const last = rows.find((r) =>
|
||||
(r.notified as unknown[]).some((n) => (n as { ok: boolean }).ok === false),
|
||||
);
|
||||
expect(last?.notified).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: 'email', to: 'security@example.com', ok: false, error: 'connection refused' },
|
||||
{ kind: 'ring', to: '100', ok: true },
|
||||
]),
|
||||
);
|
||||
t.mail.fail = null;
|
||||
});
|
||||
|
||||
it('the test button notifies with TEST in front, records nothing and dials nothing', async () => {
|
||||
const before = (await t.db.select().from(emergencyEvents)).length;
|
||||
t.ami.actions.length = 0;
|
||||
const r = await req('POST', '/api/settings/emergency/test');
|
||||
expect(r.statusCode).toBe(200);
|
||||
expect(t.mails.at(-1)!.subject).toMatch(/^TEST EMERGENCY: 911 dialed/);
|
||||
expect(t.mails.at(-1)!.text).toContain('No emergency call was made');
|
||||
expect(t.ami.actions).toEqual([
|
||||
expect.objectContaining({ Channel: 'PJSIP/100', Context: 'sp-emergency-alert' }),
|
||||
]);
|
||||
expect((await t.db.select().from(emergencyEvents)).length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -184,4 +184,32 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
|
||||
admin.ws.terminate();
|
||||
user.ws.terminate();
|
||||
});
|
||||
|
||||
it('a 911 alert reaches admins and notify users only; trunk status reaches admins only', async () => {
|
||||
const admin = await open(await t.login(ADMIN.email, ADMIN.password));
|
||||
const notifyUser = await t.addUser('desk@example.com', 'desk-password-123');
|
||||
const desk = await open(await t.login('desk@example.com', 'desk-password-123'));
|
||||
const other = await open(await t.login('u@example.com', 'user-password-123'));
|
||||
await until(() => [admin, desk, other].every((c) => c.msgs.length === 1));
|
||||
const call = {
|
||||
ext: '101',
|
||||
name: 'Dan',
|
||||
location: null,
|
||||
callerId: '13125551234',
|
||||
at: new Date().toISOString(),
|
||||
test: false,
|
||||
audienceUserIds: [notifyUser.id],
|
||||
};
|
||||
t.live.emit('message', { type: 'emergency.call', data: call });
|
||||
t.live.emit('message', {
|
||||
type: 'trunk.updated',
|
||||
data: { trunkId: 'x', registration: 'registered', reachable: true, lastChange: call.at },
|
||||
});
|
||||
await until(() => admin.msgs.length === 3 && desk.msgs.length === 2);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(desk.msgs[1]).toMatchObject({ type: 'emergency.call', data: { ext: '101' } });
|
||||
expect(desk.msgs).toHaveLength(2); // no trunk.updated
|
||||
expect(other.msgs).toHaveLength(1); // just the snapshot
|
||||
for (const c of [admin, desk, other]) c.ws.terminate();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,14 @@ describe.skipIf(!hasTestDb)('settings service', () => {
|
||||
await expect(setSetting(db, 'backup.time', '25:00')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('a nullable setting can be set and cleared again (JSON null, not SQL NULL)', async () => {
|
||||
const id = '11111111-1111-4111-8111-111111111111';
|
||||
await setSetting(db, 'emergency.defaultCallerIdDidId', id);
|
||||
expect(await getSetting(db, 'emergency.defaultCallerIdDidId')).toBe(id);
|
||||
await setSetting(db, 'emergency.defaultCallerIdDidId', null);
|
||||
expect(await getSetting(db, 'emergency.defaultCallerIdDidId')).toBeNull();
|
||||
});
|
||||
|
||||
it('makes sms.webhookToken once and keeps it', async () => {
|
||||
const a = await getSetting(db, 'sms.webhookToken');
|
||||
expect(a).toMatch(/^[A-Za-z0-9]{32}$/);
|
||||
|
||||
Reference in new issue
Block a user