OTP overhaul: test-user bypass + hash-at-rest + Fazpass integration
- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
+ DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
pricing.service.js.
208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
176
backend/test/services/fazpass.service.test.js
Normal file
176
backend/test/services/fazpass.service.test.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
// Imported lazily after env stubs so the config getter reads the test values.
|
||||
let fazpassRequestOtp
|
||||
let fazpassVerifyOtp
|
||||
let FazpassError
|
||||
|
||||
const setFazpassEnv = () => {
|
||||
vi.stubEnv('FAZPASS_ENABLED', 'true')
|
||||
vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example')
|
||||
vi.stubEnv('FAZPASS_MERCHANT_KEY', 'test-merchant-key')
|
||||
vi.stubEnv('FAZPASS_GATEWAY_KEY', 'test-gateway-key')
|
||||
vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000')
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
setFazpassEnv()
|
||||
// Re-import so the module's top-level closures use the stubbed env.
|
||||
// getFazpassConfig reads process.env at call time so this is mostly a safety
|
||||
// belt — but it also ensures the test isn't depending on import order.
|
||||
const mod = await import('../../src/services/fazpass.service.js')
|
||||
fazpassRequestOtp = mod.fazpassRequestOtp
|
||||
fazpassVerifyOtp = mod.fazpassVerifyOtp
|
||||
FazpassError = mod.FazpassError
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl)
|
||||
|
||||
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
describe('fazpassRequestOtp', () => {
|
||||
it('POSTs phone + gateway_key with Bearer auth and returns reference', async () => {
|
||||
let captured
|
||||
mockFetch(async (url, init) => {
|
||||
captured = { url, init }
|
||||
return jsonResponse(200, {
|
||||
status: true,
|
||||
message: 'Request generated successfully',
|
||||
code: '2000200',
|
||||
data: {
|
||||
id: 'abc-123', otp: 'XXXXXX', otp_length: 6,
|
||||
channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'testing',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fazpassRequestOtp({ phone: '+628111' })
|
||||
|
||||
expect(result).toEqual({
|
||||
reference: 'abc-123',
|
||||
channel_used: 'WA_GENERIC_OTP',
|
||||
provider: 'Fazpass',
|
||||
})
|
||||
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/request')
|
||||
expect(captured.init.method).toBe('POST')
|
||||
expect(captured.init.headers.Authorization).toBe('Bearer test-merchant-key')
|
||||
expect(captured.init.headers['Content-Type']).toBe('application/json')
|
||||
expect(JSON.parse(captured.init.body)).toEqual({
|
||||
phone: '+628111',
|
||||
gateway_key: 'test-gateway-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws FazpassError on non-2xx with provider code surfaced', async () => {
|
||||
mockFetch(async () => jsonResponse(400, { status: false, code: '4000400', message: 'bad gateway_key' }))
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({
|
||||
code: 'OTP_PROVIDER_FAILED',
|
||||
statusCode: 502,
|
||||
httpStatus: 400,
|
||||
providerCode: '4000400',
|
||||
providerMessage: 'bad gateway_key',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws FazpassError when 2xx body has status:false', async () => {
|
||||
mockFetch(async () => jsonResponse(200, { status: false, code: '5000500', message: 'gateway down' }))
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', providerCode: '5000500' })
|
||||
})
|
||||
|
||||
it('throws FazpassError when 2xx body is missing data.id', async () => {
|
||||
mockFetch(async () => jsonResponse(200, { status: true, data: { otp: 'XXXXXX' } }))
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toBeInstanceOf(FazpassError)
|
||||
})
|
||||
|
||||
it('throws FazpassError on transport / timeout error', async () => {
|
||||
mockFetch(async () => { throw new Error('network down') })
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', httpStatus: null })
|
||||
})
|
||||
|
||||
it('throws when FAZPASS_ENABLED is false', async () => {
|
||||
vi.stubEnv('FAZPASS_ENABLED', 'false')
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
|
||||
})
|
||||
|
||||
it('throws when merchantKey or gatewayKey are blank', async () => {
|
||||
vi.stubEnv('FAZPASS_MERCHANT_KEY', '')
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fazpassVerifyOtp', () => {
|
||||
it('POSTs otp_id + otp with Bearer auth and returns valid:true on status:true', async () => {
|
||||
let captured
|
||||
mockFetch(async (url, init) => {
|
||||
captured = { url, init }
|
||||
return jsonResponse(200, {
|
||||
status: true, message: 'Validate otp successfully', code: '2000200',
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '424242' })
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: true,
|
||||
providerCode: '2000200',
|
||||
providerMessage: 'Validate otp successfully',
|
||||
})
|
||||
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/verify')
|
||||
expect(JSON.parse(captured.init.body)).toEqual({
|
||||
otp_id: 'abc-123', otp: '424242',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns valid:false on 2xx + status:false (the "wrong OTP" path)', async () => {
|
||||
mockFetch(async () => jsonResponse(200, {
|
||||
status: false, message: 'Invalid OTP', code: '4000401',
|
||||
}))
|
||||
|
||||
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '000000' })
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.providerCode).toBe('4000401')
|
||||
})
|
||||
|
||||
it('throws FazpassError on non-2xx (provider outage)', async () => {
|
||||
mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }))
|
||||
|
||||
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
|
||||
.rejects.toMatchObject({
|
||||
code: 'OTP_PROVIDER_FAILED',
|
||||
httpStatus: 503,
|
||||
providerCode: '5030503',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws FazpassError on malformed body (no status field)', async () => {
|
||||
mockFetch(async () => new Response('not json', { status: 200, headers: { 'Content-Type': 'text/plain' } }))
|
||||
|
||||
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
|
||||
.rejects.toBeInstanceOf(FazpassError)
|
||||
})
|
||||
|
||||
it('throws FazpassError on network error', async () => {
|
||||
mockFetch(async () => { throw new Error('connection reset') })
|
||||
|
||||
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
|
||||
})
|
||||
})
|
||||
436
backend/test/services/otp.service.test.js
Normal file
436
backend/test/services/otp.service.test.js
Normal file
@@ -0,0 +1,436 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user