Initial Commit
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user