fix issue

This commit is contained in:
Marshal
2026-07-16 01:05:57 +00:00
parent 41fe04652f
commit fe29b38377
18 changed files with 518 additions and 51 deletions

View File

@@ -1,18 +1,61 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OtpService } from './otp.service';
import { OtpService, normalizeOtpTarget } from './otp.service';
describe('OtpService', () => {
let service: OtpService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OtpService],
}).compile();
service = module.get<OtpService>(OtpService);
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('should be defined', () => {
expect(service).toBeDefined();
it('maps local 07… mobile to +2517…', () => {
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
});
it('passes email targets through untouched', () => {
expect(normalizeOtpTarget({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' });
});
it('keeps an already-normalised number stable (idempotent)', () => {
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
});
});
describe('OtpService — send/verify agree across phone formats', () => {
// In-memory fake keyed by the exact phone string the service stores under, so
// the test proves normalisation makes send and verify collide on one key.
function makeService() {
const rows = new Map<string, { phone?: string; email?: string; otp: string; updatedAt: Date }>();
const repo = {
findByTarget: jest.fn(async (t: { phone?: string; email?: string }) =>
rows.get(t.email ?? t.phone!) ?? null,
),
updateOtp: jest.fn(async (existing: { otp: string }, otp: string) => {
existing.otp = otp;
}),
createOtp: jest.fn(async (t: { phone?: string; email?: string }, otp: string) => {
rows.set(t.phone ?? t.email!, { ...t, otp, updatedAt: new Date(0) });
}),
deleteOtp: jest.fn(async (row: { phone?: string; email?: string }) => {
rows.delete(row.phone ?? row.email!);
}),
};
const sms = { sendSms: jest.fn().mockResolvedValue(undefined) };
const email = { sendEmail: jest.fn().mockResolvedValue(undefined) };
const service = new OtpService(repo as never, sms as never, email as never);
return { service, rows };
}
it('verifies a code sent to +251… when verify is called with 09…', async () => {
const { service, rows } = makeService();
await service.sendOtp({ phone: '+251986680099' });
const stored = [...rows.values()][0]!.otp;
// Fresh TTL: stamp updatedAt to now so the action verifier does not expire it.
[...rows.values()][0]!.updatedAt = new Date();
await expect(
service.verifyOtpForAction({ phone: '0986680099' }, stored),
).resolves.toEqual({ success: true });
});
});

View File

@@ -12,6 +12,28 @@ import { EmailClientService } from "../notifications/email-client.service";
// reaches here.
export type OtpTarget = { phone?: string; email?: string };
/**
* Canonicalise a phone to E.164 so the code stored on send and the one looked
* up on verify collide regardless of how the number was typed. Without this,
* `+251986680099`, `251986680099` and `0986680099` are three different keys and
* a code sent to one is invisible to the others — the send/verify halves must
* agree on the exact string. Ethiopian local `09…`/`07…` (10 digits) maps to
* `+2519…`/`+2517…`; a bare `251…` gains its `+`; anything already `+…` is kept.
* Email targets pass through untouched.
*/
export function normalizeOtpTarget(target: OtpTarget): OtpTarget {
if (target.email || !target.phone) return target;
const raw = target.phone.trim();
const digits = raw.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return { phone: digits };
const bare = digits.replace(/^0+/, '');
if (/^251\d{9}$/.test(digits)) return { phone: `+${digits}` };
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return { phone: `+251${bare}` };
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
// it looks like a full international number, else leave as typed.
return { phone: digits.length >= 11 ? `+${digits}` : raw };
}
@Injectable()
export class OtpService {
logger = new Logger(OtpService.name);
@@ -35,7 +57,10 @@ export class OtpService {
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(target: OtpTarget) {
async sendOtp(rawTarget: OtpTarget) {
// Store under the canonical E.164 key so verify (which normalises the same
// way) always finds this row regardless of how either side typed the number.
const target = normalizeOtpTarget(rawTarget);
try {
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
@@ -99,7 +124,10 @@ export class OtpService {
// Verify OTP
// ---------------------------------------------------------------------------
async verifyOtp(target: OtpTarget, otp: string) {
async verifyOtp(rawTarget: OtpTarget, otp: string) {
// Same canonicalisation as sendOtp so a code stored under +2519… is found
// when verify is called with 09… (or any equivalent form).
const target = normalizeOtpTarget(rawTarget);
// find the channel's row
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);
@@ -173,10 +201,11 @@ export class OtpService {
}
async verifyOtpForAction(
target: OtpTarget,
rawTarget: OtpTarget,
otp: string,
ttlMs: number = this.ACTION_OTP_TTL_MS,
) {
const target = normalizeOtpTarget(rawTarget);
const otpData = await this.otpRepository.findByTarget(target);
const key = this.targetKey(target);