Files
selfphoneng/apps/api/test/schema.test.ts
T
iamdoubz 8c750c67ca
ci / check (push) Successful in 1m1s
ci / images (push) Successful in 27s
auth: two-step sign-in (TOTP) for admins, with recovery codes
auth/totp.ts per 08 §2.4: RFC 6238 (HMAC-SHA1, 30 s, 6 digits, ±1
step) with node:crypto only, checked against the RFC's test vectors.
Migration 0003: users.totp_recovery_hashes and totp_last_step.

Admins turn it on from My account (the header's "Account"): scan the
QR or type the key, confirm a code, get 10 recovery codes once. Login
asks for the code only after the password is right (401 totp_required,
no session); a wrong code is a failed login (rate limit, audit). A
step signs in once (totp_last_step updated atomically) and a recovery
code once (array_remove guarded by any()). Off and new codes need the
password; another admin can turn it off for a lost phone, which signs
that user out. Users list shows it (twoStep).

Also, found by the full e2e chain on one shared database:
- Linphone tokens: only the exact token issued is accepted. The last
  base64 character can carry ignored bits, so another spelling decoded
  to the same signed token.
- Test scripts no longer assume a fresh system: m3-t3 picks Dan in the
  test box, m8-check dials whatever slot the park took (findslot =
  next), m10-t4 uses the admin's existing extension, m11-t1 clears the
  public IP on its own first (local nets are in the transports while
  one is set, and transports change only on a restart).

e2e m11-t1-totp; the full chain M1-M11 (33 scripts) passes.

Task: M11-T1
2026-10-19 15:41:53 -05:00

150 lines
5.2 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// M1-T1: the migration matches the DDL in docs/spec/02-data-model.md for five key tables.
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Db } from '../src/db/client.ts';
import { createTestDb, hasTestDb } from './db.ts';
// column → "data_type|nullable|default-ish", straight from the spec DDL (+ created_at/updated_at).
const ts = {
created_at: 'timestamp with time zone|NO|now()',
updated_at: 'timestamp with time zone|NO|now()',
};
const EXPECTED: Record<string, Record<string, string>> = {
users: {
id: 'uuid|NO|gen_random_uuid()',
email: 'USER-DEFINED:citext|NO|',
display_name: 'text|NO|',
password_hash: 'text|NO|',
role: "USER-DEFINED:user_role|NO|'user'::user_role",
is_active: 'boolean|NO|true',
totp_secret_enc: 'text|YES|',
totp_recovery_hashes: "ARRAY:_text|NO|'{}'::text[]",
totp_last_step: 'bigint|YES|',
must_change_password: 'boolean|NO|false',
last_login_at: 'timestamp with time zone|YES|',
...ts,
},
extensions: {
id: 'uuid|NO|gen_random_uuid()',
number: 'text|NO|',
user_id: 'uuid|YES|',
name: 'text|NO|',
sip_password_enc: 'text|NO|',
webrtc_enabled: 'boolean|NO|true',
max_contacts: 'integer|NO|5',
ring_seconds: 'integer|NO|25',
voicemail_enabled: 'boolean|NO|true',
voicemail_pin_enc: 'text|NO|',
voicemail_email: 'boolean|NO|true',
voicemail_attach_audio: 'boolean|NO|true',
voicemail_delete_after_email: 'boolean|NO|false',
outbound_class: "USER-DEFINED:outbound_class|NO|'national'::outbound_class",
outbound_caller_id_did_id: 'uuid|YES|',
emergency_caller_id_did_id: 'uuid|YES|',
emergency_location: 'text|YES|',
record_mode: "USER-DEFINED:record_mode|NO|'never'::record_mode",
pickup_group: 'text|YES|',
language: "text|NO|'en'::text",
moh_class: "text|NO|'default'::text",
blf_numbers: "ARRAY:_text|NO|'{}'::text[]",
...ts,
},
trunks: {
id: 'uuid|NO|gen_random_uuid()',
name: 'text|NO|',
provider: "USER-DEFINED:trunk_provider|NO|'voipms'::trunk_provider",
enabled: 'boolean|NO|true',
server_host: 'text|NO|',
server_port: 'integer|NO|5060',
transport: "USER-DEFINED:sip_transport|NO|'udp'::sip_transport",
auth_mode: "USER-DEFINED:trunk_auth_mode|NO|'registration'::trunk_auth_mode",
username: 'text|YES|',
password_enc: 'text|YES|',
from_user: 'text|YES|',
from_domain: 'text|YES|',
codecs: "ARRAY:_text|NO|'{ulaw}'::text[]",
srtp: 'boolean|NO|false',
max_channels: 'integer|YES|',
voipms_api_username: 'text|YES|',
voipms_api_password_enc: 'text|YES|',
...ts,
},
calls: {
id: 'uuid|NO|gen_random_uuid()',
linkedid: 'text|NO|',
direction: 'USER-DEFINED:call_direction|NO|',
status: 'USER-DEFINED:call_status|NO|',
started_at: 'timestamp with time zone|NO|',
answered_at: 'timestamp with time zone|YES|',
ended_at: 'timestamp with time zone|NO|',
from_number: 'text|YES|',
from_name: 'text|YES|',
to_number: 'text|YES|',
did_id: 'uuid|YES|',
answered_by_extension_id: 'uuid|YES|',
talk_seconds: 'integer|NO|0',
recording_id: 'uuid|YES|',
...ts,
},
audit_log: {
id: "bigint|NO|nextval('audit_log_id_seq'::regclass)",
created_at: 'timestamp with time zone|NO|now()',
actor_user_id: 'uuid|YES|',
actor_ip: 'inet|YES|',
action: 'text|NO|',
entity_type: 'text|YES|',
entity_id: 'text|YES|',
summary: 'text|NO|',
diff: 'jsonb|YES|',
},
};
describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
let db: Db;
let drop: () => Promise<void>;
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 40 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 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
const r = await db.execute<{
column_name: string;
data_type: string;
udt_name: string;
is_nullable: string;
column_default: string | null;
}>(sql`select column_name, data_type, udt_name, is_nullable, column_default
from information_schema.columns where table_schema = 'public' and table_name = ${table}`);
const actual = Object.fromEntries(
r.rows.map((c) => {
const type = ['USER-DEFINED', 'ARRAY'].includes(c.data_type)
? `${c.data_type}:${c.udt_name}`
: c.data_type;
return [c.column_name, `${type}|${c.is_nullable}|${c.column_default ?? ''}`];
}),
);
expect(actual).toEqual(EXPECTED[table]);
});
it('enforces the spec check constraints', async () => {
await expect(
db.execute(
sql`insert into holidays (name, start_date, end_date) values ('x', '2026-01-01', '2026-01-01')`,
),
).resolves.toBeDefined();
await expect(
db.execute(
sql`insert into time_conditions (name, timezone, open_ranges, override_digit, open_destination, closed_destination)
values ('t', 'UTC', '[]', 12, '{}', '{}')`,
),
).rejects.toThrow();
});
});