conferences: rooms with PIN and admin PIN, per-room limits, recording
/api/conferences CRUD: PINs stored encrypted, shown to admins (they hand
them out), never in the audit log; an admin PIN needs a different room
PIN (checked against the stored values on PATCH). The generator renders
sp-conf-{id} per 03 §6.11 (PIN prompt, 3 tries, admin PIN joins with
sp_admin_user), the number in sp-local-numbers and destApp conference.
Two changes from the spec's snippet, both found with real calls and
written back into 03:
- Set(CONFBRIDGE(bridge,max_members)) is ignored once ConfBridge()
names a bridge profile: a 4th caller got into a room of 3. Each room
now has its own [sp_room_<n>](default_bridge) profile in
confbridge_selfphoneng.conf.
- A recorded room runs sp-sub-record on each caller's leg (just before
ConfBridge) instead of record_conference, so the files are ordinary
call recordings: call history, Recordings page, retention.
Web: Conference rooms page (list, drawer form), "Conference room" in
the DestinationPicker. Golden case conferences. e2e m8-t3: room built
in the UI, wrong PIN refused and asked again, admin PIN joins with
flags AM, a 4th caller turned away, recordings listed, and a no-PIN
room with two desk phones.
Task: M8-T3
This commit is contained in:
1 parent
964a3c0b25
commit
a6c423de97
45 files changed
+1456
-14
No files matched your search
@@ -66,6 +66,7 @@ function snapshot(): Snapshot {
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -155,6 +155,7 @@ function snapshot(): Snapshot {
|
||||
? ({ type: 'hangup' } as const)
|
||||
: ({ type: 'voicemail', id: CDR_IDS.ext('102') } as const),
|
||||
})),
|
||||
conferences: [],
|
||||
ivrs: [
|
||||
{
|
||||
id: CDR_IDS.ivr,
|
||||
|
||||
@@ -105,6 +105,7 @@ function snapshot(): Snapshot {
|
||||
trunks: [trunk(GOOD, 'voip.ms', SUB.pass), trunk(BAD, 'wrong password', 'NotThePassword1')],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -61,6 +61,7 @@ function snapshot(): Snapshot {
|
||||
routes: [],
|
||||
ringGroups: [],
|
||||
queues: [],
|
||||
conferences: [],
|
||||
ivrs: [],
|
||||
timeConditions: [],
|
||||
holidays: [],
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M8-T3: /api/conferences (03 §6.11, 04 §7.3).
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { auditLog, conferences } from '../src/db/schema.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
import { ADMIN, H, createTestApp } from './app.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('/api/conferences', () => {
|
||||
let t: Awaited<ReturnType<typeof createTestApp>>;
|
||||
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
|
||||
let roomId: string;
|
||||
const conf = async (f: string) => {
|
||||
await t.app.generator.apply({ trigger: 'test' });
|
||||
return readFile(join(t.genDir, f), 'utf8');
|
||||
};
|
||||
|
||||
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 },
|
||||
});
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
|
||||
it('creates a room with PINs: stored encrypted, shown to admins, in the dialplan', async () => {
|
||||
const r = await req('POST', '/api/conferences', {
|
||||
number: '800',
|
||||
name: 'Board',
|
||||
pin: '4826',
|
||||
adminPin: '917355',
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(201);
|
||||
roomId = r.json().id;
|
||||
expect(r.json()).toMatchObject({
|
||||
pin: '4826',
|
||||
adminPin: '917355',
|
||||
maxMembers: 20,
|
||||
record: false,
|
||||
});
|
||||
const [row] = await t.db.select().from(conferences);
|
||||
expect(row!.pinEnc).not.toContain('4826');
|
||||
const d = await conf('extensions_selfphoneng.conf');
|
||||
expect(d).toContain(`exten => 800,1,Goto(sp-conf-${roomId},s,1)`);
|
||||
expect(d).toContain(' same => n,GotoIf($["${PIN}" = "917355"]?admin)');
|
||||
expect(d).toContain(' same => n(admin),ConfBridge(800,sp_room_800,sp_admin_user,default_menu)');
|
||||
expect(await conf('confbridge_selfphoneng.conf')).toContain('[sp_admin_user]');
|
||||
// the audit log says a PIN was set, not which
|
||||
const logged = JSON.stringify(await t.db.select().from(auditLog));
|
||||
expect(logged).not.toContain('4826');
|
||||
expect(logged).not.toContain('917355');
|
||||
});
|
||||
|
||||
it('checks the number and the PINs', async () => {
|
||||
const bad = (body: object) =>
|
||||
req('POST', '/api/conferences', { number: '801', name: 'X', ...body });
|
||||
expect((await bad({ number: '700' })).statusCode).toBe(400); // parking, not 800–849
|
||||
expect((await bad({ number: '800' })).statusCode).toBe(409);
|
||||
expect((await bad({ pin: '12' })).statusCode).toBe(400);
|
||||
expect((await bad({ pin: '12a4' })).statusCode).toBe(400);
|
||||
expect((await bad({ adminPin: '1234' })).statusCode).toBe(400); // admin PIN alone
|
||||
expect((await bad({ pin: '1234', adminPin: '1234' })).statusCode).toBe(400);
|
||||
expect((await bad({ maxMembers: 1 })).statusCode).toBe(400);
|
||||
// PATCH checks against what's stored: removing the room PIN keeps an admin PIN alone
|
||||
expect((await req('PATCH', `/api/conferences/${roomId}`, { pin: null })).statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('no PIN: straight in; record: each caller is recorded once inside', async () => {
|
||||
const r = await req('PATCH', `/api/conferences/${roomId}`, {
|
||||
pin: null,
|
||||
adminPin: null,
|
||||
record: true,
|
||||
maxMembers: 5,
|
||||
});
|
||||
expect(r.statusCode, r.body).toBe(200);
|
||||
expect(r.json()).toMatchObject({ pin: null, adminPin: null });
|
||||
const d = await conf('extensions_selfphoneng.conf');
|
||||
const ctx = d.slice(d.indexOf(`[sp-conf-${roomId}]`)).split('\n\n')[0]!;
|
||||
expect(ctx).not.toContain('Read(PIN');
|
||||
expect(await conf('confbridge_selfphoneng.conf')).toContain(
|
||||
'[sp_room_800](default_bridge) ; room 800\nmax_members = 5',
|
||||
);
|
||||
expect(ctx).toContain(
|
||||
'Gosub(sp-sub-record,s,1)\n same => n,ConfBridge(800,sp_room_800,default_user',
|
||||
);
|
||||
});
|
||||
|
||||
it('a phone number can point at it; delete is refused while it does', async () => {
|
||||
const trunkId = (
|
||||
await req('POST', '/api/trunks', {
|
||||
name: 'voip.ms',
|
||||
serverHost: '127.0.0.1',
|
||||
username: '123456_pbx',
|
||||
password: 'pw',
|
||||
})
|
||||
).json().id;
|
||||
const did = await req('POST', '/api/dids', {
|
||||
number: '3125551234',
|
||||
trunkId,
|
||||
destination: { type: 'conference', id: roomId },
|
||||
});
|
||||
expect(did.json().destinationSummary).toBe('Conference Board (800)');
|
||||
expect((await req('DELETE', `/api/conferences/${roomId}`)).statusCode).toBe(409);
|
||||
await req('PATCH', `/api/dids/${did.json().id}`, { destination: { type: 'hangup' } });
|
||||
expect((await req('DELETE', `/api/conferences/${roomId}`)).statusCode).toBe(200);
|
||||
expect(await conf('extensions_selfphoneng.conf')).not.toContain('sp-conf-');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user