211 lines
7.1 KiB
TypeScript
211 lines
7.1 KiB
TypeScript
/**
|
|
* End-to-end /notify-sms smoke run against fake everything.
|
|
*
|
|
* pkgx npx tsx scripts/sms-smoke.ts
|
|
*
|
|
* pkgx npx tsx scripts/sms-smoke.ts +15551234567
|
|
*
|
|
* Stubs the two things that normally need the real world — Endorser and the
|
|
* signing key — and leaves the SMS path itself completely real. With Twilio
|
|
* test credentials in the environment it makes a genuine API round trip that
|
|
* sends no message and costs nothing; without them it prints to the console.
|
|
*/
|
|
import { createServer } from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import express from "express";
|
|
|
|
const USER_DID = "did:ethr:0x0000000000000000000000000000000000c0ffee";
|
|
/** Resolved after the environment is set, since the guard reads Twilio config. */
|
|
let TO_NUMBER = "";
|
|
|
|
// A fresh database each run, so the 3-per-hour code throttle never bites.
|
|
process.env.NOTIFY_DATA_DIR = mkdtempSync(path.join(tmpdir(), "sms-smoke-"));
|
|
// Accepts unsigned did:ethr JWTs and unlocks devCode. Never set in production.
|
|
process.env.NODE_ENV = "test-local";
|
|
process.env.SMS_ENABLED = "true";
|
|
process.env.SMS_CODE_SECRET ??= "sms-smoke-secret";
|
|
process.env.SMS_DEV_ECHO_CODE = "true";
|
|
|
|
/** Answers the one call requireEndorserAuth makes. */
|
|
function startStubEndorser(): Promise<string> {
|
|
const server = createServer((req, res) => {
|
|
console.log(` [stub-endorser] ${req.method} ${req.url} -> 200`);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ doneClaimsThisWeek: 0, maxClaimsPerWeek: 100 }));
|
|
});
|
|
return new Promise((resolve) => {
|
|
server.listen(0, () => {
|
|
const { port } = server.address() as AddressInfo;
|
|
resolve(`http://127.0.0.1:${port}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An unsigned JWT. decodeAndVerifyJwt returns verified:true for a did:ethr
|
|
* issuer under NODE_ENV=test-local without checking the signature, so no key
|
|
* material is needed to exercise the real middleware chain.
|
|
*/
|
|
function mintActionJwt(action: string, phoneNumber?: string): string {
|
|
const b64 = (value: unknown) =>
|
|
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return [
|
|
b64({ alg: "ES256K", typ: "JWT" }),
|
|
b64({
|
|
iss: USER_DID,
|
|
iat: now,
|
|
exp: now + 300,
|
|
claim: {
|
|
"@context": "https://giftopia.tech",
|
|
"@type": "SmsNotificationAction",
|
|
action,
|
|
...(phoneNumber === undefined ? {} : { phoneNumber }),
|
|
},
|
|
}),
|
|
"unsigned",
|
|
].join(".");
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
// ENDORSER_URL is a module-level const in env.ts, so it has to be set before
|
|
// anything that imports env.ts is loaded — including the target guard.
|
|
process.env.ENDORSER_URL = await startStubEndorser();
|
|
console.log("Stub Endorser at", process.env.ENDORSER_URL);
|
|
|
|
const { resolveTarget } = await import("./smokeTarget.js");
|
|
TO_NUMBER = resolveTarget(process.argv[2], "SMS_SMOKE_TO");
|
|
|
|
// Imported after the environment is set, since env.ts reads it at load.
|
|
const { notifySmsRouter } = await import("../src/routes/notifySms.js");
|
|
const { smsConfig } = await import("../src/env.js");
|
|
|
|
const configured =
|
|
smsConfig().twilioAccountSid !== undefined &&
|
|
smsConfig().twilioAuthToken !== undefined &&
|
|
(smsConfig().twilioFromNumber !== undefined ||
|
|
smsConfig().twilioMessagingServiceSid !== undefined);
|
|
console.log(
|
|
configured
|
|
? `Twilio credentials present: real API call, From=${smsConfig().twilioFromNumber ?? smsConfig().twilioMessagingServiceSid}`
|
|
: "No Twilio credentials: console adapter, nothing leaves the machine"
|
|
);
|
|
console.log("To:", TO_NUMBER);
|
|
console.log("Data dir:", process.env.NOTIFY_DATA_DIR, "\n");
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use("/notify-sms", notifySmsRouter);
|
|
const server = app.listen(0);
|
|
await new Promise((resolve) => server.once("listening", resolve));
|
|
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
|
|
|
async function call(
|
|
label: string,
|
|
method: string,
|
|
urlPath: string,
|
|
action: string,
|
|
phoneNumber?: string,
|
|
body?: unknown
|
|
): Promise<Record<string, unknown>> {
|
|
const response = await fetch(base + urlPath, {
|
|
method,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer " + mintActionJwt(action, phoneNumber),
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
const parsed = (await response.json()) as Record<string, unknown>;
|
|
console.log(`${label}: ${response.status}`, JSON.stringify(parsed));
|
|
return parsed;
|
|
}
|
|
|
|
const posted = await call(
|
|
"POST /phone ",
|
|
"POST",
|
|
"/notify-sms/phone",
|
|
"register-phone",
|
|
TO_NUMBER,
|
|
{ phoneNumber: TO_NUMBER }
|
|
);
|
|
|
|
const { smsPhoneLogDb } = await import("../src/db/smsPhoneLogSqlite.js");
|
|
|
|
async function dumpLog(): Promise<void> {
|
|
const rows = await smsPhoneLogDb.listByUserId(USER_DID);
|
|
console.log("\nsms_phone_log (newest first):");
|
|
for (const row of rows) {
|
|
console.log(
|
|
` ${row.action.padEnd(26)} ${row.result.padEnd(9)} ` +
|
|
`phone=${row.phoneE164 ?? "(scrubbed)"} ` +
|
|
`sid=${row.providerMessageId ?? "-"}` +
|
|
(row.detail === undefined ? "" : ` detail=${row.detail}`)
|
|
);
|
|
}
|
|
}
|
|
|
|
const code = posted.devCode;
|
|
if (typeof code !== "string") {
|
|
await dumpLog();
|
|
console.error(
|
|
"\nNo devCode in the response, so the code never went out. The " +
|
|
"code-send-failed detail above is Twilio's own words.\n" +
|
|
` \u2022 'To' rejected (${TO_NUMBER}): check the country code — a US ` +
|
|
"number is +1 and ten digits (+18015601471, not +8015601471). Passing " +
|
|
"the ten digits with no + at all assumes US. Fictional 555-01xx numbers " +
|
|
"are rejected too.\n" +
|
|
" \u2022 'From' rejected (21606): under test credentials it must be " +
|
|
"+15005550006; under live credentials it must be a number you own, or " +
|
|
"a Messaging Service SID.\n" +
|
|
" \u2022 'resource ... was not found' (20404): the token authenticated " +
|
|
"but that account is not reachable. Run `pnpm run twilio:whoami`."
|
|
);
|
|
server.close();
|
|
process.exit(1);
|
|
}
|
|
|
|
await call(
|
|
"PUT /phone ",
|
|
"PUT",
|
|
"/notify-sms/phone",
|
|
"verify-phone",
|
|
TO_NUMBER,
|
|
{ phoneNumber: TO_NUMBER, code }
|
|
);
|
|
await call("GET /phone ", "GET", "/notify-sms/phone", "list-phones");
|
|
await call(
|
|
"GET /phone? ",
|
|
"GET",
|
|
`/notify-sms/phone?phoneNumber=${encodeURIComponent(TO_NUMBER)}`,
|
|
"list-phones",
|
|
TO_NUMBER
|
|
);
|
|
// No batch was ever stored here (that needs 100 signed delegated JWTs), so
|
|
// this reports zeros. It still proves the route, the claim, and the log line.
|
|
await call(
|
|
"DELETE /alert-a",
|
|
"DELETE",
|
|
"/notify-sms/alert-authorization",
|
|
"revoke-alert-search"
|
|
);
|
|
await call(
|
|
"DELETE /phone ",
|
|
"DELETE",
|
|
"/notify-sms/phone",
|
|
"delete-phone",
|
|
TO_NUMBER,
|
|
{ phoneNumber: TO_NUMBER }
|
|
);
|
|
|
|
await dumpLog();
|
|
|
|
server.close();
|
|
process.exit(0);
|
|
}
|
|
|
|
void main();
|