mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
244 lines
8.2 KiB
TypeScript
244 lines
8.2 KiB
TypeScript
import { OtpService, 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('maps local 07… mobile to +2517…', () => {
|
|
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
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('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/,
|
|
);
|
|
});
|
|
});
|