import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; import { smsBlockedNumbersDb, strongerBlockReason, } from "../../src/db/smsBlockedNumbersSqlite.js"; import { closeDatabase } from "../../src/db/sqlite.js"; import { isPhoneNumberBlocked } from "../../src/services/smsService.js"; import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js"; const SECRET = "block-test-secret"; const PHONE = "+15555550123"; const OTHER = "+15555550124"; const HASH = hashPhoneNumber(PHONE, SECRET); let dir: string; let savedDataDir: string | undefined; let savedBlocked: string | undefined; beforeEach(async () => { savedDataDir = process.env.NOTIFY_DATA_DIR; savedBlocked = process.env.SMS_BLOCKED_NUMBERS; delete process.env.SMS_BLOCKED_NUMBERS; dir = await mkdtemp(path.join(tmpdir(), "sms-block-")); process.env.NOTIFY_DATA_DIR = dir; closeDatabase(); }); afterEach(async () => { closeDatabase(); if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR; else process.env.NOTIFY_DATA_DIR = savedDataDir; if (savedBlocked === undefined) delete process.env.SMS_BLOCKED_NUMBERS; else process.env.SMS_BLOCKED_NUMBERS = savedBlocked; await rm(dir, { recursive: true, force: true }); }); describe("smsBlockedNumbersDb", () => { it("blocks, reports, and unblocks by hash", async () => { assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), false); const stored = await smsBlockedNumbersDb.block({ phoneHash: HASH, phoneE164: PHONE, reason: "opt-out", detail: "keyword STOP", }); assert.equal(stored.phoneE164, PHONE); assert.equal(stored.reason, "opt-out"); assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), true); assert.equal(await smsBlockedNumbersDb.unblock(HASH), true); assert.equal(await smsBlockedNumbersDb.isBlocked(HASH), false); assert.equal(await smsBlockedNumbersDb.unblock(HASH), false); }); it("is idempotent, so a second STOP refreshes rather than fails", async () => { await smsBlockedNumbersDb.block({ phoneHash: HASH, phoneE164: PHONE, reason: "manual", detail: "first", }); await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual", detail: "second", }); const rows = await smsBlockedNumbersDb.list(); assert.equal(rows.length, 1); assert.equal(rows[0].detail, "second"); // The number is preserved when a later block omits it. assert.equal(rows[0].phoneE164, PHONE); }); it("never downgrades an opt-out to a manual block", async () => { await smsBlockedNumbersDb.block({ phoneHash: HASH, phoneE164: PHONE, reason: "opt-out", detail: "keyword STOP", }); // An operator re-blocking must not erase the record saying the handset // asked, because that record is what forbids simply lifting the block. await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual", detail: "operator", }); assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out"); }); it("upgrades a manual block when the handset actually opts out", async () => { await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual" }); await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "provider-opt-out", }); assert.equal( (await smsBlockedNumbersDb.get(HASH))?.reason, "provider-opt-out" ); await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "opt-out" }); assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out"); // ...and does not fall back down again. await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "provider-opt-out", }); assert.equal((await smsBlockedNumbersDb.get(HASH))?.reason, "opt-out"); }); it("blocks only the hashed number, not its neighbours", async () => { await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "manual" }); assert.equal( await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(OTHER, SECRET)), false ); }); }); describe("isPhoneNumberBlocked", () => { it("is false for a number on neither source", async () => { assert.equal( await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }), false ); }); it("consults the table", async () => { await smsBlockedNumbersDb.block({ phoneHash: HASH, reason: "opt-out" }); assert.equal( await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }), true ); }); it("consults SMS_BLOCKED_NUMBERS without needing a database row", async () => { process.env.SMS_BLOCKED_NUMBERS = PHONE; assert.equal( await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }), true ); assert.equal( await isPhoneNumberBlocked({ phoneE164: OTHER, phoneHash: hashPhoneNumber(OTHER, SECRET), }), false ); }); it("normalizes configured entries, so formatting does not defeat it", async () => { process.env.SMS_BLOCKED_NUMBERS = " (555) 555-0123 , 5555550124 "; assert.equal( await isPhoneNumberBlocked({ phoneE164: PHONE, phoneHash: HASH }), true ); assert.equal( await isPhoneNumberBlocked({ phoneE164: OTHER, phoneHash: hashPhoneNumber(OTHER, SECRET), }), true ); }); }); describe("strongerBlockReason", () => { it("ranks an opt-out above a provider opt-out above a manual block", () => { assert.equal(strongerBlockReason(undefined, "manual"), "manual"); assert.equal(strongerBlockReason("manual", "provider-opt-out"), "provider-opt-out"); assert.equal(strongerBlockReason("provider-opt-out", "opt-out"), "opt-out"); assert.equal(strongerBlockReason("opt-out", "manual"), "opt-out"); assert.equal(strongerBlockReason("opt-out", "provider-opt-out"), "opt-out"); assert.equal(strongerBlockReason("provider-opt-out", "manual"), "provider-opt-out"); }); });