system: information panel and event log; about: download this build's source
ci / check (push) Successful in 1m20s
ci / images (push) Successful in 20s

System page laid out after 3CX's admin dashboard: a System information panel (version,
host name, company, public IP, LAN IPs, ports, telephony, restart needed, copy buttons,
how to restart) and an Event log panel (system_events, 90 days: trunk down/up, Asterisk
down/up, restart needed, config failed, 911, public IP changed, backups, disk) with Load
more and a CSV download.

About (/about, no sign-in, linked from the login page, My phone and System): AGPL-3.0
notice, credits, the repository link, and "Code v<version>", which downloads
selfphone.tar.gz: the api image now tars its own build context (no .env, data,
node_modules, build output or .git) before installing anything.

Task: M11-T7
This commit is contained in:
iamdoubz committed 2026-10-20 11:26:34 -05:00
1 parent a6ba61756c
commit 0ff0f9292b
29 files changed
+5035 -79

No files matched your search

+8
View File
@@ -3,6 +3,13 @@ FROM node:24-trixie-slim AS build
RUN npm install -g corepack@latest && corepack enable
WORKDIR /src
COPY . .
# The source of exactly this build, before anything is installed or built (AGPL-3.0 §13, 08 §4):
# the build context minus .dockerignore, served at /api/about/source.
RUN v=$(node -p "require('./package.json').version") \
&& echo "$v" > /VERSION \
&& tar --sort=name --owner=0 --group=0 --numeric-owner --transform "s,^\.,selfphoneng-$v," \
-czf /selfphone.tar.gz . \
&& tar -tzf /selfphone.tar.gz > /dev/null
RUN pnpm install --frozen-lockfile \
&& pnpm --filter @selfphoneng/shared build \
&& pnpm --filter @selfphoneng/web build \
@@ -27,6 +34,7 @@ COPY --from=build /src/apps/web/dist ./public
COPY --from=build /src/apps/api/src/db/migrations ./dist/db/migrations
# tsc only emits .js: files the code reads next to itself come over by hand.
COPY --from=build /src/apps/api/src/auth/common-passwords.txt ./dist/auth/common-passwords.txt
COPY --from=build /VERSION /selfphone.tar.gz ./
ENV NODE_ENV=production
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["node","dist/index.js"]
+2
View File
@@ -38,6 +38,7 @@ import { recordingRoutes } from './routes/recordings.ts';
import { liveCallRoutes } from './routes/livecalls.ts';
import { integrationRoutes } from './routes/integrations.ts';
import { reportRoutes } from './routes/reports.ts';
import { aboutRoutes } from './routes/about.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -149,6 +150,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(liveCallRoutes);
app.register(integrationRoutes);
app.register(reportRoutes);
app.register(aboutRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
@@ -0,0 +1,11 @@
CREATE TABLE "system_events" (
"id" bigserial PRIMARY KEY NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"severity" text NOT NULL,
"code" text NOT NULL,
"source" text NOT NULL,
"message" text NOT NULL,
CONSTRAINT "system_events_severity_check" CHECK ("system_events"."severity" in ('info', 'warning', 'error'))
);
--> statement-breakpoint
CREATE INDEX "system_events_created_at_index" ON "system_events" USING btree ("created_at");
File diff suppressed because it is too large. Load diff
@@ -29,6 +29,13 @@
"when": 1792439261267,
"tag": "0003_users_totp",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1792512298128,
"tag": "0004_system_events",
"breakpoints": true
}
]
}
+18
View File
@@ -658,6 +658,24 @@ export const emergencyEvents = pgTable('emergency_events', {
...timestamps,
});
/** System page's event log: what the system noticed (trunks, telephony, backups…), not admin edits. */
export const systemEvents = pgTable(
'system_events',
{
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
severity: text().notNull(),
/** Stable id of the kind of event, e.g. trunk.down. */
code: text().notNull(),
source: text().notNull(),
message: text().notNull(),
},
(t) => [
index().on(t.createdAt),
check('system_events_severity_check', sql`${t.severity} in ('info', 'warning', 'error')`),
],
);
export const auditLog = pgTable('audit_log', {
id: bigserial({ mode: 'number' }).primaryKey(),
createdAt: createdAt(),
+2
View File
@@ -19,6 +19,7 @@ import { watchQueueCalls } from './services/queuestats.ts';
import { watchPinChanges } from './services/vmpin.ts';
import { startJobs } from './jobs.ts';
import { watchWebhooks } from './services/webhooks.ts';
import { watchEvents } from './services/events.ts';
let config;
try {
@@ -79,6 +80,7 @@ const voicemail = watchVoicemail(
app.decorate('voicemail', voicemail);
app.decorate('live', live);
watchWebhooks(db, live, app.log);
watchEvents(db, live, ami, app.log);
await runMigrations(db);
app.log.info('database migrated');
+29
View File
@@ -10,6 +10,7 @@ import type { Db } from './db/client.ts';
import {
extensions,
sessions,
systemEvents,
trunks,
users,
voicemailMessages,
@@ -21,6 +22,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 { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
import { getSetting, setSetting } from './services/settings.ts';
@@ -101,6 +103,9 @@ export async function retentionCleanup(d: JobDeps) {
await d.db
.delete(webhookDeliveries)
.where(lt(webhookDeliveries.createdAt, sql`now() - interval '30 days'`));
await d.db
.delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
}
@@ -132,7 +137,19 @@ export function createJobs(d: JobDeps) {
try {
const b = await runBackup(d.config, d.db);
d.log.info({ backup: b.name, size: b.size }, 'backup made');
await logEvent(d.db, {
severity: 'info',
code: 'backup.done',
source: 'Backup',
message: `Daily backup ${b.name} made (${Math.round(b.size / 1e6)} MB)`,
});
} catch (err) {
await logEvent(d.db, {
severity: 'error',
code: 'backup.failed',
source: 'Backup',
message: `Daily backup failed: ${(err as Error).message}`,
});
await mailAdmins(d, 'Backup failed', `Today's backup failed: ${(err as Error).message}`);
throw err;
}
@@ -149,6 +166,12 @@ export function createJobs(d: JobDeps) {
summary: `Public IP changed from ${old ?? 'none'} to ${ip}`,
diff: { before: old, after: ip },
});
await logEvent(d.db, {
severity: 'warning',
code: 'network.publicIpChanged',
source: 'Network',
message: `Public IP changed from ${old ?? 'none'} to ${ip}`,
});
await d.generator.apply({ trigger: 'publicIp', force: true }); // sets restartRequired
await mailAdmins(
d,
@@ -198,6 +221,12 @@ export function createJobs(d: JobDeps) {
const today = todayIn(await tz(), now());
if (free >= 0.1 || diskMailedDay === today) return;
diskMailedDay = today;
await logEvent(d.db, {
severity: 'warning',
code: 'disk.low',
source: 'Disk',
message: `Disk almost full: ${Math.round(free * 100)}% free`,
});
await mailAdmins(
d,
`Disk almost full (${Math.round(free * 100)}% free)`,
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/about (08 §4): no sign-in. AGPL-3.0 §13 says everyone using the system over the network
// must be offered its source, so the exact source each image was built from ships inside it
// (apps/api/Dockerfile writes selfphone.tar.gz from the build context) and downloads from here.
import { createReadStream } from 'node:fs';
import { readFile, stat } from 'node:fs/promises';
import type { FastifyInstance } from 'fastify';
import type { AboutDto } from '@selfphoneng/shared';
import { notFound } from '../errors.ts';
export const REPO_URL = 'https://git.dou.bet/spng/selfphoneng';
/** In the api's working directory (/app in the image), like ./public. */
export const SOURCE_ARCHIVE = 'selfphone.tar.gz';
let version: string | undefined;
/** The image has VERSION (the root package.json's, written at build); a checkout reads that. */
export async function appVersion(): Promise<string> {
version ??=
(await readFile('VERSION', 'utf8').catch(() => null))?.trim() ||
(
JSON.parse(await readFile(new URL('../../../../package.json', import.meta.url), 'utf8')) as {
version: string;
}
).version;
return version;
}
export async function aboutRoutes(app: FastifyInstance) {
app.get('/api/about', async (): Promise<AboutDto> => {
const s = await stat(SOURCE_ARCHIVE).catch(() => null);
return {
version: await appVersion(),
repoUrl: REPO_URL,
source: s ? { bytes: s.size, builtAt: s.mtime.toISOString() } : null,
};
});
app.get('/api/about/source', async (_req, reply) => {
if (!(await stat(SOURCE_ARCHIVE).catch(() => null)))
throw notFound('The source archive is only in builds made with Docker');
return reply
.header('content-type', 'application/gzip')
.header('content-disposition', 'attachment; filename="selfphone.tar.gz"')
.send(createReadStream(SOURCE_ARCHIVE));
});
}
+1 -1
View File
@@ -97,7 +97,7 @@ async function page(db: Db, where: SQL[], limit: number) {
}
/** RFC 4180, and cells that a spreadsheet would run as a formula get a leading quote. */
const csvCell = (v: string | number | null) => {
export const csvCell = (v: string | number | null) => {
let s = v === null ? '' : String(v);
if (/^[=@\t\r]/.test(s) || (/^[-+]/.test(s) && !/^\+?\d+$/.test(s))) s = `'${s}`;
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
+68 -11
View File
@@ -3,11 +3,16 @@
import { readFile } from 'node:fs/promises';
import { networkInterfaces } from 'node:os';
import { join, resolve } from 'node:path';
import { desc, lt } from 'drizzle-orm';
import { z } from 'zod';
import type { ListResponse, SystemEventDto, SystemStatus } from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
import { requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { getAllSettings } from '../services/settings.ts';
import { systemEvents } from '../db/schema.ts';
import { appVersion } from './about.ts';
import { csvCell } from './calls.ts';
import { BACKUP_NAME, listBackups, runBackup } from '../services/backup.ts';
import { HttpError, notFound } from '../errors.ts';
import { AmiDisconnectedError } from '../ami/client.ts';
@@ -45,16 +50,68 @@ export async function systemRoutes(app: FastifyInstance) {
return r;
});
// ponytail: the M3 subset of 04 §7.3's status (the wizard needs the LAN IP); M11 adds the rest.
app.get('/api/system/status', async () => ({
ami: { connected: app.ami.isConnected() },
restartRequired: await getSetting(app.db, 'system.restartRequired'),
// With host networking (production) these are the server's own addresses.
lanIps: Object.values(networkInterfaces())
.flat()
.filter((i) => i && i.family === 'IPv4' && !i.internal)
.map((i) => i!.address),
}));
app.get('/api/system/status', async (): Promise<SystemStatus> => {
const s = await getAllSettings(app.db);
return {
version: await appVersion(),
hostname: app.config.PUBLIC_HOSTNAME,
companyName: s['general.companyName'],
publicIp: s['network.publicIp'],
publicIpDynamic: s['network.publicIpWatch'],
// With host networking (production) these are the server's own addresses.
lanIps: Object.values(networkInterfaces())
.flat()
.filter((i) => i && i.family === 'IPv4' && !i.internal)
.map((i) => i!.address),
ports: {
sip: s['sip.udpPort'],
sipTcp: s['sip.tcpEnabled'],
sips: s['sip.tlsEnabled'] ? s['sip.tlsPort'] : null,
rtp: '10000–20000', // asterisk/conf/rtp.conf
https: 443,
},
ami: { connected: app.ami.isConnected() },
restartRequired: s['system.restartRequired'],
};
});
/** The event log, newest first; `before` is the last id of the previous page. */
const EventsQuery = z.object({
before: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
const toEvent = (e: typeof systemEvents.$inferSelect): SystemEventDto => ({
id: e.id,
createdAt: e.createdAt.toISOString(),
severity: e.severity as SystemEventDto['severity'],
code: e.code,
source: e.source,
message: e.message,
});
app.get('/api/system/events', async (req): Promise<ListResponse<SystemEventDto>> => {
const q = EventsQuery.parse(req.query);
const rows = await app.db
.select()
.from(systemEvents)
.where(q.before ? lt(systemEvents.id, q.before) : undefined)
.orderBy(desc(systemEvents.id))
.limit(q.limit + 1);
const items = rows.slice(0, q.limit).map(toEvent);
return { items, nextCursor: rows.length > q.limit ? String(items.at(-1)!.id) : null };
});
app.get('/api/system/events.csv', async (_req, reply) => {
// ponytail: everything kept (90 days) in one file; page it if logs ever get big.
const rows = await app.db.select().from(systemEvents).orderBy(desc(systemEvents.id));
const out = ['time,severity,event,source,details'];
for (const e of rows)
out.push(
[e.createdAt.toISOString(), e.severity, e.code, e.source, e.message].map(csvCell).join(','),
);
return reply
.header('content-type', 'text/csv; charset=utf-8')
.header('content-disposition', 'attachment; filename="selfphoneng-events.csv"')
.send(out.join('\r\n') + '\r\n');
});
/** 01 §4.3: transports only change on a restart. `now=true` drops calls; otherwise waits. */
app.post('/api/system/asterisk/restart', async (req) => {
+116
View File
@@ -0,0 +1,116 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The System page's event log (05 §2): things the system noticed, newest first. Admin edits are
// the audit log's job, not this one's.
import type { EventEmitter } from 'node:events';
import { eq } from 'drizzle-orm';
import type { LiveMessage, SystemEventDto } from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
import { systemEvents, trunks } from '../db/schema.ts';
export type SystemEvent = Pick<SystemEventDto, 'severity' | 'code' | 'source' | 'message'>;
type Log = { error(o: object, m: string): void };
/** Never throws: an event that can't be written is logged and dropped. */
export async function logEvent(db: Db, e: SystemEvent, log?: Log): Promise<void> {
try {
await db.insert(systemEvents).values(e);
} catch (err) {
log?.error({ err, event: e.code }, 'system event not saved');
}
}
const REGISTRATION: Record<string, string> = {
registered: 'registered',
rejected: 'was rejected (wrong username or password?)',
unregistered: 'is not registered',
unknown: 'is in an unknown state',
};
/** Trunk registration changes, 911 calls, Asterisk going away and coming back, config errors. */
export function watchEvents(
db: Db,
live: { on(e: 'message', fn: (m: LiveMessage) => void): unknown },
ami: Pick<EventEmitter, 'on'> & { isConnected(): boolean },
log: Log,
) {
const add = (e: SystemEvent) => void logEvent(db, e, log);
const trunkState = new Map<string, string>();
let restartRequired = false;
// null until the first connect, so starting up isn't logged as "reachable again".
let amiUp: boolean | null = ami.isConnected() ? true : null;
ami.on('connected', () => {
if (amiUp === false)
add({
severity: 'info',
code: 'telephony.up',
source: 'Telephony',
message: 'Asterisk is reachable again',
});
amiUp = true;
});
ami.on('disconnected', () => {
if (amiUp)
add({
severity: 'error',
code: 'telephony.down',
source: 'Telephony',
message: 'Lost the connection to Asterisk (stopped or restarting)',
});
amiUp = false;
});
live.on('message', (m) => {
if (m.type === 'trunk.updated') {
const prev = trunkState.get(m.data.trunkId);
trunkState.set(m.data.trunkId, m.data.registration);
if (!prev || prev === m.data.registration) return;
const up = m.data.registration === 'registered';
void (async () => {
const [t] = await db
.select({ name: trunks.name })
.from(trunks)
.where(eq(trunks.id, m.data.trunkId));
add({
severity: up ? 'info' : 'warning',
code: up ? 'trunk.up' : 'trunk.down',
source: 'Trunk',
message: `Trunk ${t?.name ?? m.data.trunkId} ${REGISTRATION[m.data.registration]}`,
});
})().catch((err: unknown) => log.error({ err }, 'trunk event failed'));
} else if (m.type === 'emergency.call') {
const who = `${m.data.ext} ${m.data.name}`;
add(
m.data.test
? {
severity: 'info',
code: 'emergency.test',
source: 'Emergency',
message: 'Emergency notifications tested',
}
: {
severity: 'error',
code: 'emergency.call',
source: 'Emergency',
message: `${who} dialed 911 (caller ID ${m.data.callerId}${m.data.location ? `, ${m.data.location}` : ''})`,
},
);
} else if (m.type === 'system.configApplied') {
if (!m.data.ok)
add({
severity: 'error',
code: 'config.failed',
source: 'Configuration',
message: `Writing Asterisk's configuration failed: ${m.data.error ?? 'unknown error'}`,
});
if (m.data.restartRequired && !restartRequired)
add({
severity: 'warning',
code: 'telephony.restartNeeded',
source: 'Configuration',
message: 'SIP ports, transports or the public IP changed: telephony needs a restart',
});
restartRequired = m.data.restartRequired;
}
});
}
+14 -1
View File
@@ -5,12 +5,18 @@ import type { LightMyRequestResponse } from 'fastify';
import { eq } from 'drizzle-orm';
import type { TrunkLive } from '@selfphoneng/shared';
import { createJobs, retentionCleanup, type JobDeps } from '../src/jobs.ts';
import { extensions, sessions, voicemailMessages } from '../src/db/schema.ts';
import { extensions, sessions, systemEvents, voicemailMessages } from '../src/db/schema.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('background jobs', () => {
const events = (code: string) =>
t.db
.select({ severity: systemEvents.severity, message: systemEvents.message })
.from(systemEvents)
.where(eq(systemEvents.code, code))
.orderBy(systemEvents.id);
let t: Awaited<ReturnType<typeof createTestApp>>;
let req: (m: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
let trunkId: string;
@@ -159,6 +165,9 @@ describe.skipIf(!hasTestDb)('background jobs', () => {
expect(await getSetting(t.db, 'network.publicIp')).toBe('203.0.113.9');
expect(await getSetting(t.db, 'system.restartRequired')).toBe(true);
expect(t.mails.map((m) => m.subject)).toEqual(['Public IP changed to 203.0.113.9']);
expect(await events('network.publicIpChanged')).toEqual([
{ severity: 'warning', message: 'Public IP changed from 203.0.113.5 to 203.0.113.9' },
]);
await j.publicIpWatch(); // unchanged: nothing
vi.stubGlobal('fetch', async () => new Response('<html>rate limited</html>'));
@@ -187,6 +196,10 @@ describe.skipIf(!hasTestDb)('background jobs', () => {
clock += 86_400_000; // next day: again
await j.diskWatch();
expect(t.mails).toHaveLength(2);
expect(await events('disk.low')).toEqual([
{ severity: 'warning', message: 'Disk almost full: 6% free' },
{ severity: 'warning', message: 'Disk almost full: 6% free' },
]);
});
it('a mail that fails to send is logged, not thrown', async () => {
+2 -2
View File
@@ -106,11 +106,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 40 tables', async () => {
it('creates all 41 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 40 });
expect(r.rows[0]).toEqual({ n: 41 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
+166
View File
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// The System page (05 §2): status, the event log, and /api/about (08 §4, no sign-in).
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LightMyRequestResponse } from 'fastify';
import type { TrunkLive } from '@selfphoneng/shared';
import { encrypt } from '../src/crypto/secrets.ts';
import { systemEvents, trunks } from '../src/db/schema.ts';
import { logEvent, watchEvents } from '../src/services/events.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const settle = () => new Promise((r) => setTimeout(r, 200)); // inserts are fire-and-forget
describe.skipIf(!hasTestDb)('system status, event log, about', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let get: (url: string, cookie?: string) => Promise<LightMyRequestResponse>;
let admin: string;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
get = (url, cookie = admin) =>
t.app.inject({ method: 'GET', url, headers: cookie ? { ...H, cookie } : H });
});
afterAll(() => t?.close());
it('status: version, host, IPs, ports, telephony, restart', async () => {
await setSetting(t.db, 'network.publicIp', '203.0.113.5');
await setSetting(t.db, 'sip.tlsEnabled', true);
const s = (await get('/api/system/status')).json();
expect(s).toMatchObject({
version: '0.0.0',
hostname: expect.any(String),
companyName: expect.any(String),
publicIp: '203.0.113.5',
publicIpDynamic: false,
ports: { sip: 5060, sipTcp: true, sips: 5061, rtp: '10000–20000', https: 443 },
ami: { connected: true },
restartRequired: expect.any(Boolean),
});
expect(Array.isArray(s.lanIps)).toBe(true);
});
it('the watcher logs trunk changes, 911, Asterisk going away, config problems', async () => {
watchEvents(t.db, t.live, t.ami, t.app.log);
const [trunk] = await t.db
.insert(trunks)
.values({
name: 'voip.ms',
serverHost: 'x.voip.ms',
username: 'u',
passwordEnc: encrypt('p'),
})
.returning();
const reg = (registration: TrunkLive['registration']) =>
t.live.emit('message', {
type: 'trunk.updated',
data: { trunkId: trunk!.id, registration, reachable: true, lastChange: '' },
});
reg('registered'); // first sight: nothing
reg('registered');
reg('rejected');
reg('registered');
t.ami.events.emit('disconnected');
t.ami.events.emit('connected');
const call = { ext: '101', name: 'Ann', location: '2nd floor', callerId: '13125550100' };
t.live.emit('message', {
type: 'emergency.call',
data: { ...call, at: '', test: false, audienceUserIds: [] },
});
t.live.emit('message', {
type: 'emergency.call',
data: { ...call, at: '', test: true, audienceUserIds: [] },
});
t.live.emit('message', {
type: 'system.configApplied',
data: { ok: true, error: null, restartRequired: true },
});
t.live.emit('message', {
type: 'system.configApplied',
data: { ok: false, error: 'disk full', restartRequired: true }, // already said: once
});
await settle();
// Sorted: trunk events wait for the trunk's name, so they can land after later ones.
const rows = (await t.db.select().from(systemEvents)).sort((x, y) =>
x.code.localeCompare(y.code),
);
expect(rows.map((r) => [r.severity, r.code, r.source, r.message])).toEqual([
[
'error',
'config.failed',
'Configuration',
"Writing Asterisk's configuration failed: disk full",
],
[
'error',
'emergency.call',
'Emergency',
'101 Ann dialed 911 (caller ID 13125550100, 2nd floor)',
],
['info', 'emergency.test', 'Emergency', 'Emergency notifications tested'],
[
'error',
'telephony.down',
'Telephony',
'Lost the connection to Asterisk (stopped or restarting)',
],
[
'warning',
'telephony.restartNeeded',
'Configuration',
'SIP ports, transports or the public IP changed: telephony needs a restart',
],
['info', 'telephony.up', 'Telephony', 'Asterisk is reachable again'],
[
'warning',
'trunk.down',
'Trunk',
'Trunk voip.ms was rejected (wrong username or password?)',
],
['info', 'trunk.up', 'Trunk', 'Trunk voip.ms registered'],
]);
});
it('events: newest first, paged; CSV; admins only', async () => {
await t.db.delete(systemEvents);
for (let i = 1; i <= 5; i++)
await logEvent(t.db, {
severity: 'info',
code: 'test.n',
source: 'Test',
message: i === 5 ? '=cmd|x' : `event ${i}`,
});
const first = (await get('/api/system/events?limit=2')).json();
expect(first.items.map((e: { message: string }) => e.message)).toEqual(['=cmd|x', 'event 4']);
const second = (await get(`/api/system/events?limit=2&before=${first.nextCursor}`)).json();
expect(second.items.map((e: { message: string }) => e.message)).toEqual(['event 3', 'event 2']);
const last = (await get(`/api/system/events?limit=2&before=${second.nextCursor}`)).json();
expect(last).toMatchObject({ items: [{ message: 'event 1' }], nextCursor: null });
const csv = await get('/api/system/events.csv');
expect(csv.headers['content-disposition']).toContain('selfphoneng-events.csv');
const lines = csv.body.trim().split('\r\n');
expect(lines[0]).toBe('time,severity,event,source,details');
expect(lines).toHaveLength(6);
expect(lines[1]).toMatch(/,info,test\.n,Test,'=cmd\|x$/); // no spreadsheet formulas
await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/system/events', pat)).statusCode).toBe(403);
expect((await get('/api/system/events', '')).statusCode).toBe(401);
});
it('about needs no sign-in; outside a Docker build there is no source archive', async () => {
const r = await get('/api/about', '');
expect(r.statusCode).toBe(200);
expect(r.json()).toEqual({
version: '0.0.0',
repoUrl: 'https://git.dou.bet/spng/selfphoneng',
source: null,
});
expect((await get('/api/about/source', '')).statusCode).toBe(404);
});
});