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:
@@ -19,10 +19,21 @@ AUTH_JWT_SECRET=replace-with-strong-random-32+char-secret
|
||||
ACCESS_TOKEN_TTL_SECONDS=3600
|
||||
REFRESH_TOKEN_TTL_DAYS=30
|
||||
|
||||
# Fazpass (OTP provider — TBD real values once docs are available)
|
||||
FAZPASS_API_KEY=
|
||||
FAZPASS_BASE_URL=
|
||||
FAZPASS_WEBHOOK_SECRET=
|
||||
# --- Fazpass (OTP provider) ---
|
||||
#
|
||||
# When FAZPASS_ENABLED=true, requestOtp() calls Fazpass /v1/otp/request and
|
||||
# verifyOtp() calls Fazpass /v1/otp/verify. When false, the in-process stub
|
||||
# generates + verifies codes locally (dev/test default).
|
||||
#
|
||||
# Single merchant key authenticates the account; single gateway key selects
|
||||
# the (channel + provider) tuple configured in dashboard → Integration → Add
|
||||
# Gateway. The client-supplied `channel` in /otp/request becomes informational
|
||||
# only when Fazpass is live — the gateway decides which channel actually fires.
|
||||
FAZPASS_ENABLED=false
|
||||
FAZPASS_BASE_URL=https://api.fazpass.com
|
||||
FAZPASS_MERCHANT_KEY=
|
||||
FAZPASS_GATEWAY_KEY=
|
||||
FAZPASS_TIMEOUT_MS=10000
|
||||
|
||||
# Google OAuth — comma-separated list of valid audience client IDs (Android, iOS).
|
||||
GOOGLE_OAUTH_CLIENT_IDS=
|
||||
|
||||
@@ -403,6 +403,18 @@ const migrate = async () => {
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL`
|
||||
|
||||
// OTP requests (Fazpass reference + rate-limit tracking)
|
||||
//
|
||||
// Storage shape rationale:
|
||||
// - is_bypass : explicit intent flag — true only when a row was created by
|
||||
// the test-OTP-bypass allowlist (phone-scoped static OTP for
|
||||
// App Store reviewers). Verify routes on this flag, NOT on
|
||||
// the mere presence of code_hash.
|
||||
// - code_hash : bcrypt hash of the OTP code, present whenever the backend
|
||||
// owns verification (stub-mode rows + bypass rows). NULL when
|
||||
// Fazpass owns verification (post-cutover, non-bypass rows).
|
||||
// - CHECK constraint: bypass rows MUST have code_hash and MUST NOT carry a
|
||||
// Fazpass reference — physically prevents a bypass row from
|
||||
// ever falling into the Fazpass-verify path.
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS otp_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -414,12 +426,36 @@ const migrate = async () => {
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
code_hash VARCHAR(255)
|
||||
)
|
||||
`
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`
|
||||
|
||||
// Idempotent ALTERs for DBs created before is_bypass/code_hash were added.
|
||||
await sql`
|
||||
ALTER TABLE otp_requests
|
||||
ADD COLUMN IF NOT EXISTS is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)
|
||||
`
|
||||
|
||||
// Drop-then-add lets us tighten the invariant later without writing a v2.
|
||||
// The constraint is defense-in-depth alongside the verifyOtp branching: even
|
||||
// if app code regressed, the DB refuses to insert a corrupt bypass row.
|
||||
await sql`ALTER TABLE otp_requests DROP CONSTRAINT IF EXISTS otp_requests_bypass_shape`
|
||||
await sql`
|
||||
ALTER TABLE otp_requests
|
||||
ADD CONSTRAINT otp_requests_bypass_shape CHECK (
|
||||
is_bypass = FALSE OR (
|
||||
is_bypass = TRUE
|
||||
AND code_hash IS NOT NULL
|
||||
AND fazpass_reference IS NULL
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
// Auth-related app_config defaults
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value) VALUES
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getDb } from '../../db/client.js'
|
||||
import {
|
||||
getAnonymityConfig, setAnonymityConfig,
|
||||
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
|
||||
getFreeTrialConfig, setFreeTrialConfig,
|
||||
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
|
||||
getEarlyEndConfig, setEarlyEndConfig,
|
||||
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||
getSupportHandles, setSupportHandles,
|
||||
getTestOtpBypass, setTestOtpBypassEnabled, addTestOtpBypassEntry,
|
||||
updateTestOtpBypassEntry, deleteTestOtpBypassEntry,
|
||||
} from '../../services/config.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
@@ -111,22 +112,6 @@ export const internalConfigRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 3: Free Trial ---
|
||||
app.get('/free-trial', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const config = await getFreeTrialConfig()
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
app.patch('/free-trial', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { enabled, duration_minutes } = request.body ?? {}
|
||||
const config = await setFreeTrialConfig({ enabled, duration_minutes })
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 3: Extension Timeout ---
|
||||
app.get('/extension-timeout', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
@@ -735,6 +720,102 @@ export const internalConfigRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
// --- Test OTP bypass allowlist ---
|
||||
//
|
||||
// Phone-scoped static-OTP entries for Apple App Store reviewers / pre-launch
|
||||
// QA. See config.service.js for the storage shape and security rationale.
|
||||
// Writes publish 'config:invalidate' so peer instances drop any future cache;
|
||||
// today every read hits the DB, so this is mostly future-proofing.
|
||||
|
||||
const sendError = (reply, err) => {
|
||||
const status = err.statusCode || 500
|
||||
const payload = {
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code || 'INTERNAL',
|
||||
message: err.message,
|
||||
...(err.field && { field: err.field }),
|
||||
},
|
||||
}
|
||||
return reply.code(status).send(payload)
|
||||
}
|
||||
|
||||
app.get('/test-otp-bypass', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getTestOtpBypass() })
|
||||
})
|
||||
|
||||
app.patch('/test-otp-bypass/enabled', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { enabled } = request.body ?? {}
|
||||
try {
|
||||
const data = await setTestOtpBypassEnabled(enabled)
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
return reply.send({ success: true, data })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/test-otp-bypass/entries', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { phone, otp, user_type, label, expires_at } = request.body ?? {}
|
||||
try {
|
||||
const entry = await addTestOtpBypassEntry({ phone, otp, user_type, label, expires_at })
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
request.log.info({
|
||||
event: 'test_otp_bypass.entry_created',
|
||||
entry_id: entry.id,
|
||||
label: entry.label,
|
||||
phone_last4: entry.phone.slice(-4),
|
||||
user_type: entry.user_type,
|
||||
actor_cc_user_id: request.auth.userId,
|
||||
}, 'test OTP bypass entry created')
|
||||
return reply.code(201).send({ success: true, data: entry })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/test-otp-bypass/entries/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
const entry = await updateTestOtpBypassEntry(id, request.body ?? {})
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
request.log.info({
|
||||
event: 'test_otp_bypass.entry_updated',
|
||||
entry_id: entry.id,
|
||||
actor_cc_user_id: request.auth.userId,
|
||||
}, 'test OTP bypass entry updated')
|
||||
return reply.send({ success: true, data: entry })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/test-otp-bypass/entries/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
const result = await deleteTestOtpBypassEntry(id)
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
request.log.info({
|
||||
event: 'test_otp_bypass.entry_deleted',
|
||||
entry_id: id,
|
||||
actor_cc_user_id: request.auth.userId,
|
||||
}, 'test OTP bypass entry deleted')
|
||||
return reply.send({ success: true, data: result })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
app.get('/support-handles', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
|
||||
@@ -64,6 +64,7 @@ export const clientAuthRoutes = async (app) => {
|
||||
userType: UserType.CUSTOMER,
|
||||
ipAddress: request.ip,
|
||||
channel,
|
||||
logger: request.log,
|
||||
})
|
||||
return reply.send({ success: true, data: result })
|
||||
} catch (err) {
|
||||
@@ -74,7 +75,7 @@ export const clientAuthRoutes = async (app) => {
|
||||
app.post('/otp/verify', async (request, reply) => {
|
||||
const { otp_request_id, code } = request.body || {}
|
||||
try {
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code })
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code, logger: request.log })
|
||||
if (user_type !== UserType.CUSTOMER) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
|
||||
@@ -30,6 +30,7 @@ export const mitraAuthRoutes = async (app) => {
|
||||
userType: UserType.MITRA,
|
||||
ipAddress: request.ip,
|
||||
channel,
|
||||
logger: request.log,
|
||||
})
|
||||
return reply.send({ success: true, data: result })
|
||||
} catch (err) {
|
||||
@@ -40,7 +41,7 @@ export const mitraAuthRoutes = async (app) => {
|
||||
app.post('/otp/verify', async (request, reply) => {
|
||||
const { otp_request_id, code } = request.body || {}
|
||||
try {
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code })
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code, logger: request.log })
|
||||
if (user_type !== UserType.MITRA) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import crypto from 'node:crypto'
|
||||
import { getDb } from '../db/client.js'
|
||||
import { ExtensionTimeoutAction } from '../constants.js'
|
||||
import { ExtensionTimeoutAction, UserType } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// bcrypt cost for the per-entry static OTP. Same rationale as
|
||||
// otp.service.js OTP_BCRYPT_COST — 10 keeps the verify SLA tight without
|
||||
// meaningfully reducing protection (OTPs are 6 digits; cost mostly buys time
|
||||
// against an offline DB-dump brute force, which the 5-min TTL already bounds).
|
||||
const TEST_OTP_BYPASS_BCRYPT_COST = 10
|
||||
|
||||
export const getAnonymityConfig = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
|
||||
return { anonymity_enabled: row?.value?.enabled ?? false }
|
||||
@@ -35,49 +43,6 @@ export const setMaxCustomersPerMitra = async (value) => {
|
||||
return { max_customers_per_mitra: value }
|
||||
}
|
||||
|
||||
// --- Phase 4: First-session discount config (back-compat shim) ---
|
||||
//
|
||||
// The canonical source of truth for the first-session discount lives in the
|
||||
// `pricing_promotions` table (eligibility = 'first_session'). The CC settings
|
||||
// page still calls `/internal/config/free-trial`, which exposes a slim
|
||||
// {enabled, duration_minutes} view — kept as a back-compat shim until the CC
|
||||
// UI is migrated to the richer /internal/config/first-session-discount handler.
|
||||
// Reads and writes go directly against `pricing_promotions` so operator edits
|
||||
// stay in sync with the customer-facing pricing payload.
|
||||
//
|
||||
// The legacy `first_session_discount_*` keys in `app_config` were retired in
|
||||
// Stage 5 (deleted by migrate.js) — do NOT reintroduce them.
|
||||
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const [row] = await sql`
|
||||
SELECT enabled, duration_minutes FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return {
|
||||
enabled: row?.enabled ?? true,
|
||||
duration_minutes: row?.duration_minutes ?? 12,
|
||||
}
|
||||
}
|
||||
|
||||
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||
// Build a sparse UPDATE so undefined fields are left alone (matches the prior
|
||||
// semantics where missing patch fields were no-ops). Use COALESCE on each
|
||||
// column with the sentinel-when-undefined pattern; postgres.js parameterizes
|
||||
// null/undefined identically, so we branch on which fields the caller sent.
|
||||
if (enabled === undefined && duration_minutes === undefined) {
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = ${enabled === undefined ? sql`enabled` : enabled},
|
||||
duration_minutes = ${duration_minutes === undefined ? sql`duration_minutes` : duration_minutes},
|
||||
updated_at = NOW()
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
|
||||
export const getSupportHandles = async () => {
|
||||
@@ -177,6 +142,25 @@ export const getValkeyOnlineMirrorSweepSeconds = () => {
|
||||
return Number.isFinite(parsed) && parsed >= 30 ? parsed : 300
|
||||
}
|
||||
|
||||
// --- Fazpass (OTP provider) ---
|
||||
//
|
||||
// Env-driven per backend/CLAUDE.md Config-Source Convention. Read at call
|
||||
// time (not module load) so test setups can inject via vi.stubEnv. When
|
||||
// `enabled` is true, otp.service.js routes both /request and /verify through
|
||||
// Fazpass; when false, the in-process stub plays the role of provider.
|
||||
export const getFazpassConfig = () => {
|
||||
const rawTimeout = Number.parseInt(process.env.FAZPASS_TIMEOUT_MS ?? '', 10)
|
||||
// Trim — dotenv preserves leading whitespace after `=` and a stray space
|
||||
// would corrupt the `Authorization: Bearer …` header silently.
|
||||
return {
|
||||
enabled: process.env.FAZPASS_ENABLED === 'true',
|
||||
baseUrl: (process.env.FAZPASS_BASE_URL || 'https://api.fazpass.com').trim(),
|
||||
merchantKey: (process.env.FAZPASS_MERCHANT_KEY ?? '').trim(),
|
||||
gatewayKey: (process.env.FAZPASS_GATEWAY_KEY ?? '').trim(),
|
||||
timeoutMs: Number.isFinite(rawTimeout) && rawTimeout >= 1000 ? rawTimeout : 10_000,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 5: Xendit integration ---
|
||||
//
|
||||
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values
|
||||
@@ -390,3 +374,242 @@ export const setPairingBlastTimeoutSeconds = async (value) => {
|
||||
`
|
||||
return { pairing_blast_timeout_seconds: value }
|
||||
}
|
||||
|
||||
// --- Test OTP bypass allowlist ---
|
||||
//
|
||||
// Phone-scoped static-OTP allowlist for Apple App Store reviewers and similar
|
||||
// pre-launch QA. When the phone in requestOtp() matches an entry here, the
|
||||
// backend skips Fazpass entirely and plants the entry's pre-hashed OTP into
|
||||
// otp_requests so the existing verify path works unchanged.
|
||||
//
|
||||
// Storage shape:
|
||||
// {
|
||||
// enabled: boolean, // global kill switch
|
||||
// entries: [
|
||||
// {
|
||||
// id: uuid,
|
||||
// phone: "+E.164",
|
||||
// user_type: "client" | "mitra",
|
||||
// otp_hash: "$2b$10$...", // bcrypt; plaintext NEVER stored
|
||||
// label: "Apple Reviewer #1",
|
||||
// expires_at: "ISO-8601", // per-entry auto-disable
|
||||
// created_at: "ISO-8601",
|
||||
// },
|
||||
// ...
|
||||
// ],
|
||||
// }
|
||||
//
|
||||
// Plaintext OTP is accepted by setTestOtpBypass at write time, bcrypt-hashed
|
||||
// before persisting, and is never readable again — list/get returns hashes
|
||||
// only, callers re-create entries to rotate the secret.
|
||||
|
||||
const TEST_OTP_BYPASS_KEY = 'test_otp_bypass'
|
||||
|
||||
const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/
|
||||
const STATIC_OTP_RE = /^\d{4,8}$/
|
||||
|
||||
const isValidIsoDate = (v) => {
|
||||
if (typeof v !== 'string') return false
|
||||
const d = new Date(v)
|
||||
return !Number.isNaN(d.getTime())
|
||||
}
|
||||
|
||||
const sanitizeEntry = (entry) => ({
|
||||
id: entry.id,
|
||||
phone: entry.phone,
|
||||
user_type: entry.user_type,
|
||||
label: entry.label,
|
||||
// otp_hash is intentionally returned so the CC can show "hash on file" but
|
||||
// never the plaintext. We could redact further if the CC ever leaks logs.
|
||||
otp_hash: entry.otp_hash,
|
||||
expires_at: entry.expires_at,
|
||||
created_at: entry.created_at,
|
||||
})
|
||||
|
||||
const loadRawBypass = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = ${TEST_OTP_BYPASS_KEY}`
|
||||
const value = row?.value ?? { enabled: false, entries: [] }
|
||||
return {
|
||||
enabled: value.enabled === true,
|
||||
entries: Array.isArray(value.entries) ? value.entries : [],
|
||||
}
|
||||
}
|
||||
|
||||
const persistBypass = async ({ enabled, entries }) => {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${TEST_OTP_BYPASS_KEY}, ${sql.json({ enabled, entries })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
|
||||
export const getTestOtpBypass = async () => {
|
||||
const raw = await loadRawBypass()
|
||||
return {
|
||||
enabled: raw.enabled,
|
||||
entries: raw.entries.map(sanitizeEntry),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-path matcher used by requestOtp(). Returns the matching entry (with
|
||||
* otp_hash) if (kill switch on) + (phone exact match) + (not expired) +
|
||||
* (user_type matches). Returns null otherwise.
|
||||
*
|
||||
* Every call is a fresh DB SELECT — same pattern as the other config getters.
|
||||
* Cache TBD (see project memory: `config_cache_pending`).
|
||||
*/
|
||||
export const getTestOtpBypassMatch = async ({ phone, userType }) => {
|
||||
const raw = await loadRawBypass()
|
||||
if (!raw.enabled) return null
|
||||
const now = Date.now()
|
||||
for (const entry of raw.entries) {
|
||||
if (entry.phone !== phone) continue
|
||||
if (entry.user_type !== userType) continue
|
||||
if (!entry.expires_at) continue
|
||||
const exp = new Date(entry.expires_at).getTime()
|
||||
if (!Number.isFinite(exp) || exp <= now) continue
|
||||
return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const setTestOtpBypassEnabled = async (enabled) => {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw Object.assign(new Error('enabled must be a boolean'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422,
|
||||
})
|
||||
}
|
||||
const raw = await loadRawBypass()
|
||||
await persistBypass({ ...raw, enabled })
|
||||
return getTestOtpBypass()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry. `otp` is plaintext (4-8 digits); we hash before persisting
|
||||
* and do not return it after. Phone must be E.164. user_type must match the
|
||||
* UserType enum (client | mitra). expires_at is required and must be in the
|
||||
* future. Duplicate (phone, user_type) is rejected.
|
||||
*/
|
||||
export const addTestOtpBypassEntry = async ({ phone, otp, user_type, label, expires_at }) => {
|
||||
if (typeof phone !== 'string' || !PHONE_E164_RE.test(phone)) {
|
||||
throw Object.assign(new Error('phone must be E.164 (e.g. +628...)'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'phone',
|
||||
})
|
||||
}
|
||||
if (typeof otp !== 'string' || !STATIC_OTP_RE.test(otp)) {
|
||||
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
|
||||
})
|
||||
}
|
||||
if (user_type !== UserType.CUSTOMER && user_type !== UserType.MITRA) {
|
||||
throw Object.assign(new Error(`user_type must be "${UserType.CUSTOMER}" or "${UserType.MITRA}"`), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'user_type',
|
||||
})
|
||||
}
|
||||
if (typeof label !== 'string' || label.trim().length === 0) {
|
||||
throw Object.assign(new Error('label is required'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
|
||||
})
|
||||
}
|
||||
if (!isValidIsoDate(expires_at)) {
|
||||
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
if (new Date(expires_at).getTime() <= Date.now()) {
|
||||
throw Object.assign(new Error('expires_at must be in the future'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
|
||||
const raw = await loadRawBypass()
|
||||
if (raw.entries.some((e) => e.phone === phone && e.user_type === user_type)) {
|
||||
throw Object.assign(new Error('An entry for this phone + user_type already exists'), {
|
||||
code: 'DUPLICATE_ENTRY', statusCode: 422, field: 'phone',
|
||||
})
|
||||
}
|
||||
|
||||
const otpHash = await bcrypt.hash(otp, TEST_OTP_BYPASS_BCRYPT_COST)
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
phone,
|
||||
user_type,
|
||||
label: label.trim(),
|
||||
otp_hash: otpHash,
|
||||
expires_at: new Date(expires_at).toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
raw.entries.push(entry)
|
||||
await persistBypass(raw)
|
||||
return sanitizeEntry(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch an entry by id. Supported fields: label, expires_at, otp (plaintext
|
||||
* → rehashed). Phone and user_type are immutable — delete + re-add to change
|
||||
* them, so the audit trail stays clean.
|
||||
*/
|
||||
export const updateTestOtpBypassEntry = async (id, patch) => {
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
throw Object.assign(new Error('id is required'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'id',
|
||||
})
|
||||
}
|
||||
const raw = await loadRawBypass()
|
||||
const idx = raw.entries.findIndex((e) => e.id === id)
|
||||
if (idx < 0) {
|
||||
throw Object.assign(new Error('Entry not found'), {
|
||||
code: 'NOT_FOUND', statusCode: 404,
|
||||
})
|
||||
}
|
||||
const current = raw.entries[idx]
|
||||
const next = { ...current }
|
||||
|
||||
if (patch.label !== undefined) {
|
||||
if (typeof patch.label !== 'string' || patch.label.trim().length === 0) {
|
||||
throw Object.assign(new Error('label must be a non-empty string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
|
||||
})
|
||||
}
|
||||
next.label = patch.label.trim()
|
||||
}
|
||||
if (patch.expires_at !== undefined) {
|
||||
if (!isValidIsoDate(patch.expires_at)) {
|
||||
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
if (new Date(patch.expires_at).getTime() <= Date.now()) {
|
||||
throw Object.assign(new Error('expires_at must be in the future'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
next.expires_at = new Date(patch.expires_at).toISOString()
|
||||
}
|
||||
if (patch.otp !== undefined) {
|
||||
if (typeof patch.otp !== 'string' || !STATIC_OTP_RE.test(patch.otp)) {
|
||||
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
|
||||
})
|
||||
}
|
||||
next.otp_hash = await bcrypt.hash(patch.otp, TEST_OTP_BYPASS_BCRYPT_COST)
|
||||
}
|
||||
|
||||
raw.entries[idx] = next
|
||||
await persistBypass(raw)
|
||||
return sanitizeEntry(next)
|
||||
}
|
||||
|
||||
export const deleteTestOtpBypassEntry = async (id) => {
|
||||
const raw = await loadRawBypass()
|
||||
const before = raw.entries.length
|
||||
raw.entries = raw.entries.filter((e) => e.id !== id)
|
||||
if (raw.entries.length === before) {
|
||||
throw Object.assign(new Error('Entry not found'), {
|
||||
code: 'NOT_FOUND', statusCode: 404,
|
||||
})
|
||||
}
|
||||
await persistBypass(raw)
|
||||
return { deleted: true, id }
|
||||
}
|
||||
|
||||
192
backend/src/services/fazpass.service.js
Normal file
192
backend/src/services/fazpass.service.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// Fazpass OTP provider client.
|
||||
//
|
||||
// Two endpoints per Fazpass docs:
|
||||
// POST /v1/otp/request — Fazpass creates the OTP, masks it in response,
|
||||
// owns verification (mandatory at provider).
|
||||
// POST /v1/otp/verify — submit { otp_id, otp } back to provider.
|
||||
//
|
||||
// Auth: Authorization: Bearer <MERCHANT_KEY>. Channel selection is via
|
||||
// `gateway_key` in the body — one gateway per provider (we use a single
|
||||
// gateway today, so the client-supplied OtpChannel is informational).
|
||||
//
|
||||
// Error policy:
|
||||
// - Transport/timeout failures → throw FazpassError (502 upstream)
|
||||
// - 4xx with parseable body → throw FazpassError with the body's
|
||||
// `code` + `message` for log triage
|
||||
// - 2xx with `status: false` → success-shaped failure path; for
|
||||
// /request this is a provider reject
|
||||
// (throws), for /verify this is the
|
||||
// normal "wrong OTP" (returns {valid:false})
|
||||
// - 2xx with `status: true` (or undefined on legacy responses)
|
||||
// → success
|
||||
//
|
||||
// We do NOT trust HTTP status alone — Fazpass occasionally returns 200 OK with
|
||||
// `status: false` for legitimate "wrong code" responses, which must not be
|
||||
// reported as outages. The verify path differentiates this from a real outage
|
||||
// by always parsing the body and only escalating to FazpassError if the body
|
||||
// is unparseable or the http status is non-2xx.
|
||||
|
||||
import { getFazpassConfig } from './config.service.js'
|
||||
|
||||
export class FazpassError extends Error {
|
||||
constructor(message, { httpStatus, providerCode, providerMessage, cause } = {}) {
|
||||
super(message)
|
||||
this.name = 'FazpassError'
|
||||
this.code = 'OTP_PROVIDER_FAILED'
|
||||
this.statusCode = 502
|
||||
this.httpStatus = httpStatus ?? null
|
||||
this.providerCode = providerCode ?? null
|
||||
this.providerMessage = providerMessage ?? null
|
||||
if (cause) this.cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
const buildHeaders = (merchantKey) => ({
|
||||
'Authorization': `Bearer ${merchantKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
const parseJsonSafe = async (res) => {
|
||||
try {
|
||||
return await res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const postJson = async ({ url, body, headers, timeoutMs, logger }) => {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
} catch (err) {
|
||||
// AbortError (timeout) or network failure — log once, then escalate.
|
||||
logger?.error({ err: { message: err?.message, name: err?.name }, url }, 'Fazpass HTTP error')
|
||||
throw new FazpassError(`Fazpass request failed: ${err?.message ?? 'unknown error'}`, { cause: err })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an OTP from Fazpass. Returns { reference, channel_used, provider }.
|
||||
* Throws FazpassError on transport, non-2xx, or status:false response.
|
||||
*/
|
||||
export const fazpassRequestOtp = async ({ phone, logger }) => {
|
||||
const cfg = getFazpassConfig()
|
||||
if (!cfg.enabled) {
|
||||
throw new FazpassError('Fazpass is not enabled (FAZPASS_ENABLED=false)')
|
||||
}
|
||||
if (!cfg.merchantKey || !cfg.gatewayKey) {
|
||||
throw new FazpassError('Fazpass credentials missing — set FAZPASS_MERCHANT_KEY and FAZPASS_GATEWAY_KEY')
|
||||
}
|
||||
|
||||
const url = `${cfg.baseUrl}/v1/otp/request`
|
||||
const res = await postJson({
|
||||
url,
|
||||
headers: buildHeaders(cfg.merchantKey),
|
||||
body: { phone, gateway_key: cfg.gatewayKey },
|
||||
timeoutMs: cfg.timeoutMs,
|
||||
logger,
|
||||
})
|
||||
const json = await parseJsonSafe(res)
|
||||
|
||||
if (!res.ok) {
|
||||
logger?.warn({
|
||||
event: 'fazpass.request.non_2xx',
|
||||
http_status: res.status,
|
||||
provider_code: json?.code ?? null,
|
||||
provider_message: json?.message ?? null,
|
||||
}, 'Fazpass /request non-2xx')
|
||||
throw new FazpassError(`Fazpass /request returned HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
providerCode: json?.code ?? null,
|
||||
providerMessage: json?.message ?? null,
|
||||
})
|
||||
}
|
||||
if (json?.status !== true || !json?.data?.id) {
|
||||
logger?.warn({
|
||||
event: 'fazpass.request.bad_shape',
|
||||
http_status: res.status,
|
||||
provider_code: json?.code ?? null,
|
||||
provider_message: json?.message ?? null,
|
||||
has_id: !!json?.data?.id,
|
||||
}, 'Fazpass /request returned status:false or missing id')
|
||||
throw new FazpassError('Fazpass /request returned a non-success body', {
|
||||
httpStatus: res.status,
|
||||
providerCode: json?.code ?? null,
|
||||
providerMessage: json?.message ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
reference: json.data.id,
|
||||
channel_used: json.data.channel ?? null,
|
||||
provider: json.data.provider ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an OTP via Fazpass. Returns { valid, providerCode, providerMessage }.
|
||||
* - 2xx + status:true → { valid: true }
|
||||
* - 2xx + status:false → { valid: false } (normal "wrong code")
|
||||
* - non-2xx or bad shape → throws FazpassError
|
||||
*
|
||||
* The throw policy intentionally separates "wrong code" (a normal UX path,
|
||||
* returns valid:false) from "provider outage / our state is corrupt" (a 502).
|
||||
* If Fazpass starts using 4xx for legitimate wrong-code responses we'll need
|
||||
* to re-classify based on their `code` field — surfaced in the logs.
|
||||
*/
|
||||
export const fazpassVerifyOtp = async ({ reference, code, logger }) => {
|
||||
const cfg = getFazpassConfig()
|
||||
if (!cfg.enabled) {
|
||||
throw new FazpassError('Fazpass is not enabled (FAZPASS_ENABLED=false)')
|
||||
}
|
||||
if (!cfg.merchantKey) {
|
||||
throw new FazpassError('Fazpass credentials missing — set FAZPASS_MERCHANT_KEY')
|
||||
}
|
||||
|
||||
const url = `${cfg.baseUrl}/v1/otp/verify`
|
||||
const res = await postJson({
|
||||
url,
|
||||
headers: buildHeaders(cfg.merchantKey),
|
||||
body: { otp_id: reference, otp: code },
|
||||
timeoutMs: cfg.timeoutMs,
|
||||
logger,
|
||||
})
|
||||
const json = await parseJsonSafe(res)
|
||||
|
||||
if (!res.ok) {
|
||||
logger?.warn({
|
||||
event: 'fazpass.verify.non_2xx',
|
||||
http_status: res.status,
|
||||
provider_code: json?.code ?? null,
|
||||
provider_message: json?.message ?? null,
|
||||
}, 'Fazpass /verify non-2xx')
|
||||
throw new FazpassError(`Fazpass /verify returned HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
providerCode: json?.code ?? null,
|
||||
providerMessage: json?.message ?? null,
|
||||
})
|
||||
}
|
||||
if (json == null || typeof json.status !== 'boolean') {
|
||||
logger?.warn({
|
||||
event: 'fazpass.verify.bad_shape',
|
||||
http_status: res.status,
|
||||
body_keys: json ? Object.keys(json) : null,
|
||||
}, 'Fazpass /verify returned malformed body')
|
||||
throw new FazpassError('Fazpass /verify returned a malformed body', {
|
||||
httpStatus: res.status,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
valid: json.status === true,
|
||||
providerCode: json.code ?? null,
|
||||
providerMessage: json.message ?? null,
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
import crypto from 'node:crypto'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { getDb } from '../db/client.js'
|
||||
import { getOtpRateLimits } from './config.service.js'
|
||||
import { getOtpRateLimits, getTestOtpBypassMatch, getFazpassConfig } from './config.service.js'
|
||||
import { fazpassRequestOtp, fazpassVerifyOtp, FazpassError } from './fazpass.service.js'
|
||||
import { OtpChannel, UserType } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const OTP_TTL_MINUTES = 5
|
||||
|
||||
// bcrypt cost for OTP codes. Lower than password (12) because OTPs live 5 min
|
||||
// and the verify call happens once per attempt — total budget ~80ms per verify
|
||||
// is fine, and the lower cost makes the verify SLA tighter on slow Cloud Run
|
||||
// cold starts.
|
||||
const OTP_BCRYPT_COST = 10
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// ⚠ Fazpass integration — STUB until real API docs are obtained.
|
||||
// Fazpass integration — STUB until real API docs are obtained.
|
||||
//
|
||||
// In production, Fazpass is the source of truth for the OTP code.
|
||||
// We will only ever handle a reference ID (string) returned by Fazpass,
|
||||
// never the raw code. For now, we generate a 6-digit code locally and
|
||||
// store its bcrypt hash in the metadata field of otp_requests via
|
||||
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
|
||||
// round-trip without schema changes.
|
||||
// In production, Fazpass is the source of truth for the OTP code: the backend
|
||||
// never sees the plaintext code. The stub generates a 6-digit code locally,
|
||||
// bcrypt-hashes it into otp_requests.code_hash, and ships the plaintext only
|
||||
// to in-memory (for /peek-otp dev convenience) and to the dev console log.
|
||||
//
|
||||
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
|
||||
// with real HTTP calls and drop the local code generation.
|
||||
// When real docs arrive: replace fazpassSendStub with a real HTTP call, and
|
||||
// stop writing code_hash on the normal path (Fazpass owns verification then).
|
||||
// The bypass path keeps writing code_hash exactly as it does today — that's
|
||||
// the only place backend-owned verification survives post-cutover.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const generate6DigitCode = () => {
|
||||
@@ -47,10 +55,6 @@ const fazpassSendStub = async ({ phone, channel }) => {
|
||||
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
||||
return { reference, channel_used: channel, code } // `code` only present in stub
|
||||
}
|
||||
|
||||
const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
|
||||
return { valid: code === expectedCode }
|
||||
}
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export class OtpError extends Error {
|
||||
@@ -132,7 +136,7 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
||||
* Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
|
||||
* Does NOT return the code to the caller.
|
||||
*/
|
||||
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP }) => {
|
||||
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP, logger }) => {
|
||||
if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
|
||||
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
|
||||
}
|
||||
@@ -143,19 +147,92 @@ export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChan
|
||||
const limits = await getOtpRateLimits()
|
||||
await checkRateLimits({ phone, ipAddress, limits })
|
||||
|
||||
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
|
||||
// Test-user bypass: when this phone is in the CC-managed allowlist,
|
||||
// plant a pre-hashed static OTP and skip Fazpass entirely. Logged loudly so
|
||||
// any successful bypass is visible in audit pipelines.
|
||||
const bypassEntry = await getTestOtpBypassMatch({ phone, userType })
|
||||
if (bypassEntry) {
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (
|
||||
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
|
||||
is_bypass, code_hash
|
||||
)
|
||||
VALUES (
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, NULL, ${channel},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
|
||||
TRUE, ${bypassEntry.otp_hash}
|
||||
)
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
if (logger) {
|
||||
logger.info({
|
||||
event: 'test_otp_bypass.request',
|
||||
otp_request_id: row.id,
|
||||
label: bypassEntry.label,
|
||||
phone_last4: phone.slice(-4),
|
||||
user_type: userType,
|
||||
}, 'test OTP bypass triggered')
|
||||
}
|
||||
return {
|
||||
otp_request_id: row.id,
|
||||
channel_used: channel,
|
||||
expires_at: row.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Store the reference. In stub mode, we also store the expected code appended
|
||||
// after a colon so the verify stub can compare. Real Fazpass flow will NOT store
|
||||
// the code; Fazpass itself holds it. This line is the main place to change
|
||||
// when switching to real Fazpass.
|
||||
const storedReference = code ? `${reference}:${code}` : reference
|
||||
// Live Fazpass path. Provider owns the code AND verification — we only
|
||||
// hold the reference. code_hash MUST stay NULL so verifyOtp's branching
|
||||
// routes this row to Fazpass (the DB CHECK constraint also relies on the
|
||||
// is_bypass=false shape we set here).
|
||||
const fazpass = getFazpassConfig()
|
||||
if (fazpass.enabled) {
|
||||
const { reference, channel_used: providerChannel, provider } = await fazpassRequestOtp({
|
||||
phone, logger,
|
||||
})
|
||||
if (logger) {
|
||||
logger.info({
|
||||
event: 'fazpass.request.ok',
|
||||
phone_last4: phone.slice(-4),
|
||||
provider, provider_channel: providerChannel, requested_channel: channel,
|
||||
}, 'Fazpass OTP request succeeded')
|
||||
}
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (
|
||||
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
|
||||
is_bypass, code_hash
|
||||
)
|
||||
VALUES (
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
|
||||
FALSE, NULL
|
||||
)
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
return {
|
||||
otp_request_id: row.id,
|
||||
// Echo the client-requested channel for backwards compatibility — apps
|
||||
// already render this in user-facing strings. Provider's internal
|
||||
// channel code lives in logs only.
|
||||
channel_used: channel,
|
||||
expires_at: row.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Stub fallback (FAZPASS_ENABLED=false). Generates a local 6-digit code,
|
||||
// stores its bcrypt hash, and lets the in-memory peek endpoint expose the
|
||||
// plaintext for Maestro / dev. Removed once Fazpass is the only path.
|
||||
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
|
||||
const codeHash = await bcrypt.hash(code, OTP_BCRYPT_COST)
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at)
|
||||
INSERT INTO otp_requests (
|
||||
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
|
||||
is_bypass, code_hash
|
||||
)
|
||||
VALUES (
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel_used},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
|
||||
FALSE, ${codeHash}
|
||||
)
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
@@ -171,7 +248,7 @@ export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChan
|
||||
* Verify an OTP code. Returns { phone, user_type } on success.
|
||||
* Throws OtpError on failure.
|
||||
*/
|
||||
export const verifyOtp = async ({ otpRequestId, code }) => {
|
||||
export const verifyOtp = async ({ otpRequestId, code, logger }) => {
|
||||
if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
|
||||
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
|
||||
}
|
||||
@@ -179,7 +256,8 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
|
||||
const limits = await getOtpRateLimits()
|
||||
|
||||
const [row] = await sql`
|
||||
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at
|
||||
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at,
|
||||
is_bypass, code_hash
|
||||
FROM otp_requests
|
||||
WHERE id = ${otpRequestId}
|
||||
`
|
||||
@@ -198,9 +276,86 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
|
||||
|
||||
await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`
|
||||
|
||||
// Stub: fazpass_reference is stored as "<ref>:<code>"
|
||||
const [reference, expectedCode] = (row.fazpass_reference || '').split(':')
|
||||
const { valid } = await fazpassVerifyStub({ reference, code, expectedCode })
|
||||
// Verification routing: the is_bypass flag is sovereign — never use the
|
||||
// mere presence of code_hash to decide which verifier runs, because a
|
||||
// bug or errant migration could leave code_hash populated on a normal row.
|
||||
let valid = false
|
||||
if (row.is_bypass) {
|
||||
if (!row.code_hash) {
|
||||
// DB CHECK constraint should make this impossible, but defend anyway.
|
||||
if (logger) {
|
||||
logger.error({ otp_request_id: row.id }, 'bypass row missing code_hash — refusing')
|
||||
}
|
||||
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
|
||||
}
|
||||
valid = await bcrypt.compare(code, row.code_hash)
|
||||
if (valid && logger) {
|
||||
logger.info({
|
||||
event: 'test_otp_bypass.verify_success',
|
||||
otp_request_id: row.id,
|
||||
phone_last4: row.phone.slice(-4),
|
||||
user_type: row.user_type,
|
||||
}, 'test OTP bypass verified')
|
||||
}
|
||||
} else {
|
||||
// Normal row. Routing depends on which mode wrote it:
|
||||
// - stub-mode row → code_hash is set, bcrypt-compare locally
|
||||
// - Fazpass-live row → code_hash is NULL, defer to provider
|
||||
// Distinguishing by code_hash presence is safe here because the
|
||||
// is_bypass=true case is already handled above; this branch only sees
|
||||
// normal rows where the writer's mode is encoded by which fields they
|
||||
// populated (CHECK constraint ensures bypass rows can't reach here).
|
||||
if (row.code_hash) {
|
||||
valid = await bcrypt.compare(code, row.code_hash)
|
||||
} else {
|
||||
if (!row.fazpass_reference) {
|
||||
// Both code_hash AND fazpass_reference are NULL — row is unverifiable
|
||||
// (a bug, partial write, or someone tampering). Don't fall through to
|
||||
// "valid by default"; reject and alert.
|
||||
if (logger) {
|
||||
logger.error({ otp_request_id: row.id }, 'non-bypass row has no code_hash and no fazpass_reference — unverifiable')
|
||||
}
|
||||
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
|
||||
}
|
||||
try {
|
||||
const result = await fazpassVerifyOtp({
|
||||
reference: row.fazpass_reference,
|
||||
code,
|
||||
logger,
|
||||
})
|
||||
valid = result.valid
|
||||
if (!valid && logger) {
|
||||
logger.info({
|
||||
event: 'fazpass.verify.invalid',
|
||||
otp_request_id: row.id,
|
||||
provider_code: result.providerCode,
|
||||
provider_message: result.providerMessage,
|
||||
}, 'Fazpass reported invalid OTP — surfacing as CODE_MISMATCH')
|
||||
}
|
||||
} catch (err) {
|
||||
// Provider outage / our state corrupt / Fazpass schema drift.
|
||||
// Distinct from "wrong code" — preserve attempt increment but throw
|
||||
// 502 so the client distinguishes "retry the code" from "retry later".
|
||||
if (err instanceof FazpassError) {
|
||||
if (logger) {
|
||||
logger.error({
|
||||
err: {
|
||||
message: err.message,
|
||||
provider_code: err.providerCode,
|
||||
provider_message: err.providerMessage,
|
||||
http_status: err.httpStatus,
|
||||
},
|
||||
otp_request_id: row.id,
|
||||
}, 'Fazpass verify failed (provider-side)')
|
||||
}
|
||||
throw new OtpError('OTP verification temporarily unavailable', 'OTP_PROVIDER_FAILED', 502, {
|
||||
provider_code: err.providerCode,
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)
|
||||
|
||||
@@ -203,12 +203,6 @@ export const getExtensionPriceTiers = async (customerId) => {
|
||||
|
||||
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ----
|
||||
|
||||
/**
|
||||
* @deprecated Use isCustomerEligibleForFirstSessionDiscount.
|
||||
* Kept so route handlers and migrated services still resolve while we cut over.
|
||||
*/
|
||||
export const isCustomerEligibleForFreeTrial = isCustomerEligibleForFirstSessionDiscount
|
||||
|
||||
/**
|
||||
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
|
||||
* Returns chat tiers in the legacy shape (single array, no group wrapper).
|
||||
|
||||
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