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' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user