Files
selfphoneng/apps/api/test/app.ts
T
iamdoubz 97e67e347d
ci / check (push) Successful in 1m3s
ci / images (push) Successful in 19s
ci / check (pull_request) Successful in 1m5s
ci / images (pull_request) Successful in 5s
auth: security audit fixes, rate limits on every route
Two separate reviews of the SSO branch and all 207 API routes (spec 12 §8.6). Every route was
guarded as meant. Fixed, each with a test:
- login: a directory 503 counts as a failed sign-in; a locked directory account is a refused
  password, not "unavailable"
- open redirect after OpenID sign-in (a tab in next)
- directory email matching only for non-admins without their own password; others connect on
  My settings (POST /api/me/identities/ldap); only admins disconnect a directory account
- OpenID session check reads the refreshed ID token's claims too
- connect is POST /api/auth/oidc/connect (CSRF header)
- stored bind password, client secret and SMTP password tied to their server
- 409 role_managed when a provider sets the role; empty Contrack PATCH is a 400
- @fastify/rate-limit on every route (RATE_LIMIT_PER_MINUTE), 10/min on Test buttons; a
  password asked again shares the login limiter
Live tests pass against lldap 0.6.3, Samba 4 AD and Keycloak 26.8.1.
2026-10-26 09:46:23 -05:00

168 lines
5.2 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// A full api on a throwaway database, for Fastify inject() tests.
import { randomBytes } from 'node:crypto';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { buildApp } from '../src/app.ts';
import { loadConfig } from '../src/config.ts';
import { setSecretKey } from '../src/crypto/secrets.ts';
import { users } from '../src/db/schema.ts';
import { hashPassword } from '../src/auth/passwords.ts';
import { resetLoginLimiter } from '../src/auth/login-limiter.ts';
import { ensureInitialAdmin } from '../src/services/bootstrap.ts';
import { EventEmitter } from 'node:events';
import { createGenerator } from '../src/generator/index.ts';
import { LiveState } from '../src/live/state.ts';
import type { AmiMessage } from '../src/ami/events.ts';
import type { Mail } from '../src/services/mail.ts';
import { watchVoicemail } from '../src/services/voicemail.ts';
import { createTestDb } from './db.ts';
/** Stand-in for AmiClient: records commands; flip `connected` to simulate Asterisk going away. */
export function fakeAmi() {
const commands: string[] = [];
const events = new EventEmitter();
return {
connected: true,
commands,
events,
on: events.on.bind(events),
off: events.off.bind(events),
/** Canned list responses by Action name. */
lists: {} as Record<string, AmiMessage[]>,
actions: [] as Record<string, string>[],
async actionList(f: Record<string, string>): Promise<AmiMessage[]> {
return this.lists[f.Action!] ?? [];
},
async action(f: Record<string, string>) {
this.actions.push(f);
return { Response: 'Success' };
},
isConnected() {
return this.connected;
},
async command(c: string) {
commands.push(c);
return '';
},
};
}
export const ADMIN = { email: 'admin@example.com', password: 'initial-admin-pw' };
export const H = { 'x-requested-with': 'selfphoneng' };
export async function createTestApp(
extend?: (app: ReturnType<typeof buildApp>) => void,
env: Record<string, string> = {},
) {
const t = await createTestDb();
const config = loadConfig({
APP_SECRET: randomBytes(32).toString('base64'),
PUBLIC_HOSTNAME: 'pbx.test',
DATABASE_URL: t.url,
INITIAL_ADMIN_EMAIL: ADMIN.email,
INITIAL_ADMIN_PASSWORD: ADMIN.password,
AMI_SECRET: 'ami-secret-123',
LOG_LEVEL: 'fatal',
RATE_LIMIT_PER_MINUTE: '0', // the rate limit has its own test
RECORDINGS_DIR: mkdtempSync(join(tmpdir(), 'sp-rec-')),
SOUNDS_DIR: mkdtempSync(join(tmpdir(), 'sp-sounds-')),
MOH_DIR: mkdtempSync(join(tmpdir(), 'sp-moh-')),
BACKUPS_DIR: mkdtempSync(join(tmpdir(), 'sp-backups-')),
...env,
});
setSecretKey(config.APP_SECRET);
const pub = mkdtempSync(join(tmpdir(), 'sp-public-'));
writeFileSync(join(pub, 'index.html'), '<h1>spa</h1>');
const app = buildApp(config, t.db, pub);
const ami = fakeAmi();
const genDir = mkdtempSync(join(tmpdir(), 'sp-gen-'));
const live = new LiveState(t.db, ami);
app.decorate('live', live);
app.decorate('ami', ami);
/** Every email the app sent; set mail.fail to make sending throw. */
const mails: Mail[] = [];
const mail = { fail: null as string | null };
app.decorate('sendMail', async (m: Mail) => {
if (mail.fail) throw new Error(mail.fail);
mails.push(m);
});
app.decorate(
'generator',
createGenerator({
db: t.db,
ami,
dir: genDir,
log: app.log,
debounceMs: 20,
onApplied: () => void live.loadFromDb().catch(() => {}), // may land after close()
}),
);
const spoolDir = mkdtempSync(join(tmpdir(), 'sp-vm-'));
app.decorate(
'voicemail',
watchVoicemail(
{
db: t.db,
spoolDir,
ami,
live,
send: (m) => app.sendMail(m),
publicHost: 'pbx.test',
log: app.log,
},
ami.events,
),
);
extend?.(app);
await ensureInitialAdmin(t.db, ADMIN.email, ADMIN.password);
resetLoginLimiter();
await app.ready();
/** Logs in and returns the Cookie header value. */
async function login(email: string, password: string): Promise<string> {
const r = await app.inject({
method: 'POST',
url: '/api/auth/login',
headers: H,
payload: { email, password },
});
if (r.statusCode !== 200) throw new Error(`login failed: ${r.statusCode} ${r.body}`);
const c = r.cookies.find((x) => x.name === 'sp_session')!;
return `sp_session=${c.value}`;
}
/** Creates a user directly in the DB (no password-change requirement). */
async function addUser(email: string, password: string, role: 'admin' | 'user' = 'user') {
const [u] = await t.db
.insert(users)
.values({ email, displayName: email, passwordHash: await hashPassword(password), role })
.returning();
return u!;
}
async function clearMustChange(email: string) {
await t.db.update(users).set({ mustChangePassword: false }).where(eq(users.email, email));
}
return {
app,
db: t.db,
ami,
mails,
mail,
live,
genDir,
spoolDir,
login,
addUser,
clearMustChange,
close: async () => {
await app.close();
await t.drop();
},
};
}