- 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>
437 lines
16 KiB
JavaScript
437 lines
16 KiB
JavaScript
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()
|
|
})
|
|
})
|