mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
281 lines
9.4 KiB
TypeScript
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/,
|
|
);
|
|
});
|
|
});
|