1362 lines
45 KiB
TypeScript
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
|
|
);
|
|
});
|
|
});
|