Files
selfphoneng/apps/api/test/devices.test.ts
T
iamdoubz 604e40adf1
ci / check (push) Successful in 1m2s
ci / images (push) Successful in 20s
ci / check (pull_request) Successful in 1m4s
ci / images (pull_request) Successful in 5s
contacts: owners' contacts on their desk phones, copy builtin contacts to Contrack (C6)
Spec 11 §6.2: a phone's directory is the extensions, its owner's own contacts (accepted shares
included), then the company's, in both modes. In Contrack mode, contacts from before Contrack
are offered for copying (contrack.toCopy, POST …/copy-to-contrack: one Contrack bulk import,
skipping what's already there; the builtin rows stay). Tested against a fake and a real
Contrack 2.0.2.
2026-10-23 16:07:19 -05:00

303 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-or-later
// M10-T1: /api/devices and /prov (07 §1–§2): Basic auth, unknown MAC, rate limit and IP block,
// LAN-only, what's served.
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { auditLog, contacts, devices } from '../src/db/schema.ts';
import { resetProvLimiter } from '../src/provisioning/limiter.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let extId: string;
let sipPassword: string;
let device: { id: string; mac: string; provisioningPassword: string };
/** A phone's request from `ip`, with Basic auth when `pass` is given. */
const prov = (file: string, ip = '192.168.1.20', pass?: string, user = device.mac) =>
t.app.inject({
method: 'GET',
url: `/prov/${file}`,
headers: {
'x-forwarded-for': ip,
...(pass !== undefined
? { authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}` }
: {}),
},
});
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 },
});
extId = (await req('POST', '/api/extensions', { number: '101', name: 'Dan "D" <Doe>' })).json()
.id;
sipPassword = (await req('GET', `/api/extensions/${extId}/sip-credentials`)).json().password;
});
afterAll(() => t?.close());
beforeEach(() => resetProvLimiter());
it('adds a phone: MAC in any format, a provisioning password, the URL to type in', async () => {
const r = await req('POST', '/api/devices', {
mac: '00:15:65:AB:CD:EF',
vendor: 'yealink',
model: 'T54W',
extensionId: extId,
});
expect(r.statusCode, r.body).toBe(201);
device = r.json();
expect(device).toMatchObject({
mac: '001565abcdef',
extensionNumber: '101',
provisioningUrl: 'https://pbx.test/prov/',
lastProvisionedAt: null,
});
expect(device.provisioningPassword).toMatch(/^[A-Za-z0-9]{20}$/);
const [row] = await t.db.select().from(devices);
expect(row!.provisioningPasswordEnc).not.toContain(device.provisioningPassword);
const again = await req('POST', '/api/devices', {
mac: '0015.65ab.cdef',
vendor: 'yealink',
extensionId: extId,
});
expect(again.statusCode).toBe(409);
for (const mac of ['00:15:65:ab:cd', 'zz:15:65:ab:cd:ef', '0015-65ab-cdef-00'])
expect(
(await req('POST', '/api/devices', { mac, vendor: 'yealink', extensionId: extId }))
.statusCode,
mac,
).toBe(400);
});
it('serves the Yealink file with Basic auth; asks for it first without counting a failure', async () => {
const first = await prov(`${device.mac}.cfg`);
expect(first.statusCode).toBe(401);
expect(first.headers['www-authenticate']).toBe('Basic realm="selfphoneng"');
const r = await prov(`${device.mac}.cfg`, '192.168.1.20', device.provisioningPassword);
expect(r.statusCode).toBe(200);
expect(r.headers['cache-control']).toBe('no-store');
expect(r.body).toContain('account.1.user_name = 101');
expect(r.body).toContain(`account.1.password = ${sipPassword}`);
expect(r.body).toContain('account.1.display_name = Dan "D" <Doe>');
expect(r.body).toContain('static.auto_provision.server.url = https://pbx.test/prov/');
// the phone may send its MAC upper case (Basic auth can't carry colons in the user name)
const upper = await prov(
`${device.mac.toUpperCase()}.cfg`,
'192.168.1.20',
device.provisioningPassword,
device.mac.toUpperCase(),
);
expect(upper.statusCode).toBe(200);
const [row] = await t.db.select().from(devices).where(eq(devices.id, device.id));
expect(row!.lastProvisionedAt).not.toBeNull();
expect(row!.lastProvisionedIp).toBe('192.168.1.20');
// the common file needs no password (it's empty)
expect((await prov('y000000000044.cfg')).body).toBe('#!version:1.0.0.1\n');
});
it('unknown MAC and the other vendor’s file answer like a known MAC; ten failures block the IP', async () => {
// 08 §6: the answers mustn't tell which MACs exist.
const known = await prov(`${device.mac}.cfg`);
for (const f of ['001565000000.cfg', `cfg${device.mac}.xml`]) {
const r = await prov(f);
expect([r.statusCode, r.headers['www-authenticate'], r.body]).toEqual([
401,
known.headers['www-authenticate'],
known.body,
]);
}
expect((await prov('anything.boot')).statusCode).toBe(404);
// with credentials they're failures, like a wrong password
expect(
(await prov('001565000000.cfg', '192.168.1.20', 'guess', '001565000000')).statusCode,
).toBe(401);
expect(
(await prov(`cfg${device.mac}.xml`, '192.168.1.20', device.provisioningPassword)).statusCode,
).toBe(401); // a Yealink here
for (let i = 0; i < 8; i++)
expect((await prov(`${device.mac}.cfg`, '192.168.1.20', 'guess')).statusCode).toBe(401);
// 10 failures (2 unknown MACs, 8 wrong passwords): even the right password is refused now
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.20', device.provisioningPassword)).statusCode,
).toBe(429);
// another phone is fine
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.21', device.provisioningPassword)).statusCode,
).toBe(200);
});
it('outside the local nets: 20 requests per 10 minutes; LAN-only refuses them', async () => {
for (let i = 0; i < 20; i++)
expect(
(await prov(`${device.mac}.cfg`, '203.0.113.9', device.provisioningPassword)).statusCode,
).toBe(200);
expect(
(await prov(`${device.mac}.cfg`, '203.0.113.9', device.provisioningPassword)).statusCode,
).toBe(429);
await setSetting(t.db, 'provisioning.lanOnly', true);
expect(
(await prov(`${device.mac}.cfg`, '198.51.100.7', device.provisioningPassword)).statusCode,
).toBe(403);
expect(
(await prov(`${device.mac}.cfg`, '10.1.2.3', device.provisioningPassword)).statusCode,
).toBe(200);
await setSetting(t.db, 'provisioning.lanOnly', false);
await setSetting(t.db, 'provisioning.enabled', false);
expect(
(await prov(`${device.mac}.cfg`, '10.1.2.3', device.provisioningPassword)).statusCode,
).toBe(404);
await setSetting(t.db, 'provisioning.enabled', true);
});
it('Grandstream: XML, values escaped', async () => {
const r = await req('POST', '/api/devices', {
mac: '000B82123456',
vendor: 'grandstream',
extensionId: extId,
});
const gs = r.json();
const file = await prov(
'cfg000b82123456.xml',
'192.168.1.30',
gs.provisioningPassword,
'000b82123456',
);
expect(file.statusCode).toBe(200);
expect(file.headers['content-type']).toMatch(/application\/xml/);
expect(file.body).toContain('<P35>101</P35>');
expect(file.body).toContain('<P3>Dan &quot;D&quot; &lt;Doe&gt;</P3>');
expect(file.body).toContain(`<P1361>${gs.provisioningPassword}</P1361>`);
});
it('config preview masks the passwords; generic phones have no file', async () => {
const p = (await req('GET', `/api/devices/${device.id}/config-preview`)).json();
expect(p.file).toBe('001565abcdef.cfg');
expect(p.content).toContain('account.1.password = ********');
expect(p.content).not.toContain(sipPassword);
expect(p.content).not.toContain(device.provisioningPassword);
const generic = (
await req('POST', '/api/devices', {
mac: '112233445566',
vendor: 'generic',
extensionId: extId,
})
).json();
expect((await req('GET', `/api/devices/${generic.id}/config-preview`)).statusCode).toBe(400);
expect(
(await prov('112233445566.cfg', '192.168.1.40', generic.provisioningPassword, '112233445566'))
.statusCode,
).toBe(401);
});
it('M10-T2: BLF keys named after their users, the time zone, and the phonebook', async () => {
await req('POST', '/api/extensions', { number: '102', name: 'Ann Lee' });
await req('PATCH', `/api/extensions/${extId}`, { blfNumbers: ['102', '701'] });
await t.db.insert(contacts).values([
{ name: 'Acme', numbers: [{ label: 'office', number: '13125551234' }] },
{ name: 'Private', ownerUserId: null, numbers: [] }, // no numbers: left out
]);
const cfg = (await prov(`${device.mac}.cfg`, '192.168.1.60', device.provisioningPassword)).body;
expect(cfg).toContain('linekey.2.value = 102\nlinekey.2.label = Ann Lee');
expect(cfg).toContain('linekey.3.label = 701');
expect(cfg).toContain('local_time.time_zone_name = United States-Central Time');
expect(cfg).toContain(
`remote_phonebook.data.1.url = https://${device.mac}:${device.provisioningPassword}@pbx.test/prov/phonebook/${device.mac}.xml`,
);
const book = await prov(
`phonebook/${device.mac}.xml`,
'192.168.1.60',
device.provisioningPassword,
);
expect(book.statusCode).toBe(200);
expect(book.body).toContain('<Name>Ann Lee</Name><Telephone label="Extension">102</Telephone>');
expect(book.body).toContain('<Telephone label="office">13125551234</Telephone>');
expect(book.body).not.toContain('Private');
expect((await prov(`phonebook/${device.mac}.xml`, '192.168.1.60')).statusCode).toBe(401);
// Grandstream asks for <path>/phonebook.xml and gets its own format
const gs = (await req('GET', '/api/devices'))
.json()
.items.find((d: { vendor: string }) => d.vendor === 'grandstream');
const gsBook = await prov(
`phonebook/${gs.mac}/phonebook.xml`,
'192.168.1.61',
gs.provisioningPassword,
gs.mac,
);
expect(gsBook.body).toContain('<AddressBook>');
// the preview hides the password in the phonebook URL too
const p = (await req('GET', `/api/devices/${device.id}/config-preview`)).json();
expect(p.content).not.toContain(device.provisioningPassword);
expect(p.timezoneWarning).toBe(false);
await setSetting(t.db, 'general.timezone', 'Asia/Tokyo');
const tokyo = (await req('GET', `/api/devices/${device.id}/config-preview`)).json();
expect(tokyo.timezoneWarning).toBe(true);
expect(tokyo.content).toContain('local_time.time_zone_name = UTC');
await setSetting(t.db, 'general.timezone', 'America/Chicago');
});
it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => {
const owner = await t.addUser('dan@example.com', 'dan-password-123');
const other = await t.addUser('sue@example.com', 'sue-password-123');
await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id });
await t.db.insert(contacts).values([
{ name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] },
{
name: 'Sue Secret',
ownerUserId: other.id,
numbers: [{ label: '', number: '13125550102' }],
},
]);
const book = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(book).toContain('<Telephone label="mobile">13125550101</Telephone>');
expect(book).not.toContain('Sue Secret');
expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom'));
expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's
await req('PATCH', `/api/extensions/${extId}`, { userId: null });
const unowned = (
await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
).body;
expect(unowned).not.toContain('Mom');
});
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
expect(r.statusCode, r.body).toBe(200);
const { url, qr } = r.json();
expect(qr).toMatch(/^data:image\/png;base64,/);
expect(url).toMatch(/^https:\/\/pbx\.test\/prov\/linphone\/[A-Za-z0-9_-]+\.xml$/);
const path = new URL(url).pathname.slice('/prov/'.length);
const x = await prov(path, '203.0.113.20'); // a mobile phone, no Basic auth
expect(x.statusCode).toBe(200);
expect(x.body).toContain('<entry name="username">101</entry>');
expect(x.body).toContain(`<entry name="passwd">${sipPassword}</entry>`);
// the token is case-sensitive: a lower-cased one is just a bad token
expect((await prov(path.toLowerCase(), '203.0.113.21')).statusCode).toBe(404);
expect((await prov('linphone/AAAAAAAAAAAAAAAAAAAAAAAA.xml', '203.0.113.21')).statusCode).toBe(
404,
);
const logged = JSON.stringify(await t.db.select().from(auditLog));
expect(logged).toContain('Made a phone app setup QR for extension 101');
// a user with no extension gets a 404 from /api/me
expect((await req('GET', '/api/me/softphone-qr')).statusCode).toBe(404);
});
it('delete: the phone gets nothing any more', async () => {
expect((await req('DELETE', `/api/devices/${device.id}`)).statusCode).toBe(200);
expect(
(await prov(`${device.mac}.cfg`, '192.168.1.50', device.provisioningPassword)).statusCode,
).toBe(401);
});
});