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:
2026-05-29 22:39:34 +08:00
parent 3a0cdf5c4e
commit 6fd98ca99c
15 changed files with 1958 additions and 158 deletions

View 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' })
})
})