contacts: Contrack as the robust contacts (C3), picked in Settings or the setup wizard
ci / check (push) Successful in 1m0s
ci / images (push) Successful in 32s

Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token
write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings,
books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in
Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack
(2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake
and a real Contrack 2.0.2 (contrack-live.test.ts).
This commit is contained in:
iamdoubz committed 2026-10-23 15:23:49 -05:00
1 parent a754974a90
commit 692dd59552
32 files changed
+5787 -120

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C3 against a real Contrack (decision 0003): the routes we use, as the pinned version answers
// them. Skipped unless CONTRACK_LIVE_URL is set; it needs a fresh Contrack with sign-in on and no
// account yet (it makes the accounts and tokens itself):
//
// docker run -d --rm --name contrack-live -p 127.0.0.1:3210:3210 -e AUTH_REQUIRED=true \
// ghcr.io/arvarik/contrack:2.0.2
// CONTRACK_LIVE_URL=http://127.0.0.1:3210 pnpm vitest run apps/api/test/contrack-live.test.ts
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { contactNames } from '../src/contacts/book.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
const LIVE = process.env.CONTRACK_LIVE_URL;
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
/** Contrack's own API, signed in with a session cookie (accounts, tokens) or a token. */
async function ctk(
path: string,
init: { method?: string; body?: unknown; cookie?: string; token?: string } = {},
) {
const r = await fetch(`${LIVE}${path}`, {
method: init.method ?? 'GET',
headers: {
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(init.cookie ? { Cookie: init.cookie } : {}),
...(init.token ? { Authorization: `Bearer ${init.token}` } : {}),
},
body: init.body ? JSON.stringify(init.body) : undefined,
});
const cookie = r.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
return {
status: r.status,
body: (await r.json().catch(() => null)) as Record<string, unknown>,
cookie,
};
}
const PASSWORD = 'Correct-horse-battery-9';
/** A Contrack account's read-write personal token that never expires. */
async function tokenOf(cookie: string, name: string) {
const t = await ctk('/api/auth/tokens', { method: 'POST', cookie, body: { name } });
expect(t.status, JSON.stringify(t.body)).toBe(201);
return t.body.token as string;
}
/** An admin makes a member, who signs in, sets their own password, and makes a token. */
async function member(adminCookie: string, username: string) {
const u = await ctk('/api/admin/users', {
method: 'POST',
cookie: adminCookie,
body: {
email: `${username}@example.com`,
username,
displayName: username,
temporaryPassword: 'Temporary-pass-123',
},
});
expect(u.status, JSON.stringify(u.body)).toBe(201);
const login = await ctk('/api/auth/login', {
method: 'POST',
body: { identifier: username, password: 'Temporary-pass-123' },
});
expect(login.status, JSON.stringify(login.body)).toBe(200);
const ch = await ctk('/api/auth/change-password', {
method: 'POST',
cookie: login.cookie,
body: { currentPassword: 'Temporary-pass-123', newPassword: PASSWORD },
});
expect(ch.status, JSON.stringify(ch.body)).toBeLessThan(300);
const again = await ctk('/api/auth/login', {
method: 'POST',
body: { identifier: username, password: PASSWORD },
});
return tokenOf(again.cookie, 'SelfPhoneNG');
}
describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: Req;
let dan: Req;
let sue: Req;
let danId: string;
const tok = { company: '', dan: '', sue: '' };
beforeAll(async () => {
// Contrack: the first account is the company's (and Contrack's admin); Dan and Sue are members.
const setup = await ctk('/api/auth/setup', {
method: 'POST',
body: {
email: 'company@example.com',
username: 'company',
password: PASSWORD,
displayName: 'Company',
},
});
expect(setup.status, `needs a fresh Contrack: ${JSON.stringify(setup.body)}`).toBe(201);
tok.company = await tokenOf(setup.cookie, 'SelfPhoneNG company');
tok.dan = await member(setup.cookie, 'dan');
tok.sue = await member(setup.cookie, 'sue');
for (const [token, body] of [
[tok.company, { name: 'Plumber', phones: ['+1 312 555 7777'] }],
[
tok.dan,
{
name: 'Jane Doe',
company: 'Acme',
emails: [{ email: 'jane@acme.example' }],
phones: [{ phone: '(312) 555-1234', label: 'mobile' }, 'front desk ext 123'],
},
],
] as const) {
const c = await ctk('/api/contacts', { method: 'POST', token, body });
expect(c.status, JSON.stringify(c.body)).toBe(201);
}
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as =
(cookie: string): Req =>
(method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
dan = as(await t.login('dan@example.com', 'dan-password-123'));
sue = as(await t.login('sue@example.com', 'sue-password-123'));
}, 60_000);
afterAll(() => t?.close());
it('Test, then save: Contrack answers and reads the company account', async () => {
expect(
(
await admin('POST', '/api/contacts/contrack/test', { url: LIVE, companyToken: tok.company })
).json(),
).toEqual({
ok: true,
detail: 'Contrack answers; the token reads 1 contact.',
});
expect(
(
await admin('POST', '/api/contacts/contrack/test', {
url: LIVE,
companyToken: 'ctk_wrong_token',
})
).json(),
).toEqual({
ok: false,
error: 'Contrack refused the token',
});
const r = await admin('PATCH', '/api/settings', {
'contacts.provider': 'contrack',
'contacts.contrack': {
url: LIVE,
publicUrl: 'https://contacts.example.com',
companyToken: tok.company,
},
});
expect(r.statusCode, r.body).toBe(200);
});
it('a person connects; their book and the company book come from Contrack', async () => {
expect((await dan('PUT', '/api/me/contrack', { token: 'ctk_wrong_token' })).statusCode).toBe(
400,
);
expect(
(await dan('PUT', '/api/me/contrack', { token: tok.dan })).json().contrack.connected,
).toBe(true);
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book.contrack).toEqual({
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
});
expect(book.items).toEqual([
expect.objectContaining({
book: 'mine',
name: 'Jane Doe',
company: 'Acme',
email: 'jane@acme.example',
numbers: [{ label: '', number: '13125551234' }],
url: expect.stringMatching(
/^https:\/\/contacts\.example\.com\/contact\/jane-doe-[0-9a-f]{8}$/,
),
}),
expect.objectContaining({
book: 'company',
name: 'Plumber',
numbers: [{ label: '', number: '13125557777' }],
}),
]);
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
expect(Object.fromEntries(names)).toEqual({
'13125551234': 'Jane Doe',
'13125557777': 'Plumber',
});
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
'Dan Doe',
'Sue Smith',
'Plumber',
]);
});
it('Add to contacts lands in Contrack, with its label', async () => {
const r = await dan('POST', '/api/me/contacts', {
name: 'Bob Smith',
company: 'Widgets',
email: 'bob@widgets.example',
numbers: [{ label: 'work', number: '312-555-9999' }],
});
expect(r.statusCode, r.body).toBe(201);
const id = r.json().id as string;
const full = await ctk(`/api/contacts/${id}`, { token: tok.dan });
expect(full.body).toMatchObject({
name: 'Bob Smith',
company: 'Widgets',
emails: [expect.objectContaining({ email: 'bob@widgets.example' })],
phones: [expect.objectContaining({ phone: '13125559999', label: 'work' })],
});
expect(
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toContain('Bob Smith');
});
it('a share is accepted into the recipient’s own Contrack account', async () => {
const jane = (await dan('GET', '/api/me/contacts'))
.json()
.items.find((c: { name: string }) => c.name === 'Jane Doe');
await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id], extensions: ['102'] });
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).statusCode,
).toBe(409);
await sue('PUT', '/api/me/contrack', { token: tok.sue });
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
).toEqual({ added: 1, duplicates: 0 });
const sues = (await ctk('/api/contacts?view=slim', { token: tok.sue })).body as unknown as {
name: string;
}[];
expect(sues.map((c) => c.name)).toEqual(['Jane Doe']);
// Dan's account is untouched: Sue got a copy.
const dans = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
}[];
expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe']);
});
});
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// C3: contacts in Contrack (11 §4), against a fake Contrack that answers the routes we use.
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { contactNames } from '../src/contacts/book.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
type Req = (method: string, url: string, payload?: unknown) => Promise<LightMyRequestResponse>;
type Slim = {
id: string;
name: string;
company: string | null;
emails: { email: string }[];
phones: { phone: string }[];
};
/** Contrack's slim rows, one private book per token (11 §4.1). */
function fakeContrack() {
const books: Record<string, Slim[]> = {
ctk_company: [
{
id: 'plumber-1a2b3c4d',
name: 'Plumber',
company: null,
emails: [],
phones: [{ phone: '+1 312 555 7777' }],
},
],
ctk_dan_0001: [
{
id: 'jane-doe-00000001',
name: 'Jane Doe',
company: 'Acme',
emails: [{ email: 'jane@acme.example' }],
phones: [{ phone: '(312) 555-1234' }, { phone: 'ask reception' }],
},
],
ctk_sue_0001: [],
};
let n = 0;
const server: Server = createServer((req, res) => {
const send = (status: number, body: unknown) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.url === '/healthz') return send(200, { ok: true });
const book = books[(req.headers.authorization ?? '').replace(/^Bearer /, '')];
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book);
if (req.method === 'POST' && req.url === '/api/contacts') {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
const b = JSON.parse(raw) as {
name: string;
company?: string;
emails?: { email: string }[];
phones: { phone: string }[];
};
const row = {
id: `new-${++n}`,
name: b.name,
company: b.company ?? null,
emails: b.emails ?? [],
phones: b.phones,
};
book.push(row);
send(201, row);
});
return;
}
send(404, { error: { code: 'ROUTE_NOT_FOUND', message: 'No route' } });
});
return { server, books };
}
describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let admin: Req;
let dan: Req;
let sue: Req;
let danId: string;
const fake = fakeContrack();
let url: string;
beforeAll(async () => {
await new Promise<void>((ok) => fake.server.listen(0, '127.0.0.1', ok));
url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const as =
(cookie: string): Req =>
(method, u, payload) =>
t.app.inject({
method: method as 'GET',
url: u,
payload: payload as object,
headers: { ...H, cookie },
});
admin = as(await t.login(ADMIN.email, ADMIN.password));
danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
dan = as(await t.login('dan@example.com', 'dan-password-123'));
sue = as(await t.login('sue@example.com', 'sue-password-123'));
});
afterAll(async () => {
await t?.close();
fake.server.close();
});
it('Settings → Contacts: a local address only, the company token write-only', async () => {
const far = await admin('PATCH', '/api/settings', {
'contacts.contrack': { url: 'https://contrack.example.com', companyToken: 'ctk_company' },
});
expect(far.statusCode).toBe(400);
expect(far.json().error.message).toMatch(/IP address/);
expect(
(
await admin('POST', '/api/contacts/contrack/test', { url, companyToken: 'ctk_company' })
).json(),
).toEqual({
ok: true,
detail: 'Contrack answers; the token reads 1 contact.',
});
expect(
(
await admin('POST', '/api/contacts/contrack/test', {
url: 'http://127.0.0.1:9',
companyToken: 'x',
})
).json().ok,
).toBe(false);
const r = await admin('PATCH', '/api/settings', {
'contacts.provider': 'contrack',
'contacts.contrack': {
url: `${url}/`,
publicUrl: 'https://contacts.example.com/',
companyToken: 'ctk_company',
},
});
expect(r.statusCode, r.body).toBe(200);
expect(r.json()['contacts.contrack']).toEqual({
url,
publicUrl: 'https://contacts.example.com',
hasCompanyToken: true,
});
expect(r.body).not.toContain('ctk_company');
// Saving again without a token keeps it.
await admin('PATCH', '/api/settings', {
'contacts.contrack': { url, publicUrl: 'https://contacts.example.com' },
});
expect((await admin('GET', '/api/settings')).json()['contacts.contrack'].hasCompanyToken).toBe(
true,
);
});
it('each person connects their own token; until then they see the company book', async () => {
expect((await dan('GET', '/api/me')).json().contrack).toEqual({
connected: false,
tokenPrefix: null,
publicUrl: 'https://contacts.example.com',
});
const before = (await dan('GET', '/api/me/contacts')).json();
expect(before.provider).toBe('contrack');
expect(before.contrack.connected).toBe(false);
expect(before.items.map((c: { name: string }) => c.name)).toEqual(['Plumber']);
const bad = await dan('PUT', '/api/me/contrack', { token: 'ctk_not_mine' });
expect(bad.statusCode).toBe(400);
expect(bad.json().error.message).toMatch(/refused the token/);
const ok = await dan('PUT', '/api/me/contrack', { token: 'ctk_dan_0001' });
expect(ok.json().contrack).toMatchObject({ connected: true, tokenPrefix: 'ctk_dan_0001…' });
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book.contrack).toEqual({
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
});
expect(book.items).toEqual([
{
id: 'jane-doe-00000001',
book: 'mine',
name: 'Jane Doe',
company: 'Acme',
email: 'jane@acme.example',
numbers: [{ label: '', number: '13125551234' }], // "ask reception" isn't a number
notes: null,
url: 'https://contacts.example.com/contact/jane-doe-00000001',
},
expect.objectContaining({ name: 'Plumber', book: 'company' }),
]);
});
it('adding works, editing and importing happen in Contrack', async () => {
const r = await dan('POST', '/api/me/contacts', {
name: 'Bob',
numbers: [{ label: 'mobile', number: '312-555-9999' }],
});
expect(r.statusCode, r.body).toBe(201);
expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
name: 'Bob',
phones: [{ phone: '13125559999', label: 'mobile' }],
});
expect(
(await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
).toContain('Bob');
expect(
(
await dan('PATCH', `/api/me/contacts/${'00000000-0000-4000-8000-000000000000'}`, {
name: 'x',
})
).statusCode,
).toBe(409);
expect(
(
await dan('POST', '/api/me/contacts/import', {
files: [{ name: 'a.vcf', text: 'BEGIN:VCARD\nFN:A\nEND:VCARD' }],
})
).statusCode,
).toBe(409);
});
it('names come from my Contrack book, then the company account; desk phones get the company', async () => {
const names = await contactNames(t.db, danId, ['13125551234', '13125557777']);
expect(Object.fromEntries(names)).toEqual({
'13125551234': 'Jane Doe',
'13125557777': 'Plumber',
});
expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([
'Dan Doe',
'Sue Smith',
'Plumber',
]);
});
it('a share waits until the recipient has connected Contrack, then lands in their account', async () => {
const r = await dan('POST', '/api/me/contacts/share', {
contactIds: ['jane-doe-00000001'],
extensions: ['102'],
});
expect(r.json()).toEqual({ offers: 1, people: 1 });
const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items;
expect(offer.contact).toMatchObject({
name: 'Jane Doe',
numbers: [{ label: '', number: '13125551234' }],
});
const early = await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] });
expect(early.statusCode).toBe(409);
expect(early.json().error.message).toMatch(/Connect your Contrack account/);
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toHaveLength(1);
await sue('PUT', '/api/me/contrack', { token: 'ctk_sue_0001' });
expect(
(await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(),
).toEqual({ added: 1, duplicates: 0 });
expect(fake.books.ctk_sue_0001!.map((c) => c.name)).toEqual(['Jane Doe']);
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
});
it('back to builtin: the table again, tokens kept for later', async () => {
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
const book = (await dan('GET', '/api/me/contacts')).json();
expect(book).toEqual({ items: [], provider: 'builtin', contrack: null });
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
});
+1
View File
@@ -66,6 +66,7 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => {
callReportTime: '08:00',
callReportWeekday: 1,
callReportSummaries: false,
contrack: null,
});
});
+1
View File
@@ -23,6 +23,7 @@ const EXPECTED: Record<string, Record<string, string>> = {
totp_last_step: 'bigint|YES|',
must_change_password: 'boolean|NO|false',
last_login_at: 'timestamp with time zone|YES|',
contrack_token_enc: 'text|YES|',
...ts,
},
extensions: {