provisioning: phones, /prov with Basic auth, rate limit and IP block
ci / check (push) Successful in 52s
ci / images (push) Successful in 1m44s

/api/devices CRUD (MAC in any format → 12 lowercase hex; a 20-char
provisioning password, stored encrypted and shown to admins, who type
it into the phone) and GET /:id/config-preview with passwords masked.

GET /prov/... per 07 §2: <mac>.cfg (Yealink), cfg<mac>.xml
(Grandstream), the empty Yealink common file. Basic auth with the MAC
and the device's password (timing-safe), 404 for unknown MACs or the
other vendor's file, 403 outside the local nets when LAN-only, 404 when
provisioning is off, Cache-Control no-store, last provisioned time and
IP recorded. Limits: 20 requests / 10 min per IP outside the local nets
(an office's phones rebooting together would pass it), and 10 failures
block the IP for an hour. A first request without credentials isn't a
failure: phones ask that way and retry after the 401.

Templates (pure functions of a ProvisionContext) hold the account,
voicemail, pickup codes and the provisioning settings; BLF, time zone
and phonebook come with M10-T2.

Web: Phones page (table, drawer with what to type into the phone,
config preview; manual settings for generic phones). e2e m10-t1: phone
added in the UI, 401 then the file, and a baresip phone registers with
only the account from it.

Also: the loadSnapshot test made its database inside the test (20 s);
under a full parallel run that timed out now and then. It now does it
in a hook like the others.

Task: M10-T1
This commit is contained in:
iamdoubz committed 2026-10-19 13:38:18 -05:00
1 parent dc79120de0
commit fa63908261
22 files changed
+1266 -43

No files matched your search

+193
View File
@@ -0,0 +1,193 @@
// 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 { 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 are 404; ten failures block the IP', async () => {
expect((await prov('001565000000.cfg')).statusCode).toBe(404);
expect((await prov(`cfg${device.mac}.xml`)).statusCode).toBe(404); // a Yealink here
expect((await prov('anything.boot')).statusCode).toBe(404);
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(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(404);
});
});
+42 -38
View File
@@ -202,45 +202,49 @@ describe.skipIf(!hasTestDb)('generator apply', () => {
});
describe.skipIf(!hasTestDb)('loadSnapshot', () => {
// The database is made in a hook (60 s): under a full parallel run, creating and migrating it
// can pass the 20 s a test gets.
let testDb: Awaited<ReturnType<typeof createTestDb>>;
beforeAll(async () => {
testDb = await createTestDb();
});
afterAll(() => testDb?.drop());
it('decrypts SIP passwords and resolves caller ID DIDs and admin flags', async () => {
const { db, drop } = await createTestDb();
try {
const { setSecretKey, encrypt } = await import('../src/crypto/secrets.ts');
setSecretKey(Buffer.alloc(32, 3).toString('base64'));
const s = await import('../src/db/schema.ts');
const [u] = await db
.insert(s.users)
.values({ email: 'a@example.com', displayName: 'A', passwordHash: 'x', role: 'admin' })
.returning();
const [t] = await db
.insert(s.trunks)
.values({ name: 't', serverHost: 'h.example' })
.returning();
const [d] = await db
.insert(s.dids)
.values({ number: '13125551234', trunkId: t!.id, destination: { type: 'hangup' } })
.returning();
await db.insert(s.extensions).values({
const { db } = testDb;
const { setSecretKey, encrypt } = await import('../src/crypto/secrets.ts');
setSecretKey(Buffer.alloc(32, 3).toString('base64'));
const s = await import('../src/db/schema.ts');
const [u] = await db
.insert(s.users)
.values({ email: 'a@example.com', displayName: 'A', passwordHash: 'x', role: 'admin' })
.returning();
const [t] = await db
.insert(s.trunks)
.values({ name: 't', serverHost: 'h.example' })
.returning();
const [d] = await db
.insert(s.dids)
.values({ number: '13125551234', trunkId: t!.id, destination: { type: 'hangup' } })
.returning();
await db.insert(s.extensions).values({
number: '101',
userId: u!.id,
name: 'Dan',
sipPasswordEnc: encrypt('Ab3dEf9hIj2kLm4nOp6qRs8t'),
voicemailPinEnc: encrypt('482913'),
outboundCallerIdDidId: d!.id,
});
const snap = await loadSnapshot(db, new Date('2026-10-17T12:00:00Z'));
expect(snap.today).toBe('2026-10-17');
expect(snap.extensions).toEqual([
expect.objectContaining({
number: '101',
userId: u!.id,
name: 'Dan',
sipPasswordEnc: encrypt('Ab3dEf9hIj2kLm4nOp6qRs8t'),
voicemailPinEnc: encrypt('482913'),
outboundCallerIdDidId: d!.id,
});
const snap = await loadSnapshot(db, new Date('2026-10-17T12:00:00Z'));
expect(snap.today).toBe('2026-10-17');
expect(snap.extensions).toEqual([
expect.objectContaining({
number: '101',
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
outboundCallerId: '13125551234',
emergencyCallerId: null,
isAdmin: true,
}),
]);
} finally {
await drop();
}
sipPassword: 'Ab3dEf9hIj2kLm4nOp6qRs8t',
outboundCallerId: '13125551234',
emergencyCallerId: null,
isAdmin: true,
}),
]);
});
});