Files
notification-wakeup-service/test/middleware/smsActionJwt.test.ts
T

226 lines
7.2 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 from "express";
import { closeDatabase } from "../../src/db/sqlite.js";
import { requireSmsActionJwt, sha256Hex } from "../../src/middleware/smsActionJwt.js";
const USER = "did:ethr:0xclaimuser";
const PHONE = "+15555550123";
const ENV_KEYS = [
"SMS_REQUIRE_ACTION_CLAIM",
"SMS_ACTION_JWT_MAX_AGE_SEC",
"NOTIFY_DATA_DIR",
] as const;
let dir: string;
let savedEnv: Record<string, string | undefined>;
let server: { url: string; close: () => Promise<void> };
let handlerRuns: number;
/** Set to false to mimic a route mounted without requireAuth. */
let authenticate: boolean;
beforeEach(async () => {
savedEnv = {};
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
dir = await mkdtemp(path.join(tmpdir(), "sms-action-jwt-"));
process.env.NOTIFY_DATA_DIR = dir;
closeDatabase();
handlerRuns = 0;
authenticate = true;
const app = express();
app.use(express.json());
app.post(
"/verify",
(req, _res, next) => {
if (!authenticate) {
next();
return;
}
const parsed = JSON.parse(req.get("X-Test-Auth") as string) as {
jwt: string;
payload: Record<string, unknown>;
};
req.did = USER;
req.jwt = parsed.jwt;
req.auth = { did: USER, jwt: parsed.jwt, payload: parsed.payload };
next();
},
requireSmsActionJwt("verify-phone"),
(req, res) => {
handlerRuns += 1;
res.status(200).json({ ok: true, jwtHash: req.smsActionJwtHash });
}
);
const listening = app.listen(0);
await new Promise((resolve) => listening.once("listening", resolve));
const port = (listening.address() as AddressInfo).port;
server = {
url: `http://127.0.0.1:${port}/verify`,
close: () => new Promise<void>((r) => listening.close(() => r())),
};
});
afterEach(async () => {
closeDatabase();
await server.close();
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 });
});
let counter = 0;
async function post(input: {
payload?: Record<string, unknown>;
jwt?: string;
body?: unknown;
}): Promise<{ status: number; body: Record<string, unknown> }> {
counter += 1;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authenticate) {
headers["X-Test-Auth"] = JSON.stringify({
jwt: input.jwt ?? `token-${counter}`,
payload: input.payload ?? {},
});
}
const response = await fetch(server.url, {
method: "POST",
headers,
body: JSON.stringify(input.body ?? { phoneNumber: PHONE }),
});
return { status: response.status, body: await response.json() };
}
function payload(
overrides: Record<string, unknown> = {},
claim: Record<string, unknown> | null = {
action: "verify-phone",
phoneNumber: PHONE,
}
): Record<string, unknown> {
return {
iss: USER,
iat: Math.floor(Date.now() / 1000),
...(claim === null ? {} : { claim }),
...overrides,
};
}
describe("requireSmsActionJwt", () => {
it("passes a fresh, correctly bound token and records its hash", async () => {
const result = await post({ payload: payload(), jwt: "the-token" });
assert.equal(result.status, 200);
assert.equal(handlerRuns, 1);
assert.equal(result.body.jwtHash, sha256Hex("the-token"));
});
it("fails closed with 500 when the route is not authenticated", async () => {
authenticate = false;
const result = await post({});
assert.equal(result.status, 500);
assert.equal(result.body.error, "SMS_ACTION_JWT_NOT_AUTHENTICATED");
assert.equal(handlerRuns, 0);
});
it("rejects a token with no claim", async () => {
const result = await post({ payload: payload({}, null) });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
assert.equal(handlerRuns, 0);
});
it("rejects a claim that is not an object", async () => {
const result = await post({ payload: { iss: USER, iat: 1, claim: "nope" } });
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_MISSING_CLAIM");
});
it("rejects a claim authorizing a different action", async () => {
const result = await post({
payload: payload({}, { action: "delete-phone", phoneNumber: PHONE }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_WRONG_ACTION");
});
it("rejects a claim naming a different phone", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone", phoneNumber: "+15555559999" }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
});
it("matches the claim's phone after normalization, not by string", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone", phoneNumber: "(555) 555-0123" }),
body: { phoneNumber: "555.555.0123" },
});
assert.equal(result.status, 200);
});
it("rejects a claim carrying no phone at all", async () => {
const result = await post({
payload: payload({}, { action: "verify-phone" }),
});
assert.equal(result.status, 403);
assert.equal(result.body.error, "SMS_ACTION_JWT_PHONE_MISMATCH");
});
it("rejects a token issued too long ago", async () => {
process.env.SMS_ACTION_JWT_MAX_AGE_SEC = "60";
const result = await post({
payload: payload({ iat: Math.floor(Date.now() / 1000) - 120 }),
});
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
});
it("rejects a token with no iat", async () => {
const result = await post({ payload: payload({ iat: undefined }) });
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_STALE");
});
it("rejects an expired token", async () => {
const result = await post({
payload: payload({ exp: Math.floor(Date.now() / 1000) - 1 }),
});
assert.equal(result.status, 401);
assert.equal(result.body.error, "SMS_ACTION_JWT_EXPIRED");
});
it("accepts a token whose exp is still ahead", async () => {
const result = await post({
payload: payload({ exp: Math.floor(Date.now() / 1000) + 300 }),
});
assert.equal(result.status, 200);
});
it("rejects the same token used a second time", async () => {
const first = await post({ payload: payload(), jwt: "one-shot" });
assert.equal(first.status, 200);
const second = await post({ payload: payload(), jwt: "one-shot" });
assert.equal(second.status, 401);
assert.equal(second.body.error, "SMS_ACTION_JWT_REPLAYED");
assert.equal(handlerRuns, 1);
});
it("skips every check when the claim is not required", async () => {
process.env.SMS_REQUIRE_ACTION_CLAIM = "false";
const result = await post({ payload: payload({}, null) });
assert.equal(result.status, 200);
assert.equal(result.body.jwtHash, undefined);
});
});