Files
edr-platform/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts
2026-07-22 07:18:07 +00:00

281 lines
9.4 KiB
TypeScript

import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";
describe("normalizeOtpTarget", () => {
it("canonicalises Ethiopian forms to one E.164 key", () => {
const forms = [
"+251986680099",
"251986680099",
"0986680099",
"+251 98 668 0099",
];
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
expect(new Set(keys)).toEqual(new Set(["+251986680099"]));
});
it("canonicalises email case and surrounding whitespace to one key", () => {
const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"];
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
expect(new Set(keys)).toEqual(new Set(["a@b.com"]));
});
it("keeps an already-normalised email stable (idempotent)", () => {
const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
});
it("keeps an already-normalised number stable (idempotent)", () => {
const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
});
});
describe("isDomesticPhone", () => {
it.each(["+251986680099", "0986680099", "251986680099"])(
"accepts Ethiopian mobile form %s",
(phone) => expect(isDomesticPhone(phone)).toBe(true),
);
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
"rejects non-domestic or malformed %s",
(phone) => expect(isDomesticPhone(phone)).toBe(false),
);
});
interface FakeRow {
id: string;
phone?: string;
email?: string;
otp: string;
updatedAt: Date;
}
/**
* In-memory stand-in for OtpRepository, mirroring the two properties the service
* depends on: rows are matched by OR across every channel named, and a send
* replaces all overlapping rows with one row carrying every channel.
*/
function makeService(
transports: {
sms?: () => Promise<{ queued: boolean }>;
email?: () => Promise<{ queued: boolean }>;
} = {},
) {
let rows: FakeRow[] = [];
let nextId = 1;
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
(!!t.email && row.email === t.email) ||
(!!t.phone && row.phone === t.phone);
const repo = {
findByTarget: jest.fn(
async (t: { phone?: string; email?: string }) =>
rows.filter((row) => matches(row, t))[0] ?? null,
),
replaceOtp: jest.fn(
async (t: { phone?: string; email?: string }, otp: string) => {
const overlapping = rows.filter((row) => matches(row, t));
rows = rows.filter((row) => !overlapping.includes(row));
const record: FakeRow = {
id: String(nextId++),
...t,
otp,
updatedAt: new Date(),
};
rows.push(record);
return { record, rotated: overlapping.length > 0 };
},
),
deleteOtp: jest.fn(async (row: FakeRow) => {
rows = rows.filter((r) => r !== row);
}),
};
// Both clients return `{ queued }` — the service reads it to tell a published
// code apart from one the transport silently dropped.
const sms = {
sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))),
};
const email = {
sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))),
};
const service = new OtpService(repo as never, sms as never, email as never);
return { service, sms, email, rows: () => rows };
}
describe("OtpService — send/verify agree across phone formats", () => {
it("verifies a code sent to +251… when verify is called with 09…", async () => {
const { service, rows } = makeService();
await service.sendOtp({ phone: "+251986680099" });
await expect(
service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => {
const { service, rows } = makeService();
await service.sendOtp({ email: " User@Example.COM " });
await expect(
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
});
describe("OtpService — dual-channel send", () => {
const both = { phone: "0986680099", email: "User@Example.COM" };
it("sends ONE code to both transports", async () => {
const { service, sms, email, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
expect(sms.sendSms).toHaveBeenCalledTimes(1);
expect(email.sendEmail).toHaveBeenCalledTimes(1);
// Same secret on both messages — the user types whichever arrives first.
expect(sms.sendSms).toHaveBeenCalledWith(
expect.objectContaining({
to: "+251986680099",
message: expect.stringContaining(otp),
}),
);
expect(email.sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: "user@example.com",
text: expect.stringContaining(otp),
}),
);
// One row, both channels canonicalised.
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({
phone: "+251986680099",
email: "user@example.com",
});
});
it.each([
["phone alone", { phone: "0986680099" }],
["email alone", { email: "user@example.com" }],
["both", both],
])(
"verifies a dual-channel code when quoted back by %s",
async (_label, target) => {
const { service, rows } = makeService();
await service.sendOtp(both);
await expect(
service.verifyOtpForAction(target, rows()[0]!.otp),
).resolves.toEqual({ success: true });
},
);
it("consuming the code via one channel kills the other", async () => {
const { service, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
await service.verifyOtpForAction({ email: "user@example.com" }, otp);
// Single-use is per-code, not per-channel: the phone half must be dead too.
await expect(
service.verifyOtpForAction({ phone: "0986680099" }, otp),
).rejects.toThrow(/No verification code was requested/);
});
it("replaces an overlapping single-channel row instead of colliding with it", async () => {
const { service, rows } = makeService();
// A pending signup code on the phone only, then a dual-channel send.
await service.sendOtp({ phone: "0986680099" });
await service.sendOtp(both);
expect(rows()).toHaveLength(1);
expect(rows()[0]).toMatchObject({ email: "user@example.com" });
});
it("degrades to one channel when the account has only one contact", async () => {
const { service, sms, email } = makeService();
await service.sendOtp({ phone: "0986680099" });
expect(sms.sendSms).toHaveBeenCalledTimes(1);
expect(email.sendEmail).not.toHaveBeenCalled();
});
it("skips SMS for a foreign number when email is available", async () => {
const { service, sms, email, rows } = makeService();
await service.sendOtp({ phone: "+14155550123", email: "user@example.com" });
// The gateway is domestic-only — email is the delivery route, but the
// foreign phone stays on the row so verify still matches either channel.
expect(sms.sendSms).not.toHaveBeenCalled();
expect(email.sendEmail).toHaveBeenCalledTimes(1);
await expect(
service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it("still attempts SMS for a foreign number when it is the only channel", async () => {
const { service, sms } = makeService();
await service.sendOtp({ phone: "+14155550123" });
expect(sms.sendSms).toHaveBeenCalledTimes(1);
});
it("still succeeds when one transport throws", async () => {
const { service, rows } = makeService({
sms: async () => {
throw new Error("broker down");
},
});
await expect(service.sendOtp(both)).resolves.toMatchObject({
success: true,
delivered: true,
});
// The code is live and verifiable on the channel that worked.
await expect(
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
).resolves.toEqual({ success: true });
});
it("fails the request when every transport throws", async () => {
const { service } = makeService({
sms: async () => {
throw new Error("broker down");
},
email: async () => {
throw new Error("broker down");
},
});
await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP");
});
it("shares one brute-force budget across both channels", async () => {
const { service, rows } = makeService();
await service.sendOtp(both);
const otp = rows()[0]!.otp;
// Alternating channels must not hand the attacker two independent budgets:
// 5 wrong guesses in total burn the code regardless of how they are split.
for (const target of [
{ phone: "0986680099" },
{ email: "user@example.com" },
{ phone: "0986680099" },
{ email: "user@example.com" },
]) {
await expect(
service.verifyOtpForAction(target, "000000"),
).rejects.toThrow("Invalid verification code");
}
await expect(
service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
).rejects.toThrow(/Too many incorrect attempts/);
// Burned: even the correct code no longer works.
await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow(
/No verification code was requested/,
);
});
});