511 lines
18 KiB
TypeScript
511 lines
18 KiB
TypeScript
/**
|
|
* Second targeted branch-coverage suite — closes the remaining
|
|
* istanbul branch gaps (default-arms of `??` chains, "fetch undefined"
|
|
* arms of per-provider `common()`, parseInbound default-fall-through
|
|
* shapes, http content-type-absent path, default baseUrl path for
|
|
* AWS SNS, conditional-spread absent-arms for delivery receipts, and
|
|
* the non-object `safeJsonObject` fallback).
|
|
*/
|
|
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { AwsSnsProvider } from "../src/aws-sns/aws-sns.js";
|
|
import { httpRequest } from "../src/http/http.js";
|
|
import { MessageBirdProvider } from "../src/messagebird/messagebird.js";
|
|
import { PlivoProvider } from "../src/plivo/plivo.js";
|
|
import { TwilioProvider } from "../src/twilio/twilio.js";
|
|
import type { FetchLike } from "../src/types/provider.js";
|
|
import { VonageProvider } from "../src/vonage/vonage.js";
|
|
import {
|
|
jsonResponder,
|
|
type RecordingServer,
|
|
startRecordingServer,
|
|
} from "./setup/http-server.js";
|
|
|
|
let server: RecordingServer | undefined;
|
|
|
|
afterEach(async () => {
|
|
if (server) {
|
|
await server.close();
|
|
server = undefined;
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Temporarily replace `globalThis.fetch` with a path-rewriting fetch that
|
|
* routes any URL to the recording server's origin via the captured
|
|
* platform fetch. Restores the original on exit. Used so providers
|
|
* constructed without an explicit `fetch` config option exercise the
|
|
* "this.fetch !== undefined === false" arm of their `common()` helper.
|
|
*/
|
|
async function withGlobalFetch<T>(
|
|
origin: string,
|
|
body: () => Promise<T>
|
|
): Promise<T> {
|
|
const original = globalThis.fetch;
|
|
const rewriter = async (
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit
|
|
): Promise<Response> => {
|
|
const inputUrl =
|
|
typeof input === "string" || input instanceof URL
|
|
? new URL(input.toString())
|
|
: new URL(input.url);
|
|
const rerouted = `${origin}${inputUrl.pathname}${inputUrl.search}`;
|
|
return original.call(globalThis, rerouted, init);
|
|
};
|
|
Object.defineProperty(globalThis, "fetch", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: rewriter as unknown as typeof fetch,
|
|
});
|
|
try {
|
|
return await body();
|
|
} finally {
|
|
Object.defineProperty(globalThis, "fetch", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: original,
|
|
});
|
|
}
|
|
}
|
|
|
|
describe("http.ts — headers.get('content-type') returns null", () => {
|
|
it("treats a missing content-type header as empty and falls back to start-byte JSON sniffing", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const fetchImpl: FetchLike = async () =>
|
|
({
|
|
ok: true,
|
|
status: 200,
|
|
headers: { get: () => null },
|
|
text: async () => '{"k":1}',
|
|
}) as unknown as Response;
|
|
const { data } = await httpRequest<{ k: number }>({
|
|
method: "GET",
|
|
url: "https://x/",
|
|
fetch: fetchImpl,
|
|
});
|
|
expect(data).toEqual({ k: 1 });
|
|
});
|
|
});
|
|
|
|
describe("Twilio — getStatus without date_created falls back to a fresh Date", () => {
|
|
it("returns a Date timestamp when date_created is omitted", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(
|
|
jsonResponder({ sid: "S", status: "sent", to: "+1" })
|
|
);
|
|
const t = new TwilioProvider({
|
|
accountSid: "AC",
|
|
authToken: "T",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
});
|
|
const r = await t.getStatus("S");
|
|
expect(r.timestamp).toBeInstanceOf(Date);
|
|
});
|
|
});
|
|
|
|
describe("common() — this.fetch === undefined arm (default global fetch)", () => {
|
|
it("Twilio.send works when no explicit fetch is configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(
|
|
jsonResponder({ sid: "S", status: "queued", to: "+1" })
|
|
);
|
|
const localServer = server;
|
|
const t = new TwilioProvider({
|
|
accountSid: "AC",
|
|
authToken: "T",
|
|
baseUrl: server.origin,
|
|
});
|
|
const r = await withGlobalFetch(localServer.origin, () =>
|
|
t.send({ to: "+1", body: "x" })
|
|
);
|
|
expect(r.messageId).toBe("S");
|
|
});
|
|
|
|
it("Vonage.send works when no explicit fetch is configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(
|
|
jsonResponder({
|
|
"message-count": "1",
|
|
messages: [{ to: "1", "message-id": "x", status: "0" }],
|
|
})
|
|
);
|
|
const localServer = server;
|
|
const v = new VonageProvider({
|
|
apiKey: "k",
|
|
apiSecret: "s",
|
|
baseUrl: server.origin,
|
|
});
|
|
const r = await withGlobalFetch(localServer.origin, () =>
|
|
v.send({ to: "+1", body: "hi" })
|
|
);
|
|
expect(r.messageId).toBe("x");
|
|
});
|
|
|
|
it("MessageBird.send works when no explicit fetch is configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(
|
|
jsonResponder({
|
|
id: "mb",
|
|
originator: "S",
|
|
body: "x",
|
|
type: "sms",
|
|
recipients: {
|
|
totalCount: 1,
|
|
items: [{ recipient: 1, status: "sent" }],
|
|
},
|
|
})
|
|
);
|
|
const localServer = server;
|
|
const m = new MessageBirdProvider({
|
|
accessKey: "K",
|
|
baseUrl: server.origin,
|
|
});
|
|
const r = await withGlobalFetch(localServer.origin, () =>
|
|
m.send({ to: "+1", body: "x" })
|
|
);
|
|
expect(r.messageId).toBe("mb");
|
|
});
|
|
|
|
it("Plivo.send works when no explicit fetch is configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(
|
|
jsonResponder(
|
|
{
|
|
api_id: "x",
|
|
message: "ok",
|
|
message_uuid: ["u1"],
|
|
},
|
|
202
|
|
)
|
|
);
|
|
const localServer = server;
|
|
const p = new PlivoProvider({
|
|
authId: "A",
|
|
authToken: "T",
|
|
baseUrl: server.origin,
|
|
});
|
|
const r = await withGlobalFetch(localServer.origin, () =>
|
|
p.send({ to: "+1", body: "x" })
|
|
);
|
|
expect(r.messageId).toBe("u1");
|
|
});
|
|
|
|
it("AwsSns.send works when no explicit fetch is configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const PUBLISH_XML =
|
|
'<?xml version="1.0"?><PublishResponse><PublishResult><MessageId>x</MessageId></PublishResult></PublishResponse>';
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/xml" });
|
|
res.end(PUBLISH_XML);
|
|
});
|
|
const localServer = server;
|
|
const a = new AwsSnsProvider({
|
|
region: "us-east-1",
|
|
accessKeyId: "K",
|
|
secretAccessKey: "S",
|
|
baseUrl: server.origin,
|
|
now: () => new Date("2026-04-24T10:00:00Z"),
|
|
});
|
|
const r = await withGlobalFetch(localServer.origin, () =>
|
|
a.send({ to: "+1", body: "hi" })
|
|
);
|
|
expect(r.messageId).toBe("x");
|
|
});
|
|
});
|
|
|
|
describe("AWS SNS — default baseUrl path (config.baseUrl omitted)", () => {
|
|
it("constructs the default https://sns.<region>.amazonaws.com base URL when baseUrl is omitted", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
// We exercise the `?? \`https://${host}\`` default arm of the
|
|
// constructor; the resulting fetch URL hits the AWS host, but
|
|
// the swapped global fetch routes it to our recording server.
|
|
const PUBLISH_XML =
|
|
'<?xml version="1.0"?><PublishResponse><PublishResult><MessageId>m1</MessageId></PublishResult></PublishResponse>';
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, { "content-type": "text/xml" });
|
|
res.end(PUBLISH_XML);
|
|
});
|
|
const localServer = server;
|
|
const a = new AwsSnsProvider({
|
|
region: "us-east-1",
|
|
accessKeyId: "K",
|
|
secretAccessKey: "S",
|
|
now: () => new Date("2026-04-24T10:00:00Z"),
|
|
});
|
|
const r = await withGlobalFetch(localServer.origin, () =>
|
|
a.send({ to: "+1", body: "hi" })
|
|
);
|
|
expect(r.messageId).toBe("m1");
|
|
});
|
|
});
|
|
|
|
describe("AWS SNS — parseInbound delivery receipt without errorCode", () => {
|
|
it("omits errorCode when the SNS Message payload has no string errorCode field", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const a = new AwsSnsProvider({
|
|
region: "us-east-1",
|
|
accessKeyId: "K",
|
|
secretAccessKey: "S",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
now: () => new Date("2026-04-24T10:00:00Z"),
|
|
});
|
|
const evt = a.parseInbound({
|
|
Type: "Notification",
|
|
MessageId: "m1",
|
|
Subject: "AWS SMS Delivery Receipt",
|
|
Message: JSON.stringify({ status: "DELIVERED" }),
|
|
Timestamp: "2026-04-24T10:00:00Z",
|
|
TopicArn: "arn:aws:sns:us-east-1:000000000000:topic",
|
|
Signature: "",
|
|
SigningCertURL: "",
|
|
SignatureVersion: "1",
|
|
});
|
|
expect(evt.type).toBe("delivery_receipt");
|
|
if (evt.type === "delivery_receipt") {
|
|
expect(evt.errorCode).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("falls back to '' status when the SNS Message payload lacks a status field (delivery receipt path)", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const a = new AwsSnsProvider({
|
|
region: "us-east-1",
|
|
accessKeyId: "K",
|
|
secretAccessKey: "S",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
now: () => new Date("2026-04-24T10:00:00Z"),
|
|
});
|
|
const evt = a.parseInbound({
|
|
Type: "Notification",
|
|
MessageId: "m2",
|
|
Subject: "AWS SMS Delivery Receipt",
|
|
Message: JSON.stringify({}),
|
|
Timestamp: "2026-04-24T10:00:00Z",
|
|
TopicArn: "arn:aws:sns:us-east-1:000000000000:topic",
|
|
Signature: "",
|
|
SigningCertURL: "",
|
|
SignatureVersion: "1",
|
|
});
|
|
expect(evt.type).toBe("delivery_receipt");
|
|
if (evt.type === "delivery_receipt") {
|
|
// `mapStatus("")` defaults to "failed" per the SNS table.
|
|
expect(evt.status).toBe("failed");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("AWS SNS — safeJsonObject non-object fallback", () => {
|
|
it("parseInbound treats a non-object JSON payload as an empty object (covers the `typeof === 'object' && !== null` else arm)", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const a = new AwsSnsProvider({
|
|
region: "us-east-1",
|
|
accessKeyId: "K",
|
|
secretAccessKey: "S",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
now: () => new Date("2026-04-24T10:00:00Z"),
|
|
});
|
|
// `42` parses to a number — `typeof parsed === "object"` is false,
|
|
// so safeJsonObject returns `{}`. The non-delivery-receipt branch
|
|
// then produces a message_received event with empty fields.
|
|
const evt = a.parseInbound({
|
|
Type: "Notification",
|
|
MessageId: "mX",
|
|
Message: "42",
|
|
Timestamp: "2026-04-24T10:00:00Z",
|
|
TopicArn: "arn:aws:sns:us-east-1:000000000000:topic",
|
|
Signature: "",
|
|
SigningCertURL: "",
|
|
SignatureVersion: "1",
|
|
});
|
|
expect(evt.type).toBe("message_received");
|
|
if (evt.type === "message_received") {
|
|
expect(evt.from).toBe("");
|
|
expect(evt.to).toBe("");
|
|
expect(evt.body).toBe("");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("AWS SNS — fetchPem default-fetch arm (no provider fetch configured)", () => {
|
|
it("verifyNotification fetches the SNS signing cert via globalThis.fetch when no fetch is configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const PEM =
|
|
"-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
|
|
server = await startRecordingServer((_req, res) => {
|
|
res.writeHead(200, {
|
|
"content-type": "application/x-pem-file",
|
|
});
|
|
res.end(PEM);
|
|
});
|
|
const localServer = server;
|
|
const a = new AwsSnsProvider({
|
|
region: "us-east-1",
|
|
accessKeyId: "K",
|
|
secretAccessKey: "S",
|
|
now: () => new Date("2026-04-24T10:00:00Z"),
|
|
});
|
|
const ok = await withGlobalFetch(localServer.origin, () =>
|
|
a.verifyNotification(
|
|
{
|
|
Type: "Notification",
|
|
MessageId: "m",
|
|
Message: "{}",
|
|
Timestamp: "2026-04-24T10:00:00Z",
|
|
TopicArn: "arn:aws:sns:us-east-1:0:t",
|
|
Signature: "x",
|
|
SigningCertURL: `${localServer.origin}/cert.pem`,
|
|
SignatureVersion: "1",
|
|
},
|
|
async () => true
|
|
)
|
|
);
|
|
expect(ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("MessageBird — verifyWebhookSignature without nowMs (default arm)", () => {
|
|
it("accepts a JWT issued at the real wall-clock when nowMs is omitted", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const { createHmac } = await import("node:crypto");
|
|
const b64url = (input: Buffer | string): string => {
|
|
const buf =
|
|
typeof input === "string" ? Buffer.from(input, "utf8") : input;
|
|
return buf
|
|
.toString("base64")
|
|
.replace(/=+$/, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
};
|
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
const payload = b64url(
|
|
JSON.stringify({ iat: nowSec - 1, exp: nowSec + 60 })
|
|
);
|
|
const sig = createHmac("sha256", "secret")
|
|
.update(`${header}.${payload}`)
|
|
.digest();
|
|
const token = `${header}.${payload}.${b64url(sig)}`;
|
|
const m = new MessageBirdProvider({
|
|
accessKey: "K",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
});
|
|
const ok = await m.verifyWebhookSignature(token, "secret", {});
|
|
expect(ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("MessageBird — parseInbound fallthrough defaults", () => {
|
|
it("returns message_received with from='' when originator is absent", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const m = new MessageBirdProvider({
|
|
accessKey: "K",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
});
|
|
const evt = m.parseInbound({ recipient: "+2", payload: "hello" });
|
|
expect(evt.type).toBe("message_received");
|
|
if (evt.type === "message_received") {
|
|
expect(evt.from).toBe("");
|
|
expect(evt.body).toBe("hello");
|
|
}
|
|
});
|
|
|
|
it("returns message_received with body='' when neither payload nor body is present", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const m = new MessageBirdProvider({
|
|
accessKey: "K",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
});
|
|
const evt = m.parseInbound({
|
|
originator: "+1",
|
|
recipient: "+2",
|
|
});
|
|
expect(evt.type).toBe("message_received");
|
|
if (evt.type === "message_received") {
|
|
expect(evt.body).toBe("");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Plivo — parseInbound fallthrough defaults", () => {
|
|
it("returns message_received with from='' when From is absent", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const p = new PlivoProvider({
|
|
authId: "A",
|
|
authToken: "T",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
});
|
|
const evt = p.parseInbound({ To: "+2", Text: "hello" });
|
|
expect(evt.type).toBe("message_received");
|
|
if (evt.type === "message_received") {
|
|
expect(evt.from).toBe("");
|
|
}
|
|
});
|
|
|
|
it("returns message_received with body='' when neither Text nor text is present", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const p = new PlivoProvider({
|
|
authId: "A",
|
|
authToken: "T",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
});
|
|
const evt = p.parseInbound({ From: "+1", To: "+2" });
|
|
expect(evt.type).toBe("message_received");
|
|
if (evt.type === "message_received") {
|
|
expect(evt.body).toBe("");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Vonage — parseInbound fallthrough default", () => {
|
|
it("returns message_received with body='' when text is absent", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
server = await startRecordingServer(jsonResponder({}));
|
|
const v = new VonageProvider({
|
|
apiKey: "k",
|
|
apiSecret: "s",
|
|
baseUrl: server.origin,
|
|
fetch: server.fetch,
|
|
});
|
|
const evt = v.parseInbound({ msisdn: "+1", to: "+2" });
|
|
expect(evt.type).toBe("message_received");
|
|
if (evt.type === "message_received") {
|
|
expect(evt.body).toBe("");
|
|
}
|
|
});
|
|
});
|