523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
import { createVerify } from "node:crypto";
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
} from "@suaveplan/testing/runner";
|
|
import {
|
|
jsonResponder,
|
|
type RecordingServer,
|
|
startRecordingServer,
|
|
} from "../../tests/setup/http-server.js";
|
|
import { SMS_PROVIDERS_CODES } from "../error-codes/error-codes.js";
|
|
import { AwsSnsProvider, buildSnsStringToSign } from "./aws-sns.js";
|
|
|
|
let server!: RecordingServer;
|
|
|
|
afterEach(async () => {
|
|
await server.close();
|
|
});
|
|
|
|
function makeProvider(
|
|
config: {
|
|
region?: string;
|
|
accessKeyId?: string;
|
|
secretAccessKey?: string;
|
|
sessionToken?: string;
|
|
senderId?: string;
|
|
now?: () => Date;
|
|
smsType?: "Transactional" | "Promotional";
|
|
} = {}
|
|
): AwsSnsProvider {
|
|
return new AwsSnsProvider({
|
|
region: config.region ?? "us-east-1",
|
|
accessKeyId: config.accessKeyId ?? "AKIAEXAMPLE",
|
|
secretAccessKey: config.secretAccessKey ?? "SECRETKEY",
|
|
...(config.sessionToken !== undefined
|
|
? { sessionToken: config.sessionToken }
|
|
: {}),
|
|
...(config.senderId !== undefined ? { senderId: config.senderId } : {}),
|
|
...(config.smsType !== undefined ? { smsType: config.smsType } : {}),
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
now: config.now ?? ((): Date => new Date("2026-04-24T10:00:00Z")),
|
|
});
|
|
}
|
|
|
|
const PUBLISH_XML = `<?xml version="1.0"?>
|
|
<PublishResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
|
|
<PublishResult><MessageId>sns-msg-1</MessageId></PublishResult>
|
|
<ResponseMetadata><RequestId>req-1</RequestId></ResponseMetadata>
|
|
</PublishResponse>`;
|
|
|
|
describe("AwsSnsProvider — construction", () => {
|
|
beforeEach(async () => {
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/xml" });
|
|
res.end(PUBLISH_XML);
|
|
});
|
|
});
|
|
|
|
it("requires region + accessKeyId + secretAccessKey", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(() => makeProvider({ region: "" })).toThrow();
|
|
expect(() => makeProvider({ accessKeyId: "" })).toThrow();
|
|
expect(() => makeProvider({ secretAccessKey: "" })).toThrow();
|
|
});
|
|
|
|
it("exposes name 'aws-sns'", { timeout: 5000 }, () => {
|
|
expect(makeProvider().name).toBe("aws-sns");
|
|
});
|
|
});
|
|
|
|
describe("AwsSnsProvider — send", () => {
|
|
beforeEach(async () => {
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/xml" });
|
|
res.end(PUBLISH_XML);
|
|
});
|
|
});
|
|
|
|
it("POSTs a SigV4-signed Action=Publish form to sns.<region>.amazonaws.com", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const p = makeProvider({ senderId: "SUAVEPLAN" });
|
|
const r = await p.send({
|
|
to: "+15551234567",
|
|
body: "Hello",
|
|
});
|
|
const req = server.requests[0]!;
|
|
expect(req.method).toBe("POST");
|
|
expect(req.path).toBe("/");
|
|
expect(req.body).toContain("Action=Publish");
|
|
expect(req.body).toContain("PhoneNumber=%2B15551234567");
|
|
expect(req.body).toContain("Message=Hello");
|
|
expect(req.body).toContain("AWS.SNS.SMS.SMSType");
|
|
expect(req.body).toContain("Transactional");
|
|
expect(req.body).toContain("AWS.SNS.SMS.SenderID");
|
|
expect(req.body).toContain("SUAVEPLAN");
|
|
// SigV4 shape on Authorization header.
|
|
const auth = String(req.headers.authorization ?? "");
|
|
expect(auth).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE\//);
|
|
expect(auth).toContain("SignedHeaders=");
|
|
expect(auth).toMatch(/Signature=[0-9a-f]{64}$/);
|
|
expect(req.headers["x-amz-date"]).toMatch(/^\d{8}T\d{6}Z$/);
|
|
expect(req.headers["x-amz-content-sha256"]).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(r.messageId).toBe("sns-msg-1");
|
|
expect(r.status).toBe("sent");
|
|
expect(r.to).toBe("+15551234567");
|
|
});
|
|
|
|
it("forwards X-Amz-Security-Token when sessionToken is supplied", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const p = makeProvider({ sessionToken: "tok-123" });
|
|
await p.send({ to: "+1", body: "hi" });
|
|
expect(server.requests[0]!.headers["x-amz-security-token"]).toBe(
|
|
"tok-123"
|
|
);
|
|
});
|
|
|
|
it("uses Promotional SMSType when configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const p = makeProvider({ smsType: "Promotional" });
|
|
await p.send({ to: "+1", body: "hi" });
|
|
expect(server.requests[0]!.body).toContain("Promotional");
|
|
});
|
|
|
|
it("falls back to message.from for SenderID when senderId is unset", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const p = makeProvider();
|
|
await p.send({ to: "+1", from: "FROMSENDER", body: "hi" });
|
|
expect(server.requests[0]!.body).toContain("FROMSENDER");
|
|
});
|
|
|
|
it("omits the SenderID attribute when neither senderId nor from is set", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const p = makeProvider();
|
|
await p.send({ to: "+1", body: "hi" });
|
|
expect(server.requests[0]!.body).not.toContain("AWS.SNS.SMS.SenderID");
|
|
});
|
|
|
|
it("throws when the SNS response lacks a MessageId", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await server.close();
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/xml" });
|
|
res.end("<PublishResponse><PublishResult/></PublishResponse>");
|
|
});
|
|
const p = makeProvider();
|
|
await expect(p.send({ to: "+1", body: "hi" })).rejects.toMatchObject({
|
|
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
|
});
|
|
});
|
|
|
|
it("accepts a JSON-shaped SNS Publish response", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await server.close();
|
|
server = await startRecordingServer(
|
|
jsonResponder({ MessageId: "json-msg-1" })
|
|
);
|
|
const p = makeProvider();
|
|
const r = await p.send({ to: "+1", body: "hi" });
|
|
expect(r.messageId).toBe("json-msg-1");
|
|
});
|
|
|
|
it("throws on a non-2xx response", { timeout: 5000 }, async () => {
|
|
await server.close();
|
|
server = await startRecordingServer(
|
|
jsonResponder({ message: "InvalidParameter" }, 400)
|
|
);
|
|
const p = makeProvider();
|
|
await expect(p.send({ to: "+1", body: "hi" })).rejects.toMatchObject({
|
|
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
|
httpStatus: 400,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("AwsSnsProvider — getStatus", () => {
|
|
beforeEach(async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
});
|
|
|
|
it("returns a synthetic queued report (SNS has no per-message API)", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const p = makeProvider();
|
|
const r = await p.getStatus("anything");
|
|
expect(r.status).toBe("queued");
|
|
expect(r.messageId).toBe("anything");
|
|
expect(r.to).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("AwsSnsProvider — mapStatus", () => {
|
|
beforeEach(async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
});
|
|
|
|
it.each([
|
|
["pending", "queued"],
|
|
["success", "delivered"],
|
|
["failure", "failed"],
|
|
["optout", "undelivered"],
|
|
["nope", "failed"],
|
|
])("%s → %s", { timeout: 5000 }, (raw, mapped) => {
|
|
expect(makeProvider().mapStatus(raw)).toBe(mapped);
|
|
});
|
|
});
|
|
|
|
describe("AwsSnsProvider — parseInbound", () => {
|
|
beforeEach(async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
});
|
|
|
|
it("identifies an SMS delivery receipt notification", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const p = makeProvider();
|
|
const evt = p.parseInbound({
|
|
Type: "Notification",
|
|
MessageId: "msg1",
|
|
Subject: "AWS SMS Delivery Receipt",
|
|
Message: JSON.stringify({ status: "SUCCESS", errorCode: "0" }),
|
|
Timestamp: "2026-04-24T10:00:00Z",
|
|
Signature: "...",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "https://x/cert.pem",
|
|
});
|
|
expect(evt).toEqual({
|
|
type: "delivery_receipt",
|
|
messageId: "msg1",
|
|
status: "delivered",
|
|
errorCode: "0",
|
|
});
|
|
});
|
|
|
|
it("identifies STOP/YES/HELP and message_received from inbound MO SMS", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const p = makeProvider();
|
|
const payload = (body: Record<string, unknown>) => ({
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
Message: JSON.stringify(body),
|
|
Timestamp: "t",
|
|
Signature: "s",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "u",
|
|
});
|
|
expect(
|
|
p.parseInbound(
|
|
payload({ originationNumber: "+1", messageBody: "stop" })
|
|
)
|
|
).toMatchObject({ type: "opt_out" });
|
|
expect(
|
|
p.parseInbound(
|
|
payload({ originationNumber: "+1", messageBody: "YES" })
|
|
)
|
|
).toMatchObject({ type: "opt_in" });
|
|
expect(
|
|
p.parseInbound(
|
|
payload({ originationNumber: "+1", messageBody: "HELP" })
|
|
)
|
|
).toMatchObject({ type: "help" });
|
|
expect(
|
|
p.parseInbound(
|
|
payload({
|
|
originationNumber: "+1",
|
|
destinationNumber: "+2",
|
|
messageBody: "free form",
|
|
})
|
|
)
|
|
).toEqual({
|
|
type: "message_received",
|
|
from: "+1",
|
|
to: "+2",
|
|
body: "free form",
|
|
providerId: "id",
|
|
});
|
|
});
|
|
|
|
it("falls back to empty fields when the JSON Message is unparseable", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const p = makeProvider();
|
|
expect(
|
|
p.parseInbound({
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
Message: "not json",
|
|
Timestamp: "t",
|
|
Signature: "s",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "u",
|
|
})
|
|
).toMatchObject({
|
|
type: "message_received",
|
|
from: "",
|
|
to: "",
|
|
body: "",
|
|
providerId: "id",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("AwsSnsProvider — buildSnsStringToSign", () => {
|
|
beforeEach(async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
});
|
|
|
|
it("orders fields for Notification per the SNS spec", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const s = buildSnsStringToSign({
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
TopicArn: "arn",
|
|
Subject: "sub",
|
|
Message: "msg",
|
|
Timestamp: "t",
|
|
Signature: "sig",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "url",
|
|
});
|
|
expect(s).toBe(
|
|
[
|
|
"Message",
|
|
"msg",
|
|
"MessageId",
|
|
"id",
|
|
"Subject",
|
|
"sub",
|
|
"Timestamp",
|
|
"t",
|
|
"TopicArn",
|
|
"arn",
|
|
"Type",
|
|
"Notification",
|
|
"",
|
|
].join("\n")
|
|
);
|
|
});
|
|
|
|
it("orders fields for SubscriptionConfirmation per the SNS spec", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const s = buildSnsStringToSign({
|
|
Type: "SubscriptionConfirmation",
|
|
MessageId: "id",
|
|
TopicArn: "arn",
|
|
Message: "msg",
|
|
Timestamp: "t",
|
|
Token: "tok",
|
|
SubscribeURL: "sub",
|
|
Signature: "sig",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "url",
|
|
});
|
|
expect(s).toBe(
|
|
[
|
|
"Message",
|
|
"msg",
|
|
"MessageId",
|
|
"id",
|
|
"SubscribeURL",
|
|
"sub",
|
|
"Timestamp",
|
|
"t",
|
|
"Token",
|
|
"tok",
|
|
"TopicArn",
|
|
"arn",
|
|
"Type",
|
|
"SubscriptionConfirmation",
|
|
"",
|
|
].join("\n")
|
|
);
|
|
});
|
|
|
|
it("skips undefined fields", { timeout: 5000 }, () => {
|
|
const s = buildSnsStringToSign({
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
Message: "msg",
|
|
Timestamp: "t",
|
|
Signature: "sig",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "url",
|
|
});
|
|
// No Subject/TopicArn lines.
|
|
expect(s).not.toContain("Subject");
|
|
expect(s).not.toContain("TopicArn");
|
|
});
|
|
});
|
|
|
|
describe("AwsSnsProvider — verifyNotification", () => {
|
|
beforeEach(async () => {
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "application/x-pem-file" });
|
|
res.end(
|
|
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"
|
|
);
|
|
});
|
|
});
|
|
|
|
it("delegates to the injected rsaVerify callback", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const calls: Array<{ pem: string; msg: string; sig: string }> = [];
|
|
const rsaVerify = async (
|
|
pem: string,
|
|
msg: string,
|
|
sig: string
|
|
): Promise<boolean> => {
|
|
calls.push({ pem, msg, sig });
|
|
return true;
|
|
};
|
|
const p = makeProvider();
|
|
const ok = await p.verifyNotification(
|
|
{
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
Message: "m",
|
|
Timestamp: "t",
|
|
Signature: "sig123",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "https://sns/cert.pem",
|
|
},
|
|
rsaVerify
|
|
);
|
|
expect(ok).toBe(true);
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0]!.sig).toBe("sig123");
|
|
expect(calls[0]!.pem).toContain("BEGIN CERTIFICATE");
|
|
});
|
|
|
|
it("throws HTTP_ERROR when the cert URL fails to fetch", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await server.close();
|
|
server = await startRecordingServer(
|
|
jsonResponder({ message: "x" }, 404)
|
|
);
|
|
const p = makeProvider();
|
|
await expect(
|
|
p.verifyNotification(
|
|
{
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
Message: "m",
|
|
Timestamp: "t",
|
|
Signature: "sig",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "https://sns/cert.pem",
|
|
},
|
|
async () => true
|
|
)
|
|
).rejects.toMatchObject({
|
|
code: SMS_PROVIDERS_CODES.HTTP_ERROR,
|
|
});
|
|
});
|
|
|
|
it("throws PARSE_ERROR when the cert URL returns non-PEM", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
await server.close();
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
res.end("not a cert");
|
|
});
|
|
const p = makeProvider();
|
|
await expect(
|
|
p.verifyNotification(
|
|
{
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
Message: "m",
|
|
Timestamp: "t",
|
|
Signature: "sig",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "https://sns/cert.pem",
|
|
},
|
|
async () => true
|
|
)
|
|
).rejects.toMatchObject({
|
|
code: SMS_PROVIDERS_CODES.PARSE_ERROR,
|
|
});
|
|
});
|
|
|
|
it("returns false when the rsaVerify callback rejects the signature", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const p = makeProvider();
|
|
const ok = await p.verifyNotification(
|
|
{
|
|
Type: "Notification",
|
|
MessageId: "id",
|
|
Message: "m",
|
|
Timestamp: "t",
|
|
Signature: "sig",
|
|
SignatureVersion: "1",
|
|
SigningCertURL: "https://sns/cert.pem",
|
|
},
|
|
async () => false
|
|
);
|
|
expect(ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Touch the unused node:crypto import via a no-op describe so the
|
|
// module reference stays exercised on CI; createVerify is used in the
|
|
// usage example only.
|
|
void createVerify;
|