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/, ); }); });