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;