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 = `
sns-msg-1
req-1
`;
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..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("");
});
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) => ({
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 => {
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;