421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
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 { smsPhoneLogDb } from "../../src/db/smsPhoneLogSqlite.js";
|
|
import { smsBlockedNumbersDb } from "../../src/db/smsBlockedNumbersSqlite.js";
|
|
import { smsRegistrationsDb } from "../../src/db/smsRegistrationsSqlite.js";
|
|
import { closeDatabase } from "../../src/db/sqlite.js";
|
|
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
|
|
import type { SmsSendResult } from "../../src/services/smsService.js";
|
|
import type { DailyAlertSearchResult } from "../../src/alertSearch/daily.js";
|
|
import type { AlertSearchDigest } from "../../src/alertSearch/digest.js";
|
|
import {
|
|
ALERT_SEARCH_SMS_LINK,
|
|
SMS_SINGLE_SEGMENT_LIMIT,
|
|
alertSearchSmsBody,
|
|
deliverAlertSearchSms,
|
|
isAlertSearchSmsEligible,
|
|
} from "../../src/alertSearch/smsNotify.js";
|
|
|
|
const USER = "did:ethr:0xsmsnotify";
|
|
const PHONE = "+15555550123";
|
|
const OTHER_PHONE = "+15555550124";
|
|
const SECRET = "sms-notify-secret";
|
|
|
|
let dir: string;
|
|
let savedDataDir: string | undefined;
|
|
let savedSecret: string | undefined;
|
|
let savedAllowlist: string | undefined;
|
|
let savedBlocked: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
savedDataDir = process.env.NOTIFY_DATA_DIR;
|
|
savedSecret = process.env.SMS_CODE_SECRET;
|
|
savedAllowlist = process.env.SMS_ALLOWED_RECIPIENT_DIDS;
|
|
delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
|
|
savedBlocked = process.env.SMS_BLOCKED_NUMBERS;
|
|
delete process.env.SMS_BLOCKED_NUMBERS;
|
|
dir = await mkdtemp(path.join(tmpdir(), "sms-notify-"));
|
|
process.env.NOTIFY_DATA_DIR = dir;
|
|
process.env.SMS_CODE_SECRET = SECRET;
|
|
closeDatabase();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
closeDatabase();
|
|
if (savedDataDir === undefined) delete process.env.NOTIFY_DATA_DIR;
|
|
else process.env.NOTIFY_DATA_DIR = savedDataDir;
|
|
if (savedSecret === undefined) delete process.env.SMS_CODE_SECRET;
|
|
else process.env.SMS_CODE_SECRET = savedSecret;
|
|
if (savedAllowlist === undefined) delete process.env.SMS_ALLOWED_RECIPIENT_DIDS;
|
|
else process.env.SMS_ALLOWED_RECIPIENT_DIDS = savedAllowlist;
|
|
if (savedBlocked === undefined) delete process.env.SMS_BLOCKED_NUMBERS;
|
|
else process.env.SMS_BLOCKED_NUMBERS = savedBlocked;
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
function digest(overrides: Partial<AlertSearchDigest> = {}): AlertSearchDigest {
|
|
const counts = {
|
|
claims: 0,
|
|
personalPlanContributions: 0,
|
|
trackedPlanUpdates: 0,
|
|
trackedPlanClaims: 0,
|
|
plansNearby: 0,
|
|
profilesNearby: 0,
|
|
};
|
|
return {
|
|
completed: true,
|
|
hasUpdates: true,
|
|
totalCount: 7,
|
|
counts,
|
|
records: {
|
|
claims: [],
|
|
personalPlanContributions: [],
|
|
trackedPlanUpdates: [],
|
|
trackedPlanClaims: [],
|
|
plansNearby: [],
|
|
profilesNearby: [],
|
|
},
|
|
endorser: { outcome: "success", completed: true },
|
|
partner: { outcome: "empty", completed: true },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function daily(
|
|
overrides: Partial<DailyAlertSearchResult> = {}
|
|
): DailyAlertSearchResult {
|
|
return {
|
|
userId: USER,
|
|
utcDay: "2026-09-05",
|
|
batchId: "sms-batch-1",
|
|
jwtSequence: 1,
|
|
endorserOutcome: "success",
|
|
partnerOutcome: "empty",
|
|
completed: true,
|
|
consumed: true,
|
|
digest: digest(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function verifyPhone(phone: string, user = USER): Promise<void> {
|
|
const now = new Date().toISOString();
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: user,
|
|
phoneE164: phone,
|
|
codeHash: "hash",
|
|
codeExpiresAt: now,
|
|
sentAt: now,
|
|
});
|
|
await smsRegistrationsDb.markVerified(user, phone);
|
|
}
|
|
|
|
describe("alertSearchSmsBody", () => {
|
|
it("fits one GSM-7 segment and carries the link and the opt-out", () => {
|
|
const body = alertSearchSmsBody(7);
|
|
assert.equal(
|
|
body,
|
|
`Gift Economies: you have 7 new updates. ${ALERT_SEARCH_SMS_LINK} Reply STOP to end.`
|
|
);
|
|
assert.ok(body.length <= SMS_SINGLE_SEGMENT_LIMIT, `length ${body.length}`);
|
|
assert.ok(
|
|
alertSearchSmsBody(999999).length <= SMS_SINGLE_SEGMENT_LIMIT
|
|
);
|
|
assert.equal(body.includes("giftopia.tech"), false);
|
|
});
|
|
});
|
|
|
|
describe("SMS notification gate", () => {
|
|
it("requires consumption, completion, and updates", () => {
|
|
assert.equal(isAlertSearchSmsEligible(daily()), true);
|
|
assert.equal(isAlertSearchSmsEligible(daily({ consumed: false })), false);
|
|
assert.equal(isAlertSearchSmsEligible(daily({ digest: null })), false);
|
|
assert.equal(
|
|
isAlertSearchSmsEligible(
|
|
daily({ digest: digest({ completed: false }) })
|
|
),
|
|
false
|
|
);
|
|
assert.equal(
|
|
isAlertSearchSmsEligible(
|
|
daily({ digest: digest({ hasUpdates: false, totalCount: 0 }) })
|
|
),
|
|
false
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("deliverAlertSearchSms", () => {
|
|
it("texts each verified number once and logs the provider id", async () => {
|
|
await verifyPhone(PHONE);
|
|
await verifyPhone(OTHER_PHONE);
|
|
const sent: string[] = [];
|
|
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async (to, body) => {
|
|
sent.push(to);
|
|
assert.match(body, /7 new updates/);
|
|
return { status: "sent", messageId: "SM-" + to };
|
|
},
|
|
});
|
|
|
|
assert.equal(result.eligible, true);
|
|
assert.equal(result.sent, 2);
|
|
assert.deepEqual(sent.sort(), [PHONE, OTHER_PHONE].sort());
|
|
|
|
const log = await smsPhoneLogDb.listByUserId(USER);
|
|
const alerts = log.filter((row) => row.action === "alert-sent");
|
|
assert.equal(alerts.length, 2);
|
|
assert.ok(alerts.every((row) => row.providerMessageId?.startsWith("SM-")));
|
|
});
|
|
|
|
it("skips unverified numbers and other DIDs' numbers", async () => {
|
|
await verifyPhone(PHONE);
|
|
const now = new Date().toISOString();
|
|
await smsRegistrationsDb.upsertPendingCode({
|
|
userId: USER,
|
|
phoneE164: OTHER_PHONE,
|
|
codeHash: "hash",
|
|
codeExpiresAt: now,
|
|
sentAt: now,
|
|
});
|
|
await verifyPhone("+15555550125", "did:ethr:0xsomeoneelse");
|
|
|
|
const sent: string[] = [];
|
|
await deliverAlertSearchSms(daily(), {
|
|
send: async (to) => {
|
|
sent.push(to);
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
assert.deepEqual(sent, [PHONE]);
|
|
});
|
|
|
|
it("sends nothing when ineligible", async () => {
|
|
await verifyPhone(PHONE);
|
|
let sends = 0;
|
|
const result = await deliverAlertSearchSms(daily({ consumed: false }), {
|
|
send: async () => {
|
|
sends += 1;
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
assert.equal(result.eligible, false);
|
|
assert.equal(sends, 0);
|
|
});
|
|
|
|
it("logs a failure without throwing and without a provider id", async () => {
|
|
await verifyPhone(PHONE);
|
|
const failure: SmsSendResult = { status: "failed", error: "carrier down" };
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async () => failure,
|
|
});
|
|
assert.equal(result.sent, 0);
|
|
assert.equal(result.failed, 1);
|
|
|
|
const log = await smsPhoneLogDb.listByUserId(USER);
|
|
assert.equal(log[0].action, "alert-send-failed");
|
|
assert.equal(log[0].detail, "carrier down");
|
|
assert.equal(log[0].providerMessageId, undefined);
|
|
});
|
|
|
|
it("records a thrown send as a failure and keeps going", async () => {
|
|
await verifyPhone(PHONE);
|
|
await verifyPhone(OTHER_PHONE);
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async (to) => {
|
|
if (to === PHONE) throw new Error("socket hang up");
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
assert.equal(result.sent, 1);
|
|
assert.equal(result.failed, 1);
|
|
});
|
|
|
|
it("does not text the same number twice in one day", async () => {
|
|
await verifyPhone(PHONE);
|
|
let sends = 0;
|
|
const send = async (): Promise<SmsSendResult> => {
|
|
sends += 1;
|
|
return { status: "sent", messageId: "SM" + sends };
|
|
};
|
|
|
|
await deliverAlertSearchSms(daily(), { send });
|
|
// A second eligible run the same day: the JWT is what normally stops this,
|
|
// and the log-backed cap is the backstop underneath it.
|
|
const second = await deliverAlertSearchSms(daily(), { send });
|
|
assert.equal(sends, 1);
|
|
assert.equal(second.sent, 0);
|
|
});
|
|
});
|
|
|
|
describe("SMS_ALLOWED_RECIPIENT_DIDS", () => {
|
|
it("sends normally to a DID on the list", async () => {
|
|
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER;
|
|
await verifyPhone(PHONE);
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async () => ({ status: "sent", messageId: "SM1" }),
|
|
});
|
|
assert.equal(result.sent, 1);
|
|
assert.equal(result.blocked, 0);
|
|
});
|
|
|
|
it("withholds an eligible digest from a DID that is not listed", async () => {
|
|
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
|
|
await verifyPhone(PHONE);
|
|
await verifyPhone(OTHER_PHONE);
|
|
let sends = 0;
|
|
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async () => {
|
|
sends += 1;
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
|
|
assert.equal(sends, 0);
|
|
// The digest was worth sending; this instance was not allowed to send it.
|
|
assert.equal(result.eligible, true);
|
|
assert.equal(result.sent, 0);
|
|
assert.equal(result.blocked, 2);
|
|
});
|
|
|
|
it("records what it withheld, one row per number", async () => {
|
|
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
|
|
await verifyPhone(PHONE);
|
|
await deliverAlertSearchSms(daily(), {
|
|
send: async () => ({ status: "sent", messageId: "SM1" }),
|
|
});
|
|
|
|
const rows = await smsPhoneLogDb.listByUserId(USER);
|
|
assert.equal(rows.length, 1);
|
|
assert.equal(rows[0].action, "recipient-not-allowed");
|
|
assert.equal(rows[0].result, "rejected");
|
|
assert.equal(rows[0].phoneE164, PHONE);
|
|
assert.equal(rows[0].detail, "SMS_ALLOWED_RECIPIENT_DIDS");
|
|
});
|
|
|
|
it("blocks every DID when the list is set but empty", async () => {
|
|
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "";
|
|
await verifyPhone(PHONE);
|
|
let sends = 0;
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async () => {
|
|
sends += 1;
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
assert.equal(sends, 0);
|
|
assert.equal(result.blocked, 1);
|
|
});
|
|
|
|
it("does not fire for an ineligible digest", async () => {
|
|
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
|
|
await verifyPhone(PHONE);
|
|
const result = await deliverAlertSearchSms(daily({ consumed: false }), {
|
|
send: async () => ({ status: "sent", messageId: "SM1" }),
|
|
});
|
|
assert.equal(result.eligible, false);
|
|
assert.equal(result.blocked, 0);
|
|
assert.deepEqual(await smsPhoneLogDb.listByUserId(USER), []);
|
|
});
|
|
});
|
|
|
|
describe("blocked numbers in the daily digest", () => {
|
|
it("skips a blocked number and still texts the others", async () => {
|
|
await verifyPhone(PHONE);
|
|
await verifyPhone(OTHER_PHONE);
|
|
await smsBlockedNumbersDb.block({
|
|
phoneHash: hashPhoneNumber(PHONE, SECRET),
|
|
reason: "opt-out",
|
|
});
|
|
|
|
const sent: string[] = [];
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async (to) => {
|
|
sent.push(to);
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(sent, [OTHER_PHONE]);
|
|
assert.equal(result.sent, 1);
|
|
assert.equal(result.blocked, 1);
|
|
|
|
const rows = await smsPhoneLogDb.listByUserId(USER);
|
|
const skipped = rows.find((row) => row.action === "number-blocked");
|
|
assert.equal(skipped?.result, "rejected");
|
|
assert.equal(skipped?.phoneE164, undefined);
|
|
});
|
|
|
|
it("sends nothing when every number is blocked", async () => {
|
|
await verifyPhone(PHONE);
|
|
process.env.SMS_BLOCKED_NUMBERS = PHONE;
|
|
let sends = 0;
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async () => {
|
|
sends += 1;
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
assert.equal(sends, 0);
|
|
assert.equal(result.blocked, 1);
|
|
assert.equal(result.sent, 0);
|
|
});
|
|
});
|
|
|
|
describe("Twilio 21610 syncs the provider's opt-out list into ours", () => {
|
|
it("blocks the number after an unsubscribed refusal", async () => {
|
|
await verifyPhone(PHONE);
|
|
const hash = hashPhoneNumber(PHONE, SECRET);
|
|
|
|
const result = await deliverAlertSearchSms(daily(), {
|
|
send: async () => ({
|
|
status: "failed",
|
|
error: "The message From/To pair violates a blacklist rule.",
|
|
code: 21610,
|
|
}),
|
|
});
|
|
assert.equal(result.failed, 1);
|
|
|
|
const stored = await smsBlockedNumbersDb.get(hash);
|
|
assert.equal(stored?.reason, "provider-opt-out");
|
|
assert.equal(stored?.detail, "Twilio 21610");
|
|
|
|
const rows = await smsPhoneLogDb.listByUserId(USER);
|
|
assert.ok(rows.some((row) => row.action === "alert-send-failed"));
|
|
assert.ok(rows.some((row) => row.action === "number-blocked"));
|
|
});
|
|
|
|
it("does not retry that number on the next eligible run", async () => {
|
|
await verifyPhone(PHONE);
|
|
await deliverAlertSearchSms(daily(), {
|
|
send: async () => ({ status: "failed", error: "unsubscribed", code: 21610 }),
|
|
});
|
|
|
|
let sends = 0;
|
|
const second = await deliverAlertSearchSms(daily(), {
|
|
send: async () => {
|
|
sends += 1;
|
|
return { status: "sent", messageId: "SM1" };
|
|
},
|
|
});
|
|
assert.equal(sends, 0);
|
|
assert.equal(second.blocked, 1);
|
|
});
|
|
|
|
it("leaves an ordinary failure unblocked, so a blip is retried", async () => {
|
|
await verifyPhone(PHONE);
|
|
await deliverAlertSearchSms(daily(), {
|
|
send: async () => ({ status: "failed", error: "carrier down", code: 30001 }),
|
|
});
|
|
assert.equal(
|
|
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
|
|
false
|
|
);
|
|
});
|
|
});
|