Files
selfphoneng/apps/api/test/system-events.test.ts
T
iamdoubz 9e5cbb18ac release: container images when a release is published; version 0.0.1
.github/workflows/release.yml runs on Gitea and on the GitHub mirror (release: published only)
and pushes selfphoneng-api and selfphoneng-asterisk to that forge's registry: ghcr.io with the
job token, the Gitea registry with secrets REGISTRY_USER + REGISTRY_TOKEN (Gitea's job token
can't push packages). Tags: the version, plus <major>.<minor> and latest unless it's a
pre-release. It stops unless the tag is v + package.json's version, and checks the images
before pushing.

Run locally with act_runner exec against a throwaway registry: v0.0.1 pushed 0.0.1, 0.0 and
latest for both images (VERSION 0.0.1, OCI labels, source archive selfphoneng-0.0.1/);
v9.9.9 failed at the version check.

Version 0.0.1 in all package.json files, CHANGELOG.md started, release steps in 08 §3.1.

Task: none (owner request, 2026-10-20)
2026-10-20 13:27:49 -05:00

178 lines
6.8 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
// The System page (05 §2): status, the event log, and /api/about (08 §4, no sign-in).
import { readFileSync } from 'node:fs';
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';
/** The root package.json's: what a release is tagged with (08 §3.1). */
const VERSION = JSON.parse(
readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
).version;
/** The inserts are fire-and-forget: wait for them (a fixed sleep was too short under load). */
const rowsWhen = async (n: number) => {
for (let i = 0; i < 100; i++) {
const rows = await t.db.select().from(systemEvents);
if (rows.length >= n) return rows;
await new Promise((r) => setTimeout(r, 50));
}
return t.db.select().from(systemEvents);
};
let t: Awaited<ReturnType<typeof createTestApp>>;
describe.skipIf(!hasTestDb)('system status, event log, about', () => {
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: VERSION,
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
});
// Sorted: trunk events wait for the trunk's name, so they can land after later ones.
const rows = (await rowsWhen(8)).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: VERSION,
repoUrl: 'https://git.dou.bet/spng/selfphoneng',
source: null,
});
expect((await get('/api/about/source', '')).statusCode).toBe(404);
});
});