web: spam check in call history
ci / check (push) Failing after 1m9s
ci / images (push) Successful in 44s

SD5 (14 §9.5): each incoming call keeps what the spam check knew when it
ended (saved answers and the FTC list only, no new lookup), so the badge
stays in call history after the saved answer expires. Hidden when the
viewer's contacts name the number. New admin guide page, spam-check.md.

Follow-up: the owner's checks with real keys and a real call (14 §9).
This commit is contained in:
iamdoubz committed 2026-10-27 18:16:24 -05:00
1 parent e05065599e
commit 6b0c5aa827
11 files changed
+185 -14

No files matched your search

+41 -1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SD1, SD2, SD4: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
// SD1, SD2, SD4, SD5: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch.
import { readFileSync } from 'node:fs';
import { eq, like, sql } from 'drizzle-orm';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -7,6 +8,7 @@ import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared';
import { encrypt } from '../src/crypto/secrets.ts';
import {
auditLog,
cdr,
ftcComplaints,
settings as settingsTable,
spamLookups,
@@ -14,7 +16,9 @@ import {
users,
} from '../src/db/schema.ts';
import type { ActiveCall } from '@selfphoneng/shared';
import type { AmiMessage } from '../src/ami/events.ts';
import { callerNameToken } from '../src/contacts/callername.ts';
import { aggregateCall, toCdrRow } from '../src/services/cdr.ts';
import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts';
import { getSetting, setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
@@ -369,6 +373,42 @@ describe.skipIf(!hasTestDb)('spam check', () => {
await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } });
expect(await deskName(REPORTED)).toBe('JUNK');
});
it('call history keeps what the check knew, after the cache expires; contacts first', async () => {
const fixture = readFileSync(
new URL('./fixtures/cdr/inbound-user.json', import.meta.url),
'utf8',
);
const callFrom = async (number: string) => {
const events = (JSON.parse(fixture.replaceAll('13125559999', number)) as AmiMessage[]).map(
(e) => ({ ...e, LinkedID: `${number}-${String(e.LinkedID)}` }),
);
for (const e of events) await t.db.insert(cdr).values(toCdrRow(e));
await aggregateCall(t.db, String(events[0]!.LinkedID));
};
await checkNumber(d(), SPAMMER);
urls.length = 0;
for (const n of [SPAMMER, REPORTED, SHADY, CLEAN]) await callFrom(n);
expect(urls).toEqual([]); // cachedOnly: CLEAN isn't asked
await t.db.delete(spamLookups);
const spamOf = async (n: string) =>
(await admin('GET', '/api/calls?direction=inbound'))
.json()
.items.find((c: { fromNumber: string }) => c.fromNumber === n)?.spam;
expect(await spamOf(SPAMMER)).toMatchObject({
risk: 'high',
provider: 'ipqs',
info: { spammer: true },
});
expect(await spamOf(REPORTED)).toMatchObject({
risk: 'high',
provider: 'ftc',
info: { complaints: 3 },
});
expect(await spamOf(SHADY)).toBeNull(); // the admin's contact
// Not in the cache, never reported: low (the page shows nothing for it).
expect(await spamOf(CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' });
});
});
describe('Settings → Spam check', () => {