import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { randomUUID } from 'node:crypto' import bcrypt from 'bcrypt' const { requestOtp, verifyOtp, OtpError } = await import('../../src/services/otp.service.js') const { addTestOtpBypassEntry, setTestOtpBypassEnabled, getTestOtpBypass, } = await import('../../src/services/config.service.js') const { db, resetDb } = await import('../helpers/db.js') // Unique phone per test so rate-limit (3 per hour per phone) doesn't poison // tests that reuse otp_requests rows. resetDb() truncates otp_requests but // keeps the rate-limit guarantee tight regardless. const uniquePhone = () => { const digits = randomUUID().replace(/[^0-9]/g, '').slice(0, 10).padEnd(10, '0') return `+628${digits}` } const clearBypassConfig = async () => { const sql = db() await sql`DELETE FROM app_config WHERE key = 'test_otp_bypass'` } const peekOtpRow = async (id) => { const sql = db() const [row] = await sql` SELECT id, phone, fazpass_reference, is_bypass, code_hash, used_at, expires_at FROM otp_requests WHERE id = ${id} ` return row } describe('otp.service — hash-at-rest (stub mode)', () => { beforeEach(async () => { await resetDb() await clearBypassConfig() }) it('stores bcrypt(code_hash) instead of plaintext after requestOtp', async () => { const phone = uniquePhone() const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.1', }) const row = await peekOtpRow(otp_request_id) expect(row).toBeDefined() expect(row.is_bypass).toBe(false) expect(row.code_hash).toMatch(/^\$2[aby]\$/) // bcrypt signature // fazpass_reference holds ONLY the stub reference now — no ":code" suffix. expect(row.fazpass_reference).toMatch(/^stub_/) expect(row.fazpass_reference).not.toContain(':') }) it('verifyOtp succeeds against the same plaintext code (via stub peek)', async () => { const phone = uniquePhone() // Pin the stub to a known code so we don't depend on the in-memory Map. vi.stubEnv('OTP_STATIC_CODE', '424242') const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.1', }) const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' }) expect(result).toEqual({ phone, user_type: 'customer' }) const used = await peekOtpRow(otp_request_id) expect(used.used_at).not.toBeNull() vi.unstubAllEnvs() }) it('verifyOtp rejects a wrong code with CODE_MISMATCH', async () => { const phone = uniquePhone() vi.stubEnv('OTP_STATIC_CODE', '111111') const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.1', }) await expect(verifyOtp({ otpRequestId: otp_request_id, code: '999999' })) .rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 }) vi.unstubAllEnvs() }) }) describe('otp.service — DB-level CHECK constraint', () => { beforeEach(async () => { await resetDb() await clearBypassConfig() }) it('rejects an insert with is_bypass=true and code_hash NULL', async () => { const sql = db() await expect(sql` INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash) VALUES ('+628999999991', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', true, NULL) `).rejects.toMatchObject({ code: '23514' }) // PG check_violation }) it('rejects an insert with is_bypass=true and fazpass_reference set', async () => { const sql = db() await expect(sql` INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash, fazpass_reference) VALUES ('+628999999992', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', true, '$2b$10$abcdefghijklmnopqrstuv', 'leak_ref') `).rejects.toMatchObject({ code: '23514' }) }) it('allows is_bypass=false with code_hash NULL (Fazpass-live shape) at insert time', async () => { const sql = db() const [row] = await sql` INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash, fazpass_reference) VALUES ('+628999999993', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', false, NULL, 'fazpass_ref_xyz') RETURNING id ` expect(row.id).toBeDefined() }) }) describe('otp.service — verify anomaly refusal', () => { beforeEach(async () => { await resetDb() await clearBypassConfig() }) it('rejects verify on a row missing BOTH code_hash and fazpass_reference (unverifiable)', async () => { const sql = db() const [row] = await sql` INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash, fazpass_reference) VALUES ('+628999999994', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', false, NULL, NULL) RETURNING id ` await expect(verifyOtp({ otpRequestId: row.id, code: '123456' })) .rejects.toMatchObject({ code: 'OTP_CORRUPT', statusCode: 500 }) }) it('returns OTP_PROVIDER_FAILED when row has fazpass_reference but Fazpass is disabled', async () => { const sql = db() const [row] = await sql` INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash, fazpass_reference) VALUES ('+628999999998', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', false, NULL, 'fazpass_ref_xyz') RETURNING id ` // FAZPASS_ENABLED is unset/false in tests; fazpassVerifyOtp throws // FazpassError, which otp.service.js converts to OTP_PROVIDER_FAILED 502. await expect(verifyOtp({ otpRequestId: row.id, code: '123456' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 }) }) }) describe('otp.service — test OTP bypass allowlist', () => { beforeEach(async () => { await resetDb() await clearBypassConfig() }) afterEach(async () => { await clearBypassConfig() }) it('plants a bypass row that verifies against the configured static OTP', async () => { const phone = uniquePhone() await setTestOtpBypassEnabled(true) await addTestOtpBypassEntry({ phone, otp: '294857', user_type: 'customer', label: 'Apple Reviewer #1', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.2', }) const row = await peekOtpRow(otp_request_id) expect(row.is_bypass).toBe(true) expect(row.fazpass_reference).toBeNull() expect(row.code_hash).toMatch(/^\$2[aby]\$/) // Verify against the configured static OTP succeeds. const result = await verifyOtp({ otpRequestId: otp_request_id, code: '294857' }) expect(result).toEqual({ phone, user_type: 'customer' }) }) it('does not match when user_type differs (same phone for customer + mitra is distinct)', async () => { const phone = uniquePhone() await setTestOtpBypassEnabled(true) await addTestOtpBypassEntry({ phone, otp: '111111', user_type: 'mitra', label: 'Internal QA Mitra', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) // Customer request to the same phone → falls through to stub. const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.3', }) const row = await peekOtpRow(otp_request_id) expect(row.is_bypass).toBe(false) expect(row.fazpass_reference).toMatch(/^stub_/) }) it('does not match when the entry has expired', async () => { const phone = uniquePhone() await setTestOtpBypassEnabled(true) // addTestOtpBypassEntry refuses past dates, so set a valid future date, // then manually backdate the entry via SQL — emulating "this entry has // been sitting in the list for too long". await addTestOtpBypassEntry({ phone, otp: '222222', user_type: 'customer', label: 'Old Reviewer', expires_at: new Date(Date.now() + 60_000).toISOString(), }) const sql = db() await sql` UPDATE app_config SET value = jsonb_set( value, '{entries,0,expires_at}', to_jsonb(${new Date(Date.now() - 60_000).toISOString()}::text) ) WHERE key = 'test_otp_bypass' ` const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.4', }) const row = await peekOtpRow(otp_request_id) expect(row.is_bypass).toBe(false) }) it('does not match when the global kill switch is off', async () => { const phone = uniquePhone() await setTestOtpBypassEnabled(true) await addTestOtpBypassEntry({ phone, otp: '333333', user_type: 'customer', label: 'Disabled later', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) // Flip the kill switch off — entries remain but no longer match. await setTestOtpBypassEnabled(false) const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.5', }) const row = await peekOtpRow(otp_request_id) expect(row.is_bypass).toBe(false) }) it('rejects an entry whose plaintext OTP is malformed', async () => { await expect(addTestOtpBypassEntry({ phone: '+628999999995', otp: 'abc', user_type: 'customer', label: 'Bad OTP', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), })).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) }) it('rejects an entry whose expires_at is in the past', async () => { await expect(addTestOtpBypassEntry({ phone: '+628999999996', otp: '123456', user_type: 'customer', label: 'Stale', expires_at: new Date(Date.now() - 60_000).toISOString(), })).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) }) it('rejects a duplicate (phone, user_type) entry', async () => { const phone = uniquePhone() const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() await addTestOtpBypassEntry({ phone, otp: '101010', user_type: 'customer', label: 'First', expires_at: future, }) await expect(addTestOtpBypassEntry({ phone, otp: '202020', user_type: 'customer', label: 'Second', expires_at: future, })).rejects.toMatchObject({ code: 'DUPLICATE_ENTRY' }) }) // No new tests in this describe — see "Fazpass-live mode" below for the // request/verify integration coverage. it('getTestOtpBypass returns the bcrypt hash, not the plaintext OTP', async () => { const phone = uniquePhone() await addTestOtpBypassEntry({ phone, otp: '424242', user_type: 'customer', label: 'Apple #1', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) const list = await getTestOtpBypass() expect(list.entries).toHaveLength(1) const entry = list.entries[0] expect(entry.otp_hash).toMatch(/^\$2[aby]\$/) // Defense-in-depth: serialised object must not contain the plaintext anywhere. expect(JSON.stringify(entry)).not.toContain('424242') // And the hash actually matches the plaintext (so verify works downstream). expect(await bcrypt.compare('424242', entry.otp_hash)).toBe(true) }) }) describe('otp.service — Fazpass-live mode (FAZPASS_ENABLED=true)', () => { const setFazpassEnv = () => { vi.stubEnv('FAZPASS_ENABLED', 'true') vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example') vi.stubEnv('FAZPASS_MERCHANT_KEY', 'mkey') vi.stubEnv('FAZPASS_GATEWAY_KEY', 'gkey') vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000') } const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl) const jsonResponse = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) beforeEach(async () => { setFazpassEnv() await resetDb() await clearBypassConfig() }) afterEach(() => { vi.restoreAllMocks() vi.unstubAllEnvs() }) it('requestOtp stores fazpass_reference + leaves code_hash NULL when Fazpass returns success', async () => { mockFetch(async () => jsonResponse(200, { status: true, data: { id: 'fzp-ref-001', otp: 'XXXXXX', otp_length: 6, channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'production', }, })) const phone = uniquePhone() const { otp_request_id, channel_used } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.10', channel: 'whatsapp', }) expect(channel_used).toBe('whatsapp') // API contract: echoes client-requested channel const row = await peekOtpRow(otp_request_id) expect(row.is_bypass).toBe(false) expect(row.fazpass_reference).toBe('fzp-ref-001') expect(row.code_hash).toBeNull() }) it('requestOtp propagates Fazpass error and does NOT insert a row', async () => { mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' })) const phone = uniquePhone() await expect(requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.11', channel: 'whatsapp', })).rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 }) const sql = db() const [{ n }] = await sql`SELECT COUNT(*)::int AS n FROM otp_requests WHERE phone = ${phone}` expect(n).toBe(0) }) it('verifyOtp delegates to Fazpass and succeeds on status:true', async () => { // Sequence: 1st fetch = /request, 2nd fetch = /verify. let call = 0 mockFetch(async (url) => { call++ if (url.endsWith('/v1/otp/request')) { return jsonResponse(200, { status: true, data: { id: 'fzp-ref-002' } }) } if (url.endsWith('/v1/otp/verify')) { return jsonResponse(200, { status: true, code: '2000200' }) } throw new Error(`unexpected url ${url}`) }) const phone = uniquePhone() const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.12', channel: 'whatsapp', }) const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' }) expect(result).toEqual({ phone, user_type: 'customer' }) expect(call).toBe(2) const used = await peekOtpRow(otp_request_id) expect(used.used_at).not.toBeNull() }) it('verifyOtp surfaces wrong OTP as CODE_MISMATCH when Fazpass returns status:false', async () => { mockFetch(async (url) => { if (url.endsWith('/v1/otp/request')) { return jsonResponse(200, { status: true, data: { id: 'fzp-ref-003' } }) } return jsonResponse(200, { status: false, code: '4000401', message: 'Invalid OTP' }) }) const phone = uniquePhone() const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.13', channel: 'whatsapp', }) await expect(verifyOtp({ otpRequestId: otp_request_id, code: '000000' })) .rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 }) // Row stays unused — attempts incremented but not marked. const row = await peekOtpRow(otp_request_id) expect(row.used_at).toBeNull() }) it('verifyOtp returns OTP_PROVIDER_FAILED 502 on Fazpass outage (distinct from wrong code)', async () => { mockFetch(async (url) => { if (url.endsWith('/v1/otp/request')) { return jsonResponse(200, { status: true, data: { id: 'fzp-ref-004' } }) } return jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }) }) const phone = uniquePhone() const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.14', channel: 'whatsapp', }) await expect(verifyOtp({ otpRequestId: otp_request_id, code: '424242' })) .rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 }) }) it('test-OTP bypass still works even when FAZPASS_ENABLED=true (skips Fazpass entirely)', async () => { const fetchSpy = mockFetch(async () => { throw new Error('Fazpass MUST NOT be called for bypass rows') }) const phone = uniquePhone() await setTestOtpBypassEnabled(true) await addTestOtpBypassEntry({ phone, otp: '999000', user_type: 'customer', label: 'Apple #1', expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), }) const { otp_request_id } = await requestOtp({ phone, userType: 'customer', ipAddress: '10.0.0.15', channel: 'whatsapp', }) const result = await verifyOtp({ otpRequestId: otp_request_id, code: '999000' }) expect(result).toEqual({ phone, user_type: 'customer' }) expect(fetchSpy).not.toHaveBeenCalled() }) })