A phone that only rang no longer gets a coworker's recording, transcript or summary. The voicemail webhook leaves the transcript out. The saved Contrack token only goes to its own address. The probe trusts X-Forwarded-For from a local proxy only.
This commit is contained in:
1 parent
1db5aa22dc
commit
db244b116a
22 files changed
+243
-42
No files matched your search
@@ -181,4 +181,43 @@ describe.skipIf(!hasTestDb)('call report email', () => {
|
||||
await run('2026-10-23T13:06:00Z');
|
||||
expect(t.mails[1]!.text).not.toContain('quote');
|
||||
});
|
||||
|
||||
it('no recording or summary of a call that only rang my phone (08 §7 #3)', async () => {
|
||||
await dan('PATCH', '/api/me', { callReportSummaries: true });
|
||||
const [rec] = await t.db
|
||||
.insert(recordings)
|
||||
.values({
|
||||
linkedid: 'r4',
|
||||
filePath: '2026/10/24/r4.wav',
|
||||
startedAt: new Date('2026-10-24T15:00:00Z'),
|
||||
summary: 'Secret pricing for Acme',
|
||||
aiDoneAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db
|
||||
.insert(calls)
|
||||
.values({
|
||||
linkedid: 'c4', // the ring group rang Dan; a coworker answered and talked
|
||||
direction: 'inbound',
|
||||
status: 'answered',
|
||||
startedAt: new Date('2026-10-24T15:00:00Z'),
|
||||
endedAt: new Date('2026-10-24T15:05:00Z'),
|
||||
fromNumber: '13125557777',
|
||||
talkSeconds: 300,
|
||||
recordingId: rec!.id,
|
||||
})
|
||||
.returning();
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: extId, role: 'rang' });
|
||||
await t.db
|
||||
.update(extensions)
|
||||
.set({ callReportSentAt: new Date('2026-10-24T13:00:00Z') })
|
||||
.where(eq(extensions.id, extId));
|
||||
t.mails.length = 0;
|
||||
expect(await run('2026-10-25T13:06:00Z')).toBe(1);
|
||||
const text = t.mails[0]!.text;
|
||||
expect(text).toContain('Incoming from (312) 555-7777');
|
||||
expect(text).not.toContain('Secret pricing');
|
||||
expect(text).not.toContain('recorded');
|
||||
expect(text).not.toContain('Summary not ready');
|
||||
});
|
||||
});
|
||||
@@ -460,6 +460,19 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
fake.state.readOnly.clear();
|
||||
expect(await run()).toBe(0);
|
||||
expect(jane).toHaveLength(8);
|
||||
|
||||
// Only rang Dan (a coworker took it): on his timeline, but not its recording or summary.
|
||||
const [k12] = await t.db
|
||||
.insert(calls)
|
||||
.values(call('k12', 1.5, { recordingId: done!.id }))
|
||||
.returning();
|
||||
await t.db
|
||||
.insert(callExtensions)
|
||||
.values({ callId: k12!.id, extensionId: ext!.id, role: 'rang' });
|
||||
forgetBook(danId);
|
||||
expect(await run()).toBe(1);
|
||||
expect(jane.at(-1)!.content).not.toMatch(/recorded|Acme/);
|
||||
expect(jane.at(-1)).not.toHaveProperty('actionItem');
|
||||
});
|
||||
|
||||
it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
|
||||
@@ -521,4 +534,35 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
|
||||
]);
|
||||
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
|
||||
});
|
||||
|
||||
it('the saved tokens only go to the address they were saved for (08 §7 #7)', async () => {
|
||||
// Another machine on the network that answers like Contrack, and notes what it's sent.
|
||||
const heard: string[] = [];
|
||||
const other = createServer((req, res) => {
|
||||
heard.push(String(req.headers.authorization ?? ''));
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end('[]');
|
||||
});
|
||||
await new Promise<void>((ok) => other.listen(0, '127.0.0.1', ok));
|
||||
const otherUrl = `http://127.0.0.1:${(other.address() as AddressInfo).port}`;
|
||||
try {
|
||||
await admin('POST', '/api/contacts/contrack/test', { url: otherUrl });
|
||||
expect(heard.join(' ')).not.toContain('ctk_company');
|
||||
// A new address needs its token typed again…
|
||||
const kept = await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url: otherUrl },
|
||||
});
|
||||
expect(kept.statusCode).toBe(400);
|
||||
expect(kept.json().error.message).toMatch(/token again/);
|
||||
// …and people's own tokens are for the old one: they connect again.
|
||||
const moved = await admin('PATCH', '/api/settings', {
|
||||
'contacts.contrack': { url: otherUrl, companyToken: 'ctk_new_place' },
|
||||
});
|
||||
expect(moved.statusCode, moved.body).toBe(200);
|
||||
const [d] = await t.db.select().from(users).where(eq(users.id, danId));
|
||||
expect(d!.contrackTokenEnc).toBeNull();
|
||||
} finally {
|
||||
other.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// M7-T3: a recorded call (sp_recording) is linked to its calls row and playable by admins and the
|
||||
// people on the call; delete and retention remove file and row.
|
||||
// people on the call (not a phone that only rang, 08 §7 #3); delete and retention remove file and row.
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { LightMyRequestResponse } from 'fastify';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { AmiMessage } from '../src/ami/events.ts';
|
||||
import { calls, cdr, extensions, recordings } from '../src/db/schema.ts';
|
||||
import { callExtensions, calls, cdr, extensions, recordings, users } from '../src/db/schema.ts';
|
||||
import { encrypt } from '../src/crypto/secrets.ts';
|
||||
import { cleanupRecordings } from '../src/routes/recordings.ts';
|
||||
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
|
||||
@@ -56,6 +56,8 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
|
||||
};
|
||||
});
|
||||
afterAll(() => t?.close());
|
||||
const bobId = async () =>
|
||||
(await t.db.select().from(users).where(eq(users.email, 'bob@example.com')))[0]!.id;
|
||||
|
||||
it('the recording is linked to its call, with size and length', async () => {
|
||||
const [c] = await t.db.select().from(calls);
|
||||
@@ -91,6 +93,26 @@ describe.skipIf(!hasTestDb)('call recordings', () => {
|
||||
expect((await as.alice('GET', '/api/recordings')).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('a phone that only rang (ring group, queue) gets neither audio nor transcript (08 §7 #3)', async () => {
|
||||
const [bob] = await t.db
|
||||
.insert(extensions)
|
||||
.values({
|
||||
number: '102',
|
||||
name: 'Bob',
|
||||
userId: await bobId(),
|
||||
sipPasswordEnc: encrypt('x'),
|
||||
voicemailPinEnc: encrypt('4826'),
|
||||
})
|
||||
.returning();
|
||||
const [c] = await t.db.select().from(calls);
|
||||
await t.db.insert(callExtensions).values({ callId: c!.id, extensionId: bob!.id, role: 'rang' });
|
||||
expect((await as.bob('GET', `/api/recordings/${recId}/audio`)).statusCode).toBe(403);
|
||||
expect((await as.bob('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(403);
|
||||
const mine = (await as.bob('GET', '/api/me/calls')).json();
|
||||
expect(mine.items).toEqual([expect.objectContaining({ id: c!.id, recordingId: null })]);
|
||||
expect((await as.alice('GET', `/api/recordings/${recId}/transcript`)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('retention deletes recordings past their days; delete removes file and link', async () => {
|
||||
expect(await cleanupRecordings(t.db, dir(), 3650)).toBe(0);
|
||||
expect((await as.admin('DELETE', `/api/recordings/${recId}`)).statusCode).toBe(200);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { LiveMessage } from '@selfphoneng/shared';
|
||||
import { extensions, voicemailMessages } from '../src/db/schema.ts';
|
||||
import { extensions, voicemailMessages, webhookDeliveries, webhooks } from '../src/db/schema.ts';
|
||||
import { parseCallerId, type watchVoicemail } from '../src/services/voicemail.ts';
|
||||
import { setSetting } from '../src/services/settings.ts';
|
||||
import { hasTestDb } from './db.ts';
|
||||
@@ -175,11 +175,21 @@ describe.skipIf(!hasTestDb)('voicemail watcher', () => {
|
||||
const url = `http://127.0.0.1:${(stt.address() as AddressInfo).port}/v1`;
|
||||
await setSetting(t.db, 'ai.stt', { url, model: 'whisper' });
|
||||
await setSetting(t.db, 'ai.voicemailTranscripts', true);
|
||||
await t.db
|
||||
.insert(webhooks)
|
||||
.values({ url: 'https://hooks.example.com/x', secretEnc: 'x', events: ['voicemail.new'] });
|
||||
try {
|
||||
leave('INBOX', 3, '1792300700-00000004', '<104>');
|
||||
await w.scan('101');
|
||||
const r = (await rows()).find((x) => x.msgId === '1792300700-00000004')!;
|
||||
expect(r.transcript).toBe('Please call me.');
|
||||
// A webhook goes off this server: the words stay here (10 §1, 08 §7 #6).
|
||||
const [hook] = await t.db
|
||||
.select()
|
||||
.from(webhookDeliveries)
|
||||
.where(eq(webhookDeliveries.event, 'voicemail.new'));
|
||||
expect((hook!.payload as { data: object }).data).toMatchObject({ id: r.id });
|
||||
expect((hook!.payload as { data: object }).data).not.toHaveProperty('transcript');
|
||||
expect(t.mails.at(-1)!.text).toContain(
|
||||
'What they said (automatic, may have mistakes):\nPlease call me.',
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user