Files
notification-wakeup-service/test/routes/notifySms.test.ts
T

1362 lines
45 KiB
TypeScript

import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import express, { type RequestHandler } from "express";
import { smsAlertAuthorizationDb } from "../../src/db/smsAlertAuthorizationSqlite.js";
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 { utcCalendarDay } from "../../src/services/alertAuthorization.js";
import type { SmsSendResult } from "../../src/services/smsService.js";
import { twilioSignatureFor } from "../../src/services/twilioSignature.js";
import { hashPhoneNumber } from "../../src/util/smsVerificationCode.js";
import { createNotifySmsRouter } from "../../src/routes/notifySms.js";
const SECRET = "route-test-secret";
const USER = "did:ethr:0xrouteuser";
const PHONE = "+15555550123";
const DAY_SECONDS = 86400;
/** The hour every test batch asks for, and the UTC time it reduces to. */
const NOTIFY_HOUR = 12;
const NOTIFY_MINUTE = 0;
const NOTIFY_LABEL = "12:00";
const ENV_KEYS = [
"SMS_ENABLED",
"SMS_CODE_SECRET",
"SMS_MAX_DIDS_PER_PHONE",
"SMS_CODE_MAX_ATTEMPTS",
"SMS_CODE_TTL_SEC",
"SMS_DEV_ECHO_CODE",
"SMS_REQUIRE_ACTION_CLAIM",
"SMS_ACTION_JWT_MAX_AGE_SEC",
"SMS_ALLOWED_RECIPIENT_DIDS",
"SMS_BLOCKED_NUMBERS",
"TWILIO_AUTH_TOKEN",
"TWILIO_WEBHOOK_URL",
"NODE_ENV",
"NOTIFY_DATA_DIR",
] as const;
let dir: string;
let savedEnv: Record<string, string | undefined>;
let sent: { to: string; body: string }[];
let sendResult: SmsSendResult;
let jwtCounter: number;
beforeEach(async () => {
savedEnv = {};
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
dir = await mkdtemp(path.join(tmpdir(), "notify-sms-routes-"));
process.env.NOTIFY_DATA_DIR = dir;
process.env.SMS_ENABLED = "true";
process.env.SMS_CODE_SECRET = SECRET;
process.env.NODE_ENV = "test-local";
closeDatabase();
sent = [];
sendResult = { status: "sent", messageId: "SM1" };
jwtCounter = 0;
});
afterEach(async () => {
closeDatabase();
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
await rm(dir, { recursive: true, force: true });
});
type Claim = { action: string; phoneNumber?: string };
/**
* Stands in for requireAuth + requireEndorserAuth: sets exactly what those
* stages set, so requireSmsActionJwt is exercised for real.
*/
function authStub(): RequestHandler {
return (req, _res, next) => {
const header = req.get("X-Test-Auth");
if (header === undefined) {
next();
return;
}
const parsed = JSON.parse(header) as {
did?: string;
jwt?: string;
payload?: Record<string, unknown>;
};
const did = parsed.did ?? USER;
req.did = did;
req.jwt = parsed.jwt ?? "token";
req.auth = {
did,
jwt: parsed.jwt ?? "token",
payload: parsed.payload ?? {},
};
next();
};
}
function testAuthHeader(input: {
did?: string;
claim?: Claim | null;
jwt?: string;
iat?: number;
exp?: number;
}): string {
jwtCounter += 1;
const payload: Record<string, unknown> = {
iss: input.did ?? USER,
iat: input.iat ?? Math.floor(Date.now() / 1000),
};
if (input.exp !== undefined) payload.exp = input.exp;
if (input.claim !== null) {
payload.claim = {
"@context": "https://giftopia.tech",
"@type": "SmsNotificationAction",
...(input.claim ?? { action: "register-phone", phoneNumber: PHONE }),
};
}
return JSON.stringify({
did: input.did ?? USER,
jwt: input.jwt ?? `bearer-${jwtCounter}`,
payload,
});
}
type Server = { url: string; close: () => Promise<void> };
async function startServer(): Promise<Server> {
const app = express();
app.use(express.json());
app.use(
"/notify-sms",
createNotifySmsRouter({
authStages: [authStub()],
sender: async (to, body) => {
sent.push({ to, body });
return sendResult;
},
})
);
const server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const port = (server.address() as AddressInfo).port;
return {
url: `http://127.0.0.1:${port}`,
close: () =>
new Promise<void>((resolve) => server.close(() => resolve())),
};
}
let server: Server;
beforeEach(async () => {
server = await startServer();
});
afterEach(async () => {
await server.close();
});
type CallInput = {
method?: string;
path?: string;
body?: unknown;
auth?: Parameters<typeof testAuthHeader>[0] | "none";
};
async function call(
input: CallInput
): Promise<{ status: number; body: Record<string, unknown> }> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (input.auth !== "none") {
headers["X-Test-Auth"] = testAuthHeader(input.auth ?? {});
}
const response = await fetch(server.url + (input.path ?? "/notify-sms/phone"), {
method: input.method ?? "POST",
headers,
body: input.body === undefined ? undefined : JSON.stringify(input.body),
});
const text = await response.text();
return {
status: response.status,
body: text.length > 0 ? JSON.parse(text) : {},
};
}
/** POST then PUT with the echoed code, leaving the DID verified. */
async function registerAndVerify(did = USER, phone = PHONE): Promise<void> {
process.env.SMS_DEV_ECHO_CODE = "true";
const posted = await call({
body: { phoneNumber: phone },
auth: { did, claim: { action: "register-phone", phoneNumber: phone } },
});
assert.equal(posted.status, 200, JSON.stringify(posted.body));
const code = posted.body.devCode as string;
const put = await call({
method: "PUT",
body: { phoneNumber: phone, code },
auth: { did, claim: { action: "verify-phone", phoneNumber: phone } },
});
assert.equal(put.status, 200, JSON.stringify(put.body));
delete process.env.SMS_DEV_ECHO_CODE;
}
describe("notify-sms enable flag", () => {
it("returns 503 for every route when SMS is off", async () => {
process.env.SMS_ENABLED = "false";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 503);
assert.equal(result.body.error, "SMS_DISABLED");
assert.equal(sent.length, 0);
});
});
describe("SMS_ALLOWED_RECIPIENT_DIDS on POST /notify-sms/phone", () => {
it("registers normally for a DID on the list", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER;
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 200);
assert.equal(sent.length, 1);
});
it("refuses a DID that is not listed, before any send", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "did:ethr:0xsomeoneelse";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_RECIPIENT_NOT_ALLOWED");
assert.equal(sent.length, 0);
assert.equal(await smsRegistrationsDb.get(USER, PHONE), undefined);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.equal(log[0].action, "recipient-not-allowed");
assert.equal(log[0].result, "rejected");
});
it("matches the list case-insensitively", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = USER.toUpperCase();
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 200);
});
it("refuses everyone when the list is set but empty", async () => {
process.env.SMS_ALLOWED_RECIPIENT_DIDS = "";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(sent.length, 0);
});
});
describe("POST /notify-sms/phone", () => {
it("sends a code and stores only its hash", async () => {
const result = await call({ body: { phoneNumber: "(555) 555-0123" } });
assert.equal(result.status, 200);
assert.equal(result.body.verified, false);
assert.equal(result.body.phoneNumber, "+1555*****23");
assert.equal(typeof result.body.expiresAt, "string");
assert.equal(result.body.devCode, undefined);
assert.equal(sent.length, 1);
assert.equal(sent[0].to, PHONE);
const code = /(\d{6})/.exec(sent[0].body)?.[1];
assert.ok(code);
const row = await smsRegistrationsDb.get(USER, PHONE);
assert.equal(row?.verified, false);
assert.notEqual(row?.codeHash, code);
assert.equal(row?.codeHash?.includes(code as string), false);
});
it("rejects a number that does not normalize", async () => {
const result = await call({ body: { phoneNumber: "not a phone" } });
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_PHONE_INVALID");
assert.equal(sent.length, 0);
});
it("is a no-op on a number this DID already verified", async () => {
await registerAndVerify();
sent = [];
const result = await call({
body: { phoneNumber: PHONE },
auth: { claim: { action: "register-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.verified, true);
assert.equal(sent.length, 0);
});
it("echoes the code only under test-local with the flag set", async () => {
process.env.SMS_DEV_ECHO_CODE = "true";
const withFlag = await call({ body: { phoneNumber: PHONE } });
assert.match(withFlag.body.devCode as string, /^\d{6}$/);
process.env.NODE_ENV = "production";
const inProduction = await call({
body: { phoneNumber: "+15555550124" },
auth: { claim: { action: "register-phone", phoneNumber: "+15555550124" } },
});
assert.equal(inProduction.body.devCode, undefined);
});
it("throttles code sends per phone across DIDs", async () => {
for (let i = 0; i < 3; i += 1) {
const ok = await call({
body: { phoneNumber: PHONE },
auth: { did: `did:ethr:0xu${i}` },
});
assert.equal(ok.status, 200, JSON.stringify(ok.body));
}
const blocked = await call({
body: { phoneNumber: PHONE },
auth: { did: "did:ethr:0xu4" },
});
assert.equal(blocked.status, 429);
assert.equal(blocked.body.error, "SMS_CODE_RATE_LIMITED");
assert.equal(sent.length, 3);
});
it("counts a failed send against the throttle", async () => {
sendResult = { status: "failed", error: "carrier down" };
const failed = await call({ body: { phoneNumber: PHONE } });
assert.equal(failed.status, 502);
assert.equal(failed.body.error, "SMS_CODE_SEND_FAILED");
const log = await smsPhoneLogDb.listByUserId(USER);
assert.ok(log.some((row) => row.action === "code-send-failed"));
});
it("refuses at the DID limit and discloses no identities", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "2";
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
const blocked = await call({
body: { phoneNumber: PHONE },
auth: { did: "did:ethr:0xc" },
});
assert.equal(blocked.status, 409);
assert.equal(blocked.body.error, "SMS_PHONE_DID_LIMIT");
assert.equal(blocked.body.limit, 2);
assert.equal(blocked.body.verifiedCount, 2);
assert.equal(blocked.body.dids, undefined);
const log = await smsPhoneLogDb.listByUserId("did:ethr:0xc");
assert.equal(log[0].action, "did-limit-blocked");
});
it("does not count unverified rows from other DIDs toward the limit", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "1";
await call({ body: { phoneNumber: PHONE }, auth: { did: "did:ethr:0xa" } });
await call({ body: { phoneNumber: PHONE }, auth: { did: "did:ethr:0xb" } });
const third = await call({
body: { phoneNumber: PHONE },
auth: { did: "did:ethr:0xc" },
});
assert.equal(third.status, 200, JSON.stringify(third.body));
});
});
describe("PUT /notify-sms/phone", () => {
async function post(did = USER): Promise<string> {
process.env.SMS_DEV_ECHO_CODE = "true";
const result = await call({
body: { phoneNumber: PHONE },
auth: { did },
});
delete process.env.SMS_DEV_ECHO_CODE;
return result.body.devCode as string;
}
it("verifies with the right code and clears the stored hash", async () => {
const code = await post();
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.verified, true);
const row = await smsRegistrationsDb.get(USER, PHONE);
assert.equal(row?.verified, true);
assert.equal(row?.codeHash, undefined);
assert.equal(row?.codeAttempts, 0);
});
it("counts a wrong code and reports what is left", async () => {
await post();
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_CODE_MISMATCH");
assert.equal(result.body.attemptsRemaining, 4);
});
it("reports an expired code as expired", async () => {
process.env.SMS_CODE_TTL_SEC = "1";
const code = await post();
await new Promise((resolve) => setTimeout(resolve, 1100));
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_CODE_EXPIRED");
});
it("reports no pending code for a number never registered", async () => {
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "123456" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 400);
assert.equal(result.body.error, "SMS_CODE_EXPIRED");
});
it("exhausts attempts and clears the code", async () => {
process.env.SMS_CODE_MAX_ATTEMPTS = "2";
await post();
for (let i = 0; i < 2; i += 1) {
await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
}
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 429);
assert.equal(result.body.error, "SMS_CODE_ATTEMPTS_EXHAUSTED");
assert.equal(
(await smsRegistrationsDb.get(USER, PHONE))?.codeHash,
undefined
);
});
it("returns verified without counting an attempt when already verified", async () => {
await registerAndVerify();
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.verified, true);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.codeAttempts, 0);
});
it("refuses the sixth DID at PUT even though its POST was accepted", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "2";
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
// This POST is only accepted because the third DID registered before the
// other two verified; the cap is what holds at PUT.
process.env.SMS_MAX_DIDS_PER_PHONE = "9";
const code = await post("did:ethr:0xc");
process.env.SMS_MAX_DIDS_PER_PHONE = "2";
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: {
did: "did:ethr:0xc",
claim: { action: "verify-phone", phoneNumber: PHONE },
},
});
assert.equal(result.status, 409);
assert.equal(result.body.error, "SMS_PHONE_DID_LIMIT");
assert.deepEqual(result.body.dids, ["did:ethr:0xa", "did:ethr:0xb"]);
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xc", PHONE))?.verified,
false
);
});
it("consumes the code on a limit rejection so a second PUT cannot re-ask", async () => {
process.env.SMS_MAX_DIDS_PER_PHONE = "1";
await registerAndVerify("did:ethr:0xa");
process.env.SMS_MAX_DIDS_PER_PHONE = "9";
const code = await post("did:ethr:0xc");
process.env.SMS_MAX_DIDS_PER_PHONE = "1";
const first = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: {
did: "did:ethr:0xc",
claim: { action: "verify-phone", phoneNumber: PHONE },
},
});
assert.equal(first.status, 409);
assert.ok(Array.isArray(first.body.dids));
const second = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: {
did: "did:ethr:0xc",
claim: { action: "verify-phone", phoneNumber: PHONE },
},
});
assert.equal(second.status, 400);
assert.equal(second.body.error, "SMS_CODE_EXPIRED");
assert.equal(second.body.dids, undefined);
});
});
describe("GET /notify-sms/phone", () => {
it("lists this DID's own registrations in full", async () => {
await registerAndVerify();
const result = await call({
method: "GET",
body: undefined,
auth: { claim: { action: "list-phones" } },
});
assert.equal(result.status, 200);
const phones = result.body.phones as Record<string, unknown>[];
assert.equal(phones.length, 1);
assert.equal(phones[0].phoneNumber, PHONE);
assert.equal(phones[0].verified, true);
assert.equal(result.body.dids, undefined);
});
it("returns an empty list rather than a 404", async () => {
const result = await call({
method: "GET",
auth: { claim: { action: "list-phones" } },
});
assert.equal(result.status, 200);
assert.deepEqual(result.body.phones, []);
});
it("discloses the DIDs on a number only to a DID verified on it", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
const query = `/notify-sms/phone?phoneNumber=${encodeURIComponent(PHONE)}`;
const allowed = await call({
method: "GET",
path: query,
auth: {
did: "did:ethr:0xa",
claim: { action: "list-phones", phoneNumber: PHONE },
},
});
assert.equal(allowed.status, 200);
assert.deepEqual(allowed.body.dids, ["did:ethr:0xa", "did:ethr:0xb"]);
const refused = await call({
method: "GET",
path: query,
auth: {
did: "did:ethr:0xstranger",
claim: { action: "list-phones", phoneNumber: PHONE },
},
});
assert.equal(refused.status, 403);
assert.equal(refused.body.error, "SMS_PHONE_NOT_VERIFIED_BY_CALLER");
assert.equal(refused.body.dids, undefined);
assert.equal(refused.body.verifiedCount, undefined);
});
it("refuses an unverified registration of the number just the same", async () => {
await registerAndVerify("did:ethr:0xa");
await call({ body: { phoneNumber: PHONE }, auth: { did: "did:ethr:0xb" } });
const result = await call({
method: "GET",
path: `/notify-sms/phone?phoneNumber=${encodeURIComponent(PHONE)}`,
auth: {
did: "did:ethr:0xb",
claim: { action: "list-phones", phoneNumber: PHONE },
},
});
assert.equal(result.status, 403);
});
});
describe("DELETE /notify-sms/phone", () => {
it("removes the row, scrubs the number from the log, and keeps the hash", async () => {
await registerAndVerify();
const result = await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, true);
assert.equal(await smsRegistrationsDb.get(USER, PHONE), undefined);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.ok(log.length > 1);
const expectedHash = hashPhoneNumber(PHONE, SECRET);
for (const row of log) {
assert.equal(row.phoneE164, undefined);
assert.equal(row.phoneHash, expectedHash);
}
assert.ok(log.some((row) => row.action === "code-sent"));
assert.ok(log.some((row) => row.action === "deleted"));
});
it("accepts the query parameter, for proxies that drop DELETE bodies", async () => {
await registerAndVerify();
const result = await call({
method: "DELETE",
path: `/notify-sms/phone?phoneNumber=${encodeURIComponent(PHONE)}`,
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, true);
});
it("reports deleted:false rather than an error for an unknown number", async () => {
const result = await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, false);
});
it("leaves another DID's registration of the same number alone", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: {
did: "did:ethr:0xa",
claim: { action: "delete-phone", phoneNumber: PHONE },
},
});
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xb", PHONE))?.verified,
true
);
const theirLog = await smsPhoneLogDb.listByUserId("did:ethr:0xb");
assert.ok(theirLog.every((row) => row.phoneE164 === PHONE));
});
});
describe("POST /notify-sms/alert-authorization", () => {
function delegatedJwt(payload: Record<string, unknown>): string {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode(payload)}.sig`;
}
/** 100 consecutive UTC days, each JWT valid for the whole of its own day. */
function batch(userId = USER): Record<string, unknown> {
const jwts = [];
const firstDay =
Math.floor(Date.now() / 1000 / DAY_SECONDS) * DAY_SECONDS;
for (let i = 0; i < 100; i += 1) {
const nbf = firstDay + i * DAY_SECONDS;
const exp = nbf + DAY_SECONDS;
jwts.push({
sequence: i + 1,
day: utcCalendarDay(nbf),
jwt: delegatedJwt({ iss: userId, nbf, exp }),
nbf,
exp,
});
}
return {
batchId: "sms-batch-1",
notifyHourUtc: NOTIFY_HOUR,
notifyMinuteUtc: NOTIFY_MINUTE,
jwts,
};
}
const authorizeClaim = { action: "authorize-alert-search" };
it("refuses without a verified phone", async () => {
const result = await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 409);
assert.equal(result.body.error, "SMS_NO_VERIFIED_PHONE");
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
});
it("stores a batch of 100 into the SMS tables", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.storedCount, 100);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
const log = await smsPhoneLogDb.listByUserId(USER);
const stored = log.find(
(row) => row.action === "alert-authorization-stored"
);
assert.equal(
stored?.detail,
`batchId=sms-batch-1 notifyUtc=${NOTIFY_LABEL}`
);
});
it("accepts PUT as an alias for the same handler", async () => {
await registerAndVerify();
const result = await call({
method: "PUT",
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.storedCount, 100);
});
it("replaces unused rows and leaves consumed ones", async () => {
await registerAndVerify();
await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
const today = utcCalendarDay(Math.floor(Date.now() / 1000));
const first = await smsAlertAuthorizationDb.getUnusedForDay(USER, today);
assert.ok(first);
await smsAlertAuthorizationDb.consumeUnusedJwt({
id: first.id,
userId: USER,
});
const again = await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(again.status, 200);
assert.equal(again.body.storedCount, 100);
assert.equal(
(await smsAlertAuthorizationDb.getJwtById(first.id))?.status,
"consumed"
);
});
it("stores the hour and minute and echoes both back", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc: 0, notifyMinuteUtc: 30 },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.notifyHourUtc, 0);
assert.equal(result.body.notifyMinuteUtc, 30);
const stored = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(stored?.notifyHourUtc, 0);
assert.equal(stored?.notifyMinuteUtc, 30);
const log = await smsPhoneLogDb.listByUserId(USER);
const row = log.find((entry) => entry.action === "alert-authorization-stored");
// Zero-padded in the log even though the request carried bare integers.
assert.equal(row?.detail, "batchId=sms-batch-1 notifyUtc=00:30");
});
it("keeps midnight distinct from absent, since 0 is a real hour", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc: 0, notifyMinuteUtc: 0 },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.notifyHourUtc, 0);
assert.equal(result.body.notifyMinuteUtc, 0);
const stored = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(stored?.notifyHourUtc, 0);
assert.equal(stored?.notifyMinuteUtc, 0);
});
it("stores an optional IANA timezone alongside the hour", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), timezone: "America/Denver" },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.timezone, "America/Denver");
const stored = await smsAlertAuthorizationDb.getLatestBatch(USER);
assert.equal(stored?.timezone, "America/Denver");
// Recorded only: the stored hour is still what a send would consult.
assert.equal(stored?.notifyHourUtc, NOTIFY_HOUR);
});
it("keeps the timezone optional even though the hour is not", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: batch(),
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.notifyHourUtc, NOTIFY_HOUR);
assert.equal(result.body.timezone, null);
});
it("treats an omitted or null timezone as none", async () => {
await registerAndVerify();
for (const body of [batch(), { ...batch(), timezone: null }]) {
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.timezone, null);
assert.equal(
(await smsAlertAuthorizationDb.getLatestBatch(USER))?.timezone,
undefined
);
}
});
it("rejects a timezone Intl cannot resolve", async () => {
await registerAndVerify();
for (const timezone of ["Mars/Olympus", "Denver", "", " ", -7]) {
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), timezone },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, JSON.stringify(timezone));
assert.ok(
(result.body.details as string[]).some((line) =>
line.startsWith("timezone must be")
),
JSON.stringify(result.body.details)
);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
}
});
it("requires both halves, and rejects one arriving without the other", async () => {
await registerAndVerify();
const jwts = (batch() as { jwts: unknown }).jwts;
const cases: [string, Record<string, unknown>, string[]][] = [
["neither", { batchId: "b", jwts }, ["notifyHourUtc", "notifyMinuteUtc"]],
[
"hour only",
{ batchId: "b", notifyHourUtc: 18, jwts },
["notifyMinuteUtc"],
],
[
"minute only",
{ batchId: "b", notifyMinuteUtc: 30, jwts },
["notifyHourUtc"],
],
[
"nulls",
{ batchId: "b", notifyHourUtc: null, notifyMinuteUtc: null, jwts },
["notifyHourUtc", "notifyMinuteUtc"],
],
];
for (const [label, body, missing] of cases) {
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, label);
const details = result.body.details as string[];
for (const field of missing) {
assert.ok(
details.some((line) => line.startsWith(`${field} is required`)),
`${label}: expected ${field}, got ${JSON.stringify(details)}`
);
}
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
}
});
it("rejects an hour or minute outside its range, or not an integer", async () => {
await registerAndVerify();
const cases: [string, unknown, unknown][] = [
["hour 24", 24, 0],
["hour -1", -1, 0],
["minute 60", 12, 60],
["minute -1", 12, -1],
["fractional hour", 12.5, 0],
["string hour", "12", 0],
["string minute", 12, "30"],
["boolean hour", true, 0],
["object minute", 12, {}],
];
// NaN is absent from this list on purpose: JSON.stringify turns it into
// null, so it reaches the route as a missing field, not a malformed one.
for (const [label, notifyHourUtc, notifyMinuteUtc] of cases) {
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc, notifyMinuteUtc },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, label);
assert.ok(
(result.body.details as string[]).some((line) =>
/^notify(Hour|Minute)Utc must be an integer/.test(line)
),
`${label}: ${JSON.stringify(result.body.details)}`
);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
}
});
it("accepts the ends of both ranges", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { ...batch(), notifyHourUtc: 23, notifyMinuteUtc: 59 },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.notifyHourUtc, 23);
assert.equal(result.body.notifyMinuteUtc, 59);
});
it("rejects a JWT whose window does not cover its whole UTC day", async () => {
await registerAndVerify();
const body = batch() as { jwts: { nbf: number; exp: number }[] };
// An hour short at each end: usable at noon, useless at midnight.
body.jwts[0].nbf += 3600;
body.jwts[0].exp -= 3600;
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, JSON.stringify(result.body));
assert.ok(
(result.body.details as string[]).some((line) =>
line.includes("must be valid for all of UTC day")
),
JSON.stringify(result.body.details)
);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
});
it("rejects a day that is not a real date", async () => {
await registerAndVerify();
const body = batch() as { jwts: { day: string }[] };
body.jwts[0].day = "2026-02-30";
const result = await call({
path: "/notify-sms/alert-authorization",
body,
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400, JSON.stringify(result.body));
assert.ok(
(result.body.details as string[]).some((line) =>
line.includes("must be a real UTC date")
),
JSON.stringify(result.body.details)
);
});
it("rejects a malformed batch after the phone gate passes", async () => {
await registerAndVerify();
const result = await call({
path: "/notify-sms/alert-authorization",
body: { batchId: "b", jwts: [] },
auth: { claim: authorizeClaim },
});
assert.equal(result.status, 400);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
});
});
describe("DELETE /notify-sms/alert-authorization", () => {
function delegatedJwt(payload: Record<string, unknown>): string {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString("base64url");
return `${encode({ alg: "ES256K", typ: "JWT" })}.${encode(payload)}.sig`;
}
/** 100 consecutive UTC days, each JWT valid for the whole of its own day. */
function batch(userId = USER): Record<string, unknown> {
const jwts = [];
const firstDay =
Math.floor(Date.now() / 1000 / DAY_SECONDS) * DAY_SECONDS;
for (let i = 0; i < 100; i += 1) {
const nbf = firstDay + i * DAY_SECONDS;
const exp = nbf + DAY_SECONDS;
jwts.push({
sequence: i + 1,
day: utcCalendarDay(nbf),
jwt: delegatedJwt({ iss: userId, nbf, exp }),
nbf,
exp,
});
}
return {
batchId: "sms-batch-1",
notifyHourUtc: NOTIFY_HOUR,
notifyMinuteUtc: NOTIFY_MINUTE,
jwts,
};
}
const revokeClaim = { action: "revoke-alert-search" };
async function authorize(did = USER): Promise<void> {
const stored = await call({
path: "/notify-sms/alert-authorization",
body: batch(did),
auth: { did, claim: { action: "authorize-alert-search" } },
});
assert.equal(stored.status, 200, JSON.stringify(stored.body));
}
it("removes every batch and JWT, and stops the scheduler listing the DID", async () => {
await registerAndVerify();
await authorize();
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.deletedBatches, 1);
assert.equal(result.body.deletedJwts, 100);
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 0);
assert.deepEqual(await smsAlertAuthorizationDb.listDistinctUserIds(), []);
});
it("keeps the verified phone, so re-authorizing needs no new code", async () => {
await registerAndVerify();
await authorize();
await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
await authorize();
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
});
it("records the revocation in the phone log", async () => {
await registerAndVerify();
await authorize();
await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
const log = await smsPhoneLogDb.listByUserId(USER);
const row = log.find((entry) => entry.action === "alert-authorization-deleted");
assert.equal(row?.result, "ok");
assert.equal(row?.detail, "batches=1 jwts=100");
});
it("touches only the calling DID's inventory", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb", "+15555550124");
await authorize("did:ethr:0xa");
await authorize("did:ethr:0xb");
await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { did: "did:ethr:0xa", claim: revokeClaim },
});
assert.equal(await smsAlertAuthorizationDb.countUnused("did:ethr:0xa"), 0);
assert.equal(await smsAlertAuthorizationDb.countUnused("did:ethr:0xb"), 100);
});
it("succeeds with zero counts when there was nothing to remove", async () => {
await registerAndVerify();
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal(result.status, 200);
assert.equal(result.body.deletedBatches, 0);
assert.equal(result.body.deletedJwts, 0);
});
it("works for a DID that has already deleted its phone", async () => {
await registerAndVerify();
await authorize();
await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: revokeClaim },
});
assert.equal(result.status, 200, JSON.stringify(result.body));
assert.equal(result.body.deletedJwts, 100);
});
it("refuses a claim that authorizes a different action", async () => {
await registerAndVerify();
await authorize();
const result = await call({
method: "DELETE",
path: "/notify-sms/alert-authorization",
auth: { claim: { action: "authorize-alert-search" } },
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_WRONG_ACTION");
assert.equal(await smsAlertAuthorizationDb.countUnused(USER), 100);
});
});
describe("POST /notify-sms/inbound", () => {
const TOKEN = "twilio-auth-token";
async function inbound(
params: Record<string, string>,
signature?: string
): Promise<{ status: number; text: string }> {
const url = "https://example.test/notify-sms/inbound";
process.env.TWILIO_AUTH_TOKEN = TOKEN;
process.env.TWILIO_WEBHOOK_URL = url;
const response = await fetch(server.url + "/notify-sms/inbound", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Twilio-Signature":
signature ?? twilioSignatureFor(TOKEN, url, params),
},
body: new URLSearchParams(params).toString(),
});
return { status: response.status, text: await response.text() };
}
it("refuses a request whose signature does not match", async () => {
await registerAndVerify();
const result = await inbound({ From: PHONE, Body: "STOP" }, "wrong");
assert.equal(result.status, 403);
assert.equal(
(await smsRegistrationsDb.get(USER, PHONE))?.verified,
true
);
});
it("switches off every registration of the number on STOP", async () => {
await registerAndVerify("did:ethr:0xa");
await registerAndVerify("did:ethr:0xb");
const result = await inbound({ From: PHONE, Body: " stop " });
assert.equal(result.status, 200);
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xa", PHONE))?.verified,
false
);
assert.equal(
(await smsRegistrationsDb.get("did:ethr:0xb", PHONE))?.verified,
false
);
assert.equal(await smsRegistrationsDb.countVerifiedForPhone(PHONE), 0);
});
it("answers HELP with a fixed reply and changes nothing", async () => {
await registerAndVerify();
const result = await inbound({ From: PHONE, Body: "HELP" });
assert.equal(result.status, 200);
assert.match(result.text, /Reply STOP to end/);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, true);
});
it("makes the opt-out durable, so the number cannot simply re-register", async () => {
await registerAndVerify();
await inbound({ From: PHONE, Body: "STOP" });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
true
);
// Before the block existed this POST succeeded and texted a fresh code.
sent = [];
const retry = await call({ body: { phoneNumber: PHONE } });
assert.equal(retry.status, 403);
assert.equal(retry.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
});
it("lifts the block on START, and the number can register again", async () => {
await registerAndVerify();
await inbound({ From: PHONE, Body: "STOP" });
await inbound({ From: PHONE, Body: "START" });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
false
);
sent = [];
const retry = await call({ body: { phoneNumber: PHONE } });
assert.equal(retry.status, 200);
assert.equal(sent.length, 1);
// START lifts the block; it does not restore verification.
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, false);
});
it("tells START to register again rather than re-verifying", async () => {
await registerAndVerify();
await inbound({ From: PHONE, Body: "STOP" });
const result = await inbound({ From: PHONE, Body: "START" });
assert.match(result.text, /Register your number again/);
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, false);
});
});
describe("blocked numbers", () => {
const blockedHash = () => hashPhoneNumber(PHONE, SECRET);
it("refuses registration of a blocked number, before any send", async () => {
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
phoneE164: PHONE,
reason: "opt-out",
});
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
assert.equal(await smsRegistrationsDb.get(USER, PHONE), undefined);
const log = await smsPhoneLogDb.listByUserId(USER);
assert.equal(log[0].action, "number-blocked");
// The rejection log names no number, only its hash.
assert.equal(log[0].phoneE164, undefined);
});
it("refuses verification even with a valid code", async () => {
process.env.SMS_DEV_ECHO_CODE = "true";
const posted = await call({ body: { phoneNumber: PHONE } });
const code = posted.body.devCode as string;
delete process.env.SMS_DEV_ECHO_CODE;
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
reason: "opt-out",
});
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
assert.equal((await smsRegistrationsDb.get(USER, PHONE))?.verified, false);
});
it("beats the already-verified short circuit", async () => {
await registerAndVerify();
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
reason: "manual",
});
const result = await call({
method: "PUT",
body: { phoneNumber: PHONE, code: "000000" },
auth: { claim: { action: "verify-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
});
it("still lets the owner delete their registration", async () => {
await registerAndVerify();
await smsBlockedNumbersDb.block({
phoneHash: blockedHash(),
reason: "opt-out",
});
const result = await call({
method: "DELETE",
body: { phoneNumber: PHONE },
auth: { claim: { action: "delete-phone", phoneNumber: PHONE } },
});
assert.equal(result.status, 200);
assert.equal(result.body.deleted, true);
});
it("honours SMS_BLOCKED_NUMBERS with no database row", async () => {
process.env.SMS_BLOCKED_NUMBERS = "555-555-0123";
const result = await call({ body: { phoneNumber: PHONE } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
});
});
describe("Twilio 21610 on the verification code send", () => {
it("blocks the number so the caller cannot retry into the same refusal", async () => {
sendResult = {
status: "failed",
error: "The message From/To pair violates a blacklist rule.",
code: 21610,
};
const first = await call({ body: { phoneNumber: PHONE } });
assert.equal(first.status, 502);
const stored = await smsBlockedNumbersDb.get(hashPhoneNumber(PHONE, SECRET));
assert.equal(stored?.reason, "provider-opt-out");
sendResult = { status: "sent", messageId: "SM1" };
sent = [];
const second = await call({ body: { phoneNumber: PHONE } });
assert.equal(second.status, 403);
assert.equal(second.body.error, "SMS_PHONE_BLOCKED");
assert.equal(sent.length, 0);
});
it("leaves an ordinary send failure retryable", async () => {
sendResult = { status: "failed", error: "carrier down" };
await call({ body: { phoneNumber: PHONE } });
assert.equal(
await smsBlockedNumbersDb.isBlocked(hashPhoneNumber(PHONE, SECRET)),
false
);
});
});