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:
@@ -424,7 +424,217 @@
|
|||||||
"Bash(tee /tmp/playwright-debug.log)",
|
"Bash(tee /tmp/playwright-debug.log)",
|
||||||
"Bash(curl -s -i -X OPTIONS http://localhost:3001/internal/config/payment-session-timeout -H 'Origin: http://localhost:5173' -H 'Access-Control-Request-Method: PATCH' -H 'Access-Control-Request-Headers: authorization,content-type')",
|
"Bash(curl -s -i -X OPTIONS http://localhost:3001/internal/config/payment-session-timeout -H 'Origin: http://localhost:5173' -H 'Access-Control-Request-Method: PATCH' -H 'Access-Control-Request-Headers: authorization,content-type')",
|
||||||
"Bash(tee /tmp/playwright-run-6.log)",
|
"Bash(tee /tmp/playwright-run-6.log)",
|
||||||
"Bash(kill 882584)"
|
"Bash(kill 882584)",
|
||||||
|
"Bash(adb version *)",
|
||||||
|
"Bash(adb connect *)",
|
||||||
|
"Bash(sudo apt-get update -qq)",
|
||||||
|
"Bash(sudo apt-get install -y android-tools-adb)",
|
||||||
|
"Read(//home/ramad/flutter/bin/**)",
|
||||||
|
"Read(//home/ramad/development/flutter/bin/**)",
|
||||||
|
"Read(//snap/bin/**)",
|
||||||
|
"Read(//home/ramad/**)",
|
||||||
|
"Read(//mnt/c/src/flutter/bin/**)",
|
||||||
|
"Read(//mnt/c/flutter/bin/**)",
|
||||||
|
"Read(//mnt/c/Users/ramad/flutter/bin/**)",
|
||||||
|
"Read(//mnt/c/Users/ramad/**)",
|
||||||
|
"Read(//mnt/c/dev/flutter/**)",
|
||||||
|
"Bash(grep -E '^\\\\..*env|env$')",
|
||||||
|
"Bash(curl -fsSL https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json)",
|
||||||
|
"Bash(python3 -c \"import sys, json; d=json.load\\(sys.stdin\\); stable=d['current_release']['stable']; rel=next\\(r for r in d['releases'] if r['hash']==stable\\); print\\(rel['version']\\); print\\('https://storage.googleapis.com/' + d['base_url'].split\\('//'\\)[-1].split\\('storage.googleapis.com/'\\)[-1] + '/' + rel['archive']\\)\")",
|
||||||
|
"Bash(curl -fL -o flutter_linux.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.41.9-stable.tar.xz)",
|
||||||
|
"Bash(command -v node)",
|
||||||
|
"Bash(command -v npm)",
|
||||||
|
"Read(//usr/local/bin/**)",
|
||||||
|
"Bash(tar xf *)",
|
||||||
|
"Bash(export PATH=\"$HOME/flutter/bin:$PATH\")",
|
||||||
|
"Bash(~/flutter/bin/flutter --version)",
|
||||||
|
"Bash(~/flutter/bin/flutter doctor *)",
|
||||||
|
"Bash(mkdir -p ~/Android/Sdk/cmdline-tools)",
|
||||||
|
"Bash(curl -fL -o cmdline-tools.zip https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip)",
|
||||||
|
"Bash(unzip -q cmdline-tools.zip -d /tmp/cmdline-extract)",
|
||||||
|
"Bash(mv /tmp/cmdline-extract/cmdline-tools ~/Android/Sdk/cmdline-tools/latest)",
|
||||||
|
"Bash(python3 -m zipfile -e cmdline-tools.zip /tmp/cmdline-extract)",
|
||||||
|
"Bash(command -v java)",
|
||||||
|
"Bash(java -version)",
|
||||||
|
"Bash(chmod +x ~/Android/Sdk/cmdline-tools/latest/bin/*)",
|
||||||
|
"Bash(export ANDROID_HOME=\"$HOME/Android/Sdk\")",
|
||||||
|
"Bash(export PATH=\"$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH\")",
|
||||||
|
"Bash(sdkmanager --version)",
|
||||||
|
"Bash(sdkmanager --licenses)",
|
||||||
|
"Bash(sdkmanager \"platform-tools\" \"platforms;android-36\" \"build-tools;36.0.0\")",
|
||||||
|
"Bash(export PATH=\"$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$HOME/flutter/bin:$PATH\")",
|
||||||
|
"Bash(adb -s 192.168.88.247:5555 reverse tcp:3000 tcp:3000)",
|
||||||
|
"Bash(adb -s 192.168.88.247:5555 reverse --list)",
|
||||||
|
"Bash(adb reverse *)",
|
||||||
|
"Bash(adb kill-server *)",
|
||||||
|
"Bash(adb start-server *)",
|
||||||
|
"Bash(/usr/bin/adb version *)",
|
||||||
|
"Bash(~/Android/Sdk/platform-tools/adb version *)",
|
||||||
|
"Bash(export PATH=\"$HOME/Android/Sdk/platform-tools:$PATH\")",
|
||||||
|
"Bash(echo \"exit=$?\")",
|
||||||
|
"Bash(adb -t 1 reverse tcp:3000 tcp:3000)",
|
||||||
|
"Bash(adb shell *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"Bash(awk '{print $1}')",
|
||||||
|
"Bash(ip route *)",
|
||||||
|
"Bash(awk '/default/ {print $3}')",
|
||||||
|
"Bash(export ADB_SERVER_SOCKET=tcp:172.22.240.1:5037)",
|
||||||
|
"Read(//etc/**)",
|
||||||
|
"Read(//mnt/wsl/**)",
|
||||||
|
"Bash(ip -br addr)",
|
||||||
|
"Bash(awk '{printf \"%s %s %s %s %s %s\\\\n\", $2, $8, $9, $10, $11, $12}')",
|
||||||
|
"Bash(adb forward *)",
|
||||||
|
"Bash(kill 3639 3663)",
|
||||||
|
"Bash(adb -s emulator-5554 forward --remove-all)",
|
||||||
|
"Bash(adb -s emulator-5556 forward --remove-all)",
|
||||||
|
"Bash(timeout 1 bash -c '</dev/tcp/127.0.0.1/7777')",
|
||||||
|
"Bash(echo \"WSL 127.0.0.1:7777 -> $\\(timeout 1 bash -c '</dev/tcp/127.0.0.1/7777' 2>&1 && echo OPEN || echo CLOSED\\)\")",
|
||||||
|
"Bash(timeout 1 bash -c '</dev/tcp/127.0.0.1/8765')",
|
||||||
|
"Bash(echo \"WSL 127.0.0.1:8765 -> $\\(timeout 1 bash -c '</dev/tcp/127.0.0.1/8765' 2>&1 && echo OPEN || echo CLOSED\\)\")",
|
||||||
|
"Bash(timeout 1 bash -c '</dev/tcp/172.22.240.1/7777')",
|
||||||
|
"Bash(awk '{print $4}')",
|
||||||
|
"Bash(awk '/\\(node --watch|flutter_tools\\\\.snapshot run -d|wsl_tcp_relay\\)/ && !/awk/ {printf \"PID %s: %s\\\\n\", $2, substr\\($0, index\\($0, $8\\)\\)}')",
|
||||||
|
"Bash(cat)",
|
||||||
|
"Bash(node /tmp/list-pending.mjs)",
|
||||||
|
"Bash(node _tmp-list-pending.mjs)",
|
||||||
|
"Bash(rm _tmp-list-pending.mjs)",
|
||||||
|
"Bash(node _tmp-check.mjs)",
|
||||||
|
"Bash(node _tmp-mitras.mjs)",
|
||||||
|
"Bash(kill %1)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -t 800 '*:E' flutter:V)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 shell \"ps -A | grep -iE 'halobestie|mitra' \")",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 shell \"pm list packages 2>/dev/null | grep -iE 'halo|mitra'\")",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 shell \"ps -A 2>/dev/null | grep -iE 'halo|mitra'\")",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -b crash -t 200)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -t 3000)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d -t 5000)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -c)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat flutter:V AndroidRuntime:E '*:S')",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb -s emulator-5556 logcat -d)",
|
||||||
|
"Bash(awk '{ts=$0; sub\\(/.*\"time\":/,\"\",ts\\); sub\\(/,.*/,\"\",ts\\); url=$0; sub\\(/.*\"url\":\"/,\"\",url\\); sub\\(/\".*/,\"\",url\\); print ts, url}')",
|
||||||
|
"Bash(awk -F'\"time\":|,\"pid\"|\"url\":\"|\"host\"' '{print $2, $4}')",
|
||||||
|
"Bash(awk -F'\"time\":|\"url\":\"|\"' '{print $2, $4}')",
|
||||||
|
"Bash(node --input-type=module -e ' *)",
|
||||||
|
"Bash(npx vitest *)",
|
||||||
|
"Bash(node --env-file=/home/ramad/workspace/halobestie/halobestie-clone/backend/.env -e ' *)",
|
||||||
|
"Bash(node -e \"require\\('dotenv'\\).config\\(\\); const jwt = require\\('jsonwebtoken'\\); const { randomUUID } = require\\('crypto'\\); console.log\\(jwt.sign\\({ user_type: 'customer', session_id: randomUUID\\(\\) }, process.env.AUTH_JWT_SECRET, { algorithm: 'HS256', expiresIn: 3600, subject: '10ebeb45-7e77-45e7-8177-d5db62539cce' }\\)\\)\")",
|
||||||
|
"Bash(tee /tmp/pricing-before.json)",
|
||||||
|
"Bash(tee /tmp/pricing-after.json)",
|
||||||
|
"Bash(node -e \"require\\('dotenv'\\).config\\(\\); const jwt = require\\('jsonwebtoken'\\); const { randomUUID } = require\\('crypto'\\); console.log\\(jwt.sign\\({ user_type: 'cc_user', session_id: randomUUID\\(\\) }, process.env.AUTH_JWT_SECRET, { algorithm: 'HS256', expiresIn: 3600, subject: '54d90715-d456-4bbe-a31d-a9ae4839b379' }\\)\\)\")",
|
||||||
|
"Bash(python3 -m json.tool)",
|
||||||
|
"Bash(node --check src/pages/settings/SettingsPage.jsx)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone status)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log --oneline -5)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone remote -v)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff --stat backend/package-lock.json control_center/package-lock.json)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff backend/package.json control_center/package.json)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff backend/package-lock.json)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone commit -m ' *)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log -1 --pretty=format:\"%an <%ae>\")",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone config --local --list)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone config --global --list)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone -c 'user.name=ramadhan sjamsani' -c user.email=ramadhan.sjamsani@gmail.com commit -m ' *)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone push origin master)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 npm run dev)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 python3 .dev/wsl_tcp_relay.py --watch-adb 172.22.240.1)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 adb devices)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 15 adb devices)",
|
||||||
|
"Bash(ip addr *)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 flutter run -d emulator-5554 --dart-define=API_BASE_URL=http://192.168.88.247:3000)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 flutter run -d emulator-5556 --dart-define=API_BASE_URL=http://192.168.88.247:3000)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 10 adb -s emulator-5556 shell getprop sys.boot_completed)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 10 adb -s emulator-5556 shell pm list packages com.halobestie)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 5 adb -s emulator-5556 shell \"ps -A | grep -E 'install|pm '\")",
|
||||||
|
"Bash(kill 23571)",
|
||||||
|
"Bash(ls ~/.maestro/bin/ 2>/dev/null; ls /opt/maestro/bin 2>/dev/null; find ~ -maxdepth 5 -name \"maestro\" -type f -executable 2>/dev/null | head -5; find /usr -maxdepth 5 -name \"maestro\" -type f -executable 2>/dev/null | head -5)",
|
||||||
|
"Read(//usr/**)",
|
||||||
|
"Bash(bash)",
|
||||||
|
"Bash(wget --no-verbose -O /tmp/maestro.zip \"https://github.com/mobile-dev-inc/maestro/releases/latest/download/maestro.zip\")",
|
||||||
|
"Bash(openssl version *)",
|
||||||
|
"Bash(mkdir -p ~/.maestro)",
|
||||||
|
"Bash(unzip -qo /tmp/maestro.zip -d ~/.maestro/tmp)",
|
||||||
|
"Bash(mv ~/.maestro/tmp/maestro/* ~/.maestro/)",
|
||||||
|
"Bash(~/.maestro/bin/maestro --version)",
|
||||||
|
"Bash(export PATH=\"$HOME/.maestro/bin:$PATH\")",
|
||||||
|
"Bash(export MAESTRO_CLI_NO_ANALYTICS=1)",
|
||||||
|
"Bash(maestro --device emulator-5554 test client_app/.maestro/flows/10_returning_repays.yaml)",
|
||||||
|
"Bash(~/.maestro/bin/maestro test *)",
|
||||||
|
"Bash(~/.maestro/bin/maestro --help)",
|
||||||
|
"Bash(~/.maestro/bin/maestro list-devices *)",
|
||||||
|
"Bash(python3 .dev/wsl_tcp_relay.py 5555 172.22.240.1 5555)",
|
||||||
|
"Bash(python3 .dev/wsl_tcp_relay.py 5557 172.22.240.1 5557)",
|
||||||
|
"Bash(timeout 5 bash -c 'echo \"test\" | nc -v 127.0.0.1 5555')",
|
||||||
|
"Bash(timeout 5 bash -c 'echo \"test\" | nc -v 172.22.240.1 5555')",
|
||||||
|
"Bash(timeout 30 ~/.maestro/bin/maestro --udid emulator-5554 list-devices)",
|
||||||
|
"Bash(timeout 5 bash -c 'exec 3<>/dev/tcp/172.22.240.1/5555 && echo OK_5555')",
|
||||||
|
"Bash(timeout 5 bash -c 'exec 3<>/dev/tcp/172.22.240.1/5557 && echo OK_5557')",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 ~/.maestro/bin/maestro test client_app/.maestro/flows/10_returning_repays.yaml)",
|
||||||
|
"Bash(python3 -c \"import json,sys; d=json.load\\(open\\('/home/ramad/.maestro/tests/2026-05-17_000444/commands-\\(10_returning_repays.yaml\\).json'\\)\\); print\\(json.dumps\\(d.get\\('commands',[d]\\)[-1] if isinstance\\(d, dict\\) else d[-1], indent=2\\)[:3000]\\)\")",
|
||||||
|
"Bash(timeout 30 ~/.maestro/bin/maestro hierarchy)",
|
||||||
|
"Bash(python3 -c ' *)",
|
||||||
|
"Bash(ADB_SERVER_SOCKET=tcp:172.22.240.1:5037 timeout 5 adb devices)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff --stat requirement/phase4-customer-flow.md)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff requirement/phase4-customer-flow.md)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff backend/src/routes/internal/_test.routes.js)",
|
||||||
|
"Bash(awk '{print $3}')",
|
||||||
|
"Bash(kill 89692)",
|
||||||
|
"Bash(echo \"started pid $!\")",
|
||||||
|
"Bash(PGPASSWORD=postgres psql -h localhost -U postgres -d halobestie -c \"SELECT mitra_id, is_online, last_heartbeat_at FROM mitra_online_status WHERE is_online = true ORDER BY last_heartbeat_at DESC;\")",
|
||||||
|
"Bash(~/.maestro/bin/maestro --device emulator-5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)",
|
||||||
|
"Bash(~/.maestro/bin/maestro --udid 127.0.0.1:5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)",
|
||||||
|
"Bash(~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)",
|
||||||
|
"Bash(nohup python3 .dev/wsl_tcp_relay.py 5037 172.22.240.1 5037)",
|
||||||
|
"Bash(echo \"relay started pid $!\")",
|
||||||
|
"Bash(unset ADB_SERVER_SOCKET)",
|
||||||
|
"Bash(pkill -f \"maestro\")",
|
||||||
|
"Bash(kill 96710)",
|
||||||
|
"Bash(timeout 3 bash -c '</dev/tcp/172.22.240.1/5037')",
|
||||||
|
"Bash(ip -4 addr show eth0)",
|
||||||
|
"Bash(timeout 3 bash -c '</dev/tcp/127.0.0.1/5037')",
|
||||||
|
"Bash(powershell.exe -NoProfile -Command \"adb.exe start-server\")",
|
||||||
|
"Bash(timeout 2 bash -c '</dev/tcp/172.22.240.1/__TRACKED_VAR__')",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-01_returning_lama_online.yaml)",
|
||||||
|
"Bash(xargs -I {} sh -c 'echo \"=== {} ===\" && grep -o \"\\\\\"text\\\\\":\\\\\"[^\\\\\"]*\\\\\"\" {} | sort -u | head -30')",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET adb -s emulator-5554 install -r build/app/outputs/flutter-apk/app-debug.apk)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-04_returning_baru_blast.yaml)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-03_returning_lama_offline_tanya_admin.yaml)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-05_payment_expired_retry_preserves_targeting.yaml)",
|
||||||
|
"Bash(sed -i 's|\\\\${output.MITRA_NAME}|${output.MITRA_NAME_RE}|g' client_app/.maestro/flows/ts-0*.yaml *)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-02_returning_lama_offline_blast.yaml)",
|
||||||
|
"Bash(grep -v \"^--$\\\\|kotlin\\\\|graalvm\\\\|jvm\")",
|
||||||
|
"Bash(kill 130976)",
|
||||||
|
"Bash(echo \"restarted pid $!\")",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-06_targeted_reject_fallback_to_blast.yaml)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log --oneline -10)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone diff --stat)",
|
||||||
|
"Bash(git -c user.email=ramadhan.sjamsani@gmail.com -c 'user.name=Ramadhan Sjamsani' commit -m ' *)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone status --short)",
|
||||||
|
"Bash(git -C /home/ramad/workspace/halobestie/halobestie-clone log --oneline -3)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/ts-07_returning_existing_name_skips_setname.yaml)",
|
||||||
|
"Bash(kill 133811)",
|
||||||
|
"Bash(python3 .dev/wsl_tcp_relay.py --watch-adb 172.22.240.1)",
|
||||||
|
"Bash(grep -rEn \"INSERT INTO mitras|phone_number.*[0-9]{8,}\" src/db/migrations/)",
|
||||||
|
"Bash(ps -eo pid,cmd)",
|
||||||
|
"Bash(awk '/inet / {print $2}')",
|
||||||
|
"Bash(powershell.exe -NoProfile -Command \"netsh interface portproxy show v4tov4\")",
|
||||||
|
"Bash(tee /tmp/confirm.json)",
|
||||||
|
"Bash(adb -s emulator-5554 shell pm path com.halobestie.client.client_app)",
|
||||||
|
"Bash(python3 .dev/wsl_tcp_relay.py 5037 172.22.240.1 5037)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET adb devices)",
|
||||||
|
"Bash(adb -s emulator-5554 exec-out screencap -p)",
|
||||||
|
"Bash(adb -s emulator-5554 shell dumpsys activity activities)",
|
||||||
|
"Bash(adb -s emulator-5554 logcat -d -t '60.0')",
|
||||||
|
"Bash(dart format *)",
|
||||||
|
"Bash(adb -s emulator-5554 install -r build/app/outputs/flutter-apk/app-debug.apk)",
|
||||||
|
"Bash(adb -s emulator-5554 shell am force-stop com.halobestie.client.client_app)",
|
||||||
|
"Bash(adb -s emulator-5554 shell monkey -p com.halobestie.client.client_app -c android.intent.category.LAUNCHER 1)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test /tmp/verify_register_overflow.yaml)",
|
||||||
|
"Bash(adb -s emulator-5554 shell input tap 800 1530)",
|
||||||
|
"Bash(adb -s emulator-5554 shell wm size)",
|
||||||
|
"Bash(adb -s emulator-5554 logcat -d -t '30.0')",
|
||||||
|
"Bash(adb -s emulator-5554 shell input tap 720 1450)",
|
||||||
|
"Bash(env -u ADB_SERVER_SOCKET ~/.maestro/bin/maestro --udid emulator-5554 test client_app/.maestro/flows/verify_register_overflow.yaml)",
|
||||||
|
"Bash(adb -s emulator-5554 shell \"run-as com.halobestie.client.client_app cat /data/data/com.halobestie.client.client_app/shared_prefs/FlutterSharedPreferences.xml\")"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src",
|
"/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src",
|
||||||
@@ -434,7 +644,8 @@
|
|||||||
"/proc/5649/fd",
|
"/proc/5649/fd",
|
||||||
"/home/rama/.android/avd/Medium_Phone.avd",
|
"/home/rama/.android/avd/Medium_Phone.avd",
|
||||||
"/tmp",
|
"/tmp",
|
||||||
"/home/rama/.android/avd"
|
"/home/rama/.android/avd",
|
||||||
|
"/home/ramad/workspace/halobestie/halobestie-clone/client_app/.maestro/flows"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Fazpass API Docpdf.pdf
Normal file
BIN
Fazpass API Docpdf.pdf
Normal file
Binary file not shown.
BIN
Fazpass Dashboard.pdf
Normal file
BIN
Fazpass Dashboard.pdf
Normal file
Binary file not shown.
@@ -19,10 +19,21 @@ AUTH_JWT_SECRET=replace-with-strong-random-32+char-secret
|
|||||||
ACCESS_TOKEN_TTL_SECONDS=3600
|
ACCESS_TOKEN_TTL_SECONDS=3600
|
||||||
REFRESH_TOKEN_TTL_DAYS=30
|
REFRESH_TOKEN_TTL_DAYS=30
|
||||||
|
|
||||||
# Fazpass (OTP provider — TBD real values once docs are available)
|
# --- Fazpass (OTP provider) ---
|
||||||
FAZPASS_API_KEY=
|
#
|
||||||
FAZPASS_BASE_URL=
|
# When FAZPASS_ENABLED=true, requestOtp() calls Fazpass /v1/otp/request and
|
||||||
FAZPASS_WEBHOOK_SECRET=
|
# 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 — comma-separated list of valid audience client IDs (Android, iOS).
|
||||||
GOOGLE_OAUTH_CLIENT_IDS=
|
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`
|
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)
|
// 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`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS otp_requests (
|
CREATE TABLE IF NOT EXISTS otp_requests (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -414,12 +426,36 @@ const migrate = async () => {
|
|||||||
attempts INT NOT NULL DEFAULT 0,
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
used_at TIMESTAMPTZ,
|
used_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
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_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)`
|
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
|
// Auth-related app_config defaults
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO app_config (key, value) VALUES
|
INSERT INTO app_config (key, value) VALUES
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { getDb } from '../../db/client.js'
|
|||||||
import {
|
import {
|
||||||
getAnonymityConfig, setAnonymityConfig,
|
getAnonymityConfig, setAnonymityConfig,
|
||||||
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
|
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
|
||||||
getFreeTrialConfig, setFreeTrialConfig,
|
|
||||||
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
|
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
|
||||||
getEarlyEndConfig, setEarlyEndConfig,
|
getEarlyEndConfig, setEarlyEndConfig,
|
||||||
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
|
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
|
||||||
@@ -16,6 +15,8 @@ import {
|
|||||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||||
getSupportHandles, setSupportHandles,
|
getSupportHandles, setSupportHandles,
|
||||||
|
getTestOtpBypass, setTestOtpBypassEnabled, addTestOtpBypassEntry,
|
||||||
|
updateTestOtpBypassEntry, deleteTestOtpBypassEntry,
|
||||||
} from '../../services/config.service.js'
|
} from '../../services/config.service.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
@@ -111,22 +112,6 @@ export const internalConfigRoutes = async (app) => {
|
|||||||
return reply.send({ success: true, data: config })
|
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 ---
|
// --- Phase 3: Extension Timeout ---
|
||||||
app.get('/extension-timeout', {
|
app.get('/extension-timeout', {
|
||||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||||
@@ -735,6 +720,102 @@ export const internalConfigRoutes = async (app) => {
|
|||||||
return reply.send({ success: true, data: updated })
|
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 ---
|
// --- Phase 4: Support handles ---
|
||||||
app.get('/support-handles', {
|
app.get('/support-handles', {
|
||||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const clientAuthRoutes = async (app) => {
|
|||||||
userType: UserType.CUSTOMER,
|
userType: UserType.CUSTOMER,
|
||||||
ipAddress: request.ip,
|
ipAddress: request.ip,
|
||||||
channel,
|
channel,
|
||||||
|
logger: request.log,
|
||||||
})
|
})
|
||||||
return reply.send({ success: true, data: result })
|
return reply.send({ success: true, data: result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -74,7 +75,7 @@ export const clientAuthRoutes = async (app) => {
|
|||||||
app.post('/otp/verify', async (request, reply) => {
|
app.post('/otp/verify', async (request, reply) => {
|
||||||
const { otp_request_id, code } = request.body || {}
|
const { otp_request_id, code } = request.body || {}
|
||||||
try {
|
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) {
|
if (user_type !== UserType.CUSTOMER) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const mitraAuthRoutes = async (app) => {
|
|||||||
userType: UserType.MITRA,
|
userType: UserType.MITRA,
|
||||||
ipAddress: request.ip,
|
ipAddress: request.ip,
|
||||||
channel,
|
channel,
|
||||||
|
logger: request.log,
|
||||||
})
|
})
|
||||||
return reply.send({ success: true, data: result })
|
return reply.send({ success: true, data: result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -40,7 +41,7 @@ export const mitraAuthRoutes = async (app) => {
|
|||||||
app.post('/otp/verify', async (request, reply) => {
|
app.post('/otp/verify', async (request, reply) => {
|
||||||
const { otp_request_id, code } = request.body || {}
|
const { otp_request_id, code } = request.body || {}
|
||||||
try {
|
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) {
|
if (user_type !== UserType.MITRA) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import crypto from 'node:crypto'
|
||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { ExtensionTimeoutAction } from '../constants.js'
|
import { ExtensionTimeoutAction, UserType } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
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 () => {
|
export const getAnonymityConfig = async () => {
|
||||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
|
||||||
return { anonymity_enabled: row?.value?.enabled ?? false }
|
return { anonymity_enabled: row?.value?.enabled ?? false }
|
||||||
@@ -35,49 +43,6 @@ export const setMaxCustomersPerMitra = async (value) => {
|
|||||||
return { max_customers_per_mitra: 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 ---
|
// --- Phase 4: Support handles ---
|
||||||
|
|
||||||
export const getSupportHandles = async () => {
|
export const getSupportHandles = async () => {
|
||||||
@@ -177,6 +142,25 @@ export const getValkeyOnlineMirrorSweepSeconds = () => {
|
|||||||
return Number.isFinite(parsed) && parsed >= 30 ? parsed : 300
|
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 ---
|
// --- Phase 5: Xendit integration ---
|
||||||
//
|
//
|
||||||
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values
|
// 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 }
|
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 crypto from 'node:crypto'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
import { getDb } from '../db/client.js'
|
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'
|
import { OtpChannel, UserType } from '../constants.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
const OTP_TTL_MINUTES = 5
|
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.
|
// In production, Fazpass is the source of truth for the OTP code: the backend
|
||||||
// We will only ever handle a reference ID (string) returned by Fazpass,
|
// never sees the plaintext code. The stub generates a 6-digit code locally,
|
||||||
// never the raw code. For now, we generate a 6-digit code locally and
|
// bcrypt-hashes it into otp_requests.code_hash, and ships the plaintext only
|
||||||
// store its bcrypt hash in the metadata field of otp_requests via
|
// to in-memory (for /peek-otp dev convenience) and to the dev console log.
|
||||||
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
|
|
||||||
// round-trip without schema changes.
|
|
||||||
//
|
//
|
||||||
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
|
// When real docs arrive: replace fazpassSendStub with a real HTTP call, and
|
||||||
// with real HTTP calls and drop the local code generation.
|
// 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 = () => {
|
const generate6DigitCode = () => {
|
||||||
@@ -47,10 +55,6 @@ const fazpassSendStub = async ({ phone, channel }) => {
|
|||||||
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
||||||
return { reference, channel_used: channel, code } // `code` only present in stub
|
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 {
|
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 }.
|
* Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
|
||||||
* Does NOT return the code to the caller.
|
* 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)) {
|
if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
|
||||||
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
|
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()
|
const limits = await getOtpRateLimits()
|
||||||
await checkRateLimits({ phone, ipAddress, limits })
|
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
|
// Live Fazpass path. Provider owns the code AND verification — we only
|
||||||
// after a colon so the verify stub can compare. Real Fazpass flow will NOT store
|
// hold the reference. code_hash MUST stay NULL so verifyOtp's branching
|
||||||
// the code; Fazpass itself holds it. This line is the main place to change
|
// routes this row to Fazpass (the DB CHECK constraint also relies on the
|
||||||
// when switching to real Fazpass.
|
// is_bypass=false shape we set here).
|
||||||
const storedReference = code ? `${reference}:${code}` : reference
|
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`
|
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 (
|
VALUES (
|
||||||
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
|
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel_used},
|
||||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
|
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
|
||||||
|
FALSE, ${codeHash}
|
||||||
)
|
)
|
||||||
RETURNING id, expires_at
|
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.
|
* Verify an OTP code. Returns { phone, user_type } on success.
|
||||||
* Throws OtpError on failure.
|
* 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)) {
|
if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
|
||||||
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
|
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
|
||||||
}
|
}
|
||||||
@@ -179,7 +256,8 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
|
|||||||
const limits = await getOtpRateLimits()
|
const limits = await getOtpRateLimits()
|
||||||
|
|
||||||
const [row] = await sql`
|
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
|
FROM otp_requests
|
||||||
WHERE id = ${otpRequestId}
|
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}`
|
await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`
|
||||||
|
|
||||||
// Stub: fazpass_reference is stored as "<ref>:<code>"
|
// Verification routing: the is_bypass flag is sovereign — never use the
|
||||||
const [reference, expectedCode] = (row.fazpass_reference || '').split(':')
|
// mere presence of code_hash to decide which verifier runs, because a
|
||||||
const { valid } = await fazpassVerifyStub({ reference, code, expectedCode })
|
// 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) {
|
if (!valid) {
|
||||||
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)
|
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) ----
|
// ---- 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.
|
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
|
||||||
* Returns chat tiers in the legacy shape (single array, no group wrapper).
|
* 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,17 +23,6 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
|
|||||||
return res.data.data
|
return res.data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3 config fetchers
|
|
||||||
const fetchFreeTrialConfig = async () => {
|
|
||||||
const res = await apiClient.get('/internal/config/free-trial')
|
|
||||||
return res.data.data
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateFreeTrialConfig = async (data) => {
|
|
||||||
const res = await apiClient.patch('/internal/config/free-trial', data)
|
|
||||||
return res.data.data
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchExtensionTimeoutConfig = async () => {
|
const fetchExtensionTimeoutConfig = async () => {
|
||||||
const res = await apiClient.get('/internal/config/extension-timeout')
|
const res = await apiClient.get('/internal/config/extension-timeout')
|
||||||
return res.data.data
|
return res.data.data
|
||||||
@@ -160,6 +149,30 @@ const updateSupportHandles = async (patch) => {
|
|||||||
return res.data.data
|
return res.data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test OTP bypass allowlist — phone-scoped static OTPs for Apple reviewers / QA.
|
||||||
|
// Backend rejects requestOtp() to Fazpass for these phones; plaintext OTP is
|
||||||
|
// bcrypt-hashed on save and never readable after.
|
||||||
|
const fetchTestOtpBypass = async () => {
|
||||||
|
const res = await apiClient.get('/internal/config/test-otp-bypass')
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
const setTestOtpBypassEnabled = async (enabled) => {
|
||||||
|
const res = await apiClient.patch('/internal/config/test-otp-bypass/enabled', { enabled })
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
const addTestOtpBypassEntry = async (body) => {
|
||||||
|
const res = await apiClient.post('/internal/config/test-otp-bypass/entries', body)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
const updateTestOtpBypassEntry = async ({ id, ...patch }) => {
|
||||||
|
const res = await apiClient.patch(`/internal/config/test-otp-bypass/entries/${id}`, patch)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
const deleteTestOtpBypassEntry = async (id) => {
|
||||||
|
const res = await apiClient.delete(`/internal/config/test-otp-bypass/entries/${id}`)
|
||||||
|
return res.data.data
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
|
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
|
||||||
@@ -179,16 +192,6 @@ export default function SettingsPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-max-customers'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Phase 3: Free Trial
|
|
||||||
const { data: ftData, isLoading: ftLoading } = useQuery({
|
|
||||||
queryKey: ['config-free-trial'],
|
|
||||||
queryFn: fetchFreeTrialConfig,
|
|
||||||
})
|
|
||||||
const ftMutation = useMutation({
|
|
||||||
mutationFn: updateFreeTrialConfig,
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-free-trial'] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Phase 3: Extension Timeout
|
// Phase 3: Extension Timeout
|
||||||
const { data: etData, isLoading: etLoading } = useQuery({
|
const { data: etData, isLoading: etLoading } = useQuery({
|
||||||
queryKey: ['config-extension-timeout'],
|
queryKey: ['config-extension-timeout'],
|
||||||
@@ -331,10 +334,21 @@ export default function SettingsPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-support-handles'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Test OTP bypass allowlist. Single query, four mutations (enable + CRUD).
|
||||||
|
const { data: tobData, isLoading: tobLoading } = useQuery({
|
||||||
|
queryKey: ['config-test-otp-bypass'],
|
||||||
|
queryFn: fetchTestOtpBypass,
|
||||||
|
})
|
||||||
|
const invalidateTob = () => queryClient.invalidateQueries({ queryKey: ['config-test-otp-bypass'] })
|
||||||
|
const tobEnabledMutation = useMutation({ mutationFn: setTestOtpBypassEnabled, onSuccess: invalidateTob })
|
||||||
|
const tobAddMutation = useMutation({ mutationFn: addTestOtpBypassEntry, onSuccess: invalidateTob })
|
||||||
|
const tobUpdateMutation = useMutation({ mutationFn: updateTestOtpBypassEntry, onSuccess: invalidateTob })
|
||||||
|
const tobDeleteMutation = useMutation({ mutationFn: deleteTestOtpBypassEntry, onSuccess: invalidateTob })
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
|
isLoading || maxLoading || etLoading || eeLoading || mpLoading || senLoading ||
|
||||||
pbtLoading || pstLoading || rctLoading || edaLoading ||
|
pbtLoading || pstLoading || rctLoading || edaLoading ||
|
||||||
fsdLoading || ptLoading || shLoading
|
fsdLoading || ptLoading || shLoading || tobLoading
|
||||||
) return <div>Loading...</div>
|
) return <div>Loading...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -376,36 +390,6 @@ export default function SettingsPage() {
|
|||||||
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginBottom: 24 }}>
|
|
||||||
<h2>Free Trial</h2>
|
|
||||||
<p>Aktifkan free trial untuk customer baru yang belum pernah bertransaksi.</p>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={ftData?.enabled ?? false}
|
|
||||||
onChange={e => ftMutation.mutate({ enabled: e.target.checked })}
|
|
||||||
disabled={ftMutation.isPending}
|
|
||||||
/>
|
|
||||||
Aktifkan Free Trial
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<label>Durasi:</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={ftData?.duration_minutes ?? 5}
|
|
||||||
onChange={e => {
|
|
||||||
const val = parseInt(e.target.value, 10)
|
|
||||||
if (val >= 1) ftMutation.mutate({ duration_minutes: val })
|
|
||||||
}}
|
|
||||||
disabled={ftMutation.isPending}
|
|
||||||
style={{ width: 80 }}
|
|
||||||
/>
|
|
||||||
<span>menit</span>
|
|
||||||
</div>
|
|
||||||
{ftMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={{ marginBottom: 24 }}>
|
<section style={{ marginBottom: 24 }}>
|
||||||
<h2>Extension Timeout</h2>
|
<h2>Extension Timeout</h2>
|
||||||
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
|
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
|
||||||
@@ -645,6 +629,15 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Test OTP bypass — Apple reviewer / QA static OTP allowlist */}
|
||||||
|
<TestOtpBypassSection
|
||||||
|
data={tobData}
|
||||||
|
enabledMutation={tobEnabledMutation}
|
||||||
|
addMutation={tobAddMutation}
|
||||||
|
updateMutation={tobUpdateMutation}
|
||||||
|
deleteMutation={tobDeleteMutation}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Phase 4: Support handles */}
|
{/* Phase 4: Support handles */}
|
||||||
<section style={{ marginBottom: 24 }}>
|
<section style={{ marginBottom: 24 }}>
|
||||||
<h2>Support Handles (Tanya Admin)</h2>
|
<h2>Support Handles (Tanya Admin)</h2>
|
||||||
@@ -1107,3 +1100,293 @@ function FirstSessionDiscountSection({ data, mutation, toast, onDismissToast })
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Test OTP Bypass — Apple-reviewer / QA static OTP allowlist
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// SECURITY-SENSITIVE: any phone in this list authenticates with a static OTP
|
||||||
|
// and never receives an SMS. Backend bcrypt-hashes the plaintext on save and
|
||||||
|
// never returns it again — to rotate an OTP, edit the entry and set a new one.
|
||||||
|
//
|
||||||
|
// The kill-switch toggle disables ALL entries instantly without touching the
|
||||||
|
// list, useful for incidents. Per-entry expires_at provides automatic disable.
|
||||||
|
|
||||||
|
const formatExpiresAt = (iso) => {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
return d.toLocaleString('id-ID', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDatetimeLocal = (iso) => {
|
||||||
|
// <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in local time.
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDatetimeLocal = (s) => {
|
||||||
|
// Convert local-tz "YYYY-MM-DDTHH:mm" back to ISO. new Date() handles it.
|
||||||
|
if (!s) return null
|
||||||
|
const d = new Date(s)
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionErrorText = (err) =>
|
||||||
|
err?.response?.data?.error?.message || err?.message || 'Gagal menyimpan.'
|
||||||
|
|
||||||
|
function TestOtpBypassSection({ data, enabledMutation, addMutation, updateMutation, deleteMutation }) {
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
const entries = data?.entries ?? []
|
||||||
|
const isExpired = (iso) => {
|
||||||
|
const t = new Date(iso).getTime()
|
||||||
|
return Number.isFinite(t) && t <= Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ marginBottom: 24, border: '1px solid #f0c36d', padding: 12, background: '#fff8e8' }}>
|
||||||
|
<h2 style={{ marginTop: 0 }}>Test OTP Bypass (Apple Reviewer / QA)</h2>
|
||||||
|
<p style={{ fontSize: 13, color: '#664' }}>
|
||||||
|
Daftar nomor HP yang melewati Fazpass dan login dengan OTP statis.
|
||||||
|
Untuk reviewer App Store dan tester internal saja —{' '}
|
||||||
|
<strong>siapa pun yang tahu pasangan nomor + OTP bisa login sebagai user ini.</strong>{' '}
|
||||||
|
Jaga daftarnya kecil, tetapkan <em>expires_at</em>, dan hapus segera setelah pemakaian.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={data?.enabled ?? false}
|
||||||
|
onChange={e => enabledMutation.mutate(e.target.checked)}
|
||||||
|
disabled={enabledMutation.isPending}
|
||||||
|
/>
|
||||||
|
<strong>Aktifkan bypass</strong> (kill switch global — matikan untuk menonaktifkan semua entri sekaligus)
|
||||||
|
</label>
|
||||||
|
{enabledMutation.isError && (
|
||||||
|
<p style={{ color: 'red', fontSize: 12 }}>{sectionErrorText(enabledMutation.error)}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15 }}>Entri ({entries.length})</h3>
|
||||||
|
<button type="button" onClick={() => setShowAdd(s => !s)} disabled={addMutation.isPending}>
|
||||||
|
{showAdd ? 'Batal tambah' : '+ Tambah entri'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdd && (
|
||||||
|
<AddTestOtpBypassForm
|
||||||
|
onSubmit={(body) => addMutation.mutate(body, { onSuccess: () => setShowAdd(false) })}
|
||||||
|
onCancel={() => setShowAdd(false)}
|
||||||
|
isPending={addMutation.isPending}
|
||||||
|
serverError={addMutation.isError ? sectionErrorText(addMutation.error) : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, marginTop: 8 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#f7f7f7', textAlign: 'left' }}>
|
||||||
|
<th style={th}>Phone</th>
|
||||||
|
<th style={th}>User type</th>
|
||||||
|
<th style={th}>Label</th>
|
||||||
|
<th style={th}>Expires</th>
|
||||||
|
<th style={th}>OTP hash</th>
|
||||||
|
<th style={th}>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.length === 0 && (
|
||||||
|
<tr><td colSpan={6} style={{ ...td, color: '#999', fontStyle: 'italic' }}>Belum ada entri.</td></tr>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => (
|
||||||
|
editingId === entry.id ? (
|
||||||
|
<EditTestOtpBypassRow
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
onSave={(patch) => updateMutation.mutate(
|
||||||
|
{ id: entry.id, ...patch },
|
||||||
|
{ onSuccess: () => setEditingId(null) },
|
||||||
|
)}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
isPending={updateMutation.isPending}
|
||||||
|
serverError={updateMutation.isError ? sectionErrorText(updateMutation.error) : null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<tr key={entry.id} style={{ opacity: isExpired(entry.expires_at) ? 0.5 : 1 }}>
|
||||||
|
<td style={td}>{entry.phone}</td>
|
||||||
|
<td style={td}>{entry.user_type}</td>
|
||||||
|
<td style={td}>{entry.label}</td>
|
||||||
|
<td style={td}>
|
||||||
|
{formatExpiresAt(entry.expires_at)}
|
||||||
|
{isExpired(entry.expires_at) && (
|
||||||
|
<span style={{ marginLeft: 6, color: '#a00', fontWeight: 'bold' }}>EXPIRED</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...td, fontFamily: 'monospace', fontSize: 11, color: '#888' }}>
|
||||||
|
{entry.otp_hash ? `${entry.otp_hash.slice(0, 12)}…` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<button type="button" onClick={() => setEditingId(entry.id)} style={{ marginRight: 4 }}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm(`Hapus entri "${entry.label}" (${entry.phone})?`)) return
|
||||||
|
deleteMutation.mutate(entry.id)
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
style={{ color: '#a00' }}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{deleteMutation.isError && (
|
||||||
|
<p style={{ color: 'red', fontSize: 12 }}>{sectionErrorText(deleteMutation.error)}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddTestOtpBypassForm({ onSubmit, onCancel, isPending, serverError }) {
|
||||||
|
const [phone, setPhone] = useState('+62')
|
||||||
|
const [userType, setUserType] = useState('customer')
|
||||||
|
const [otp, setOtp] = useState('')
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
// Default expiry: 30 days from now.
|
||||||
|
const defaultExpiry = (() => {
|
||||||
|
const d = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
||||||
|
return toDatetimeLocal(d.toISOString())
|
||||||
|
})()
|
||||||
|
const [expiresAt, setExpiresAt] = useState(defaultExpiry)
|
||||||
|
const [localError, setLocalError] = useState(null)
|
||||||
|
|
||||||
|
const submit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!/^\+[1-9]\d{6,14}$/.test(phone)) return setLocalError('Phone harus format E.164 (mis. +628...).')
|
||||||
|
if (!/^\d{4,8}$/.test(otp)) return setLocalError('OTP harus 4-8 digit angka.')
|
||||||
|
if (label.trim().length === 0) return setLocalError('Label wajib diisi.')
|
||||||
|
const iso = fromDatetimeLocal(expiresAt)
|
||||||
|
if (!iso) return setLocalError('Tanggal expires_at tidak valid.')
|
||||||
|
if (new Date(iso).getTime() <= Date.now()) return setLocalError('expires_at harus di masa depan.')
|
||||||
|
|
||||||
|
setLocalError(null)
|
||||||
|
onSubmit({ phone, user_type: userType, otp, label: label.trim(), expires_at: iso })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{
|
||||||
|
padding: 10, marginBottom: 8, background: '#f0f7ff', border: '1px solid #cde',
|
||||||
|
display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end',
|
||||||
|
}}>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||||
|
Phone (E.164)
|
||||||
|
<input type="text" value={phone} onChange={e => setPhone(e.target.value.trim())}
|
||||||
|
style={{ width: 180 }} disabled={isPending} placeholder="+628111111111" required />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||||
|
User type
|
||||||
|
<select value={userType} onChange={e => setUserType(e.target.value)}
|
||||||
|
disabled={isPending} style={{ width: 110 }}>
|
||||||
|
<option value="customer">customer</option>
|
||||||
|
<option value="mitra">mitra</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||||
|
OTP (4-8 digit)
|
||||||
|
<input type="text" inputMode="numeric" value={otp}
|
||||||
|
onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
|
style={{ width: 110, fontFamily: 'monospace' }} disabled={isPending} required />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||||
|
Label
|
||||||
|
<input type="text" value={label} onChange={e => setLabel(e.target.value)}
|
||||||
|
style={{ width: 200 }} disabled={isPending} placeholder="Apple Reviewer #1" required />
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||||
|
Expires at
|
||||||
|
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
|
||||||
|
disabled={isPending} required />
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={isPending}>{isPending ? '...' : 'Tambah'}</button>
|
||||||
|
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
|
||||||
|
{(localError || serverError) && (
|
||||||
|
<div style={{ color: 'red', fontSize: 12, width: '100%' }}>
|
||||||
|
{localError || serverError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditTestOtpBypassRow({ entry, onSave, onCancel, isPending, serverError }) {
|
||||||
|
const [label, setLabel] = useState(entry.label)
|
||||||
|
const [expiresAt, setExpiresAt] = useState(toDatetimeLocal(entry.expires_at))
|
||||||
|
const [otp, setOtp] = useState('') // blank = don't rotate
|
||||||
|
const [localError, setLocalError] = useState(null)
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const patch = {}
|
||||||
|
if (label.trim() !== entry.label) {
|
||||||
|
if (label.trim().length === 0) return setLocalError('Label tidak boleh kosong.')
|
||||||
|
patch.label = label.trim()
|
||||||
|
}
|
||||||
|
const iso = fromDatetimeLocal(expiresAt)
|
||||||
|
if (!iso) return setLocalError('Tanggal expires_at tidak valid.')
|
||||||
|
if (iso !== entry.expires_at) {
|
||||||
|
if (new Date(iso).getTime() <= Date.now()) return setLocalError('expires_at harus di masa depan.')
|
||||||
|
patch.expires_at = iso
|
||||||
|
}
|
||||||
|
if (otp.length > 0) {
|
||||||
|
if (!/^\d{4,8}$/.test(otp)) return setLocalError('OTP harus 4-8 digit angka (kosongkan untuk tidak ganti).')
|
||||||
|
patch.otp = otp
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
onCancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLocalError(null)
|
||||||
|
onSave(patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={{ background: '#fffdf3' }}>
|
||||||
|
<td style={td}>{entry.phone}</td>
|
||||||
|
<td style={td}>{entry.user_type}</td>
|
||||||
|
<td style={td}>
|
||||||
|
<input type="text" value={label} onChange={e => setLabel(e.target.value)}
|
||||||
|
style={{ width: 180 }} disabled={isPending} />
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<input type="datetime-local" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
|
||||||
|
disabled={isPending} />
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<input
|
||||||
|
type="text" inputMode="numeric" value={otp}
|
||||||
|
onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
|
placeholder="Rotate OTP (opsional)"
|
||||||
|
style={{ width: 140, fontFamily: 'monospace' }}
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={td}>
|
||||||
|
<button type="button" onClick={submit} disabled={isPending} style={{ marginRight: 4 }}>
|
||||||
|
{isPending ? '...' : 'Simpan'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
|
||||||
|
{(localError || serverError) && (
|
||||||
|
<div style={{ color: 'red', fontSize: 11, marginTop: 2 }}>{localError || serverError}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user