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(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(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": [
|
||||
"/home/rama/workspaces/workspace-claude/halobestie-clone/backend/src",
|
||||
@@ -434,7 +644,8 @@
|
||||
"/proc/5649/fd",
|
||||
"/home/rama/.android/avd/Medium_Phone.avd",
|
||||
"/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
|
||||
REFRESH_TOKEN_TTL_DAYS=30
|
||||
|
||||
# Fazpass (OTP provider — TBD real values once docs are available)
|
||||
FAZPASS_API_KEY=
|
||||
FAZPASS_BASE_URL=
|
||||
FAZPASS_WEBHOOK_SECRET=
|
||||
# --- Fazpass (OTP provider) ---
|
||||
#
|
||||
# When FAZPASS_ENABLED=true, requestOtp() calls Fazpass /v1/otp/request and
|
||||
# verifyOtp() calls Fazpass /v1/otp/verify. When false, the in-process stub
|
||||
# generates + verifies codes locally (dev/test default).
|
||||
#
|
||||
# Single merchant key authenticates the account; single gateway key selects
|
||||
# the (channel + provider) tuple configured in dashboard → Integration → Add
|
||||
# Gateway. The client-supplied `channel` in /otp/request becomes informational
|
||||
# only when Fazpass is live — the gateway decides which channel actually fires.
|
||||
FAZPASS_ENABLED=false
|
||||
FAZPASS_BASE_URL=https://api.fazpass.com
|
||||
FAZPASS_MERCHANT_KEY=
|
||||
FAZPASS_GATEWAY_KEY=
|
||||
FAZPASS_TIMEOUT_MS=10000
|
||||
|
||||
# Google OAuth — comma-separated list of valid audience client IDs (Android, iOS).
|
||||
GOOGLE_OAUTH_CLIENT_IDS=
|
||||
|
||||
@@ -403,6 +403,18 @@ const migrate = async () => {
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL`
|
||||
|
||||
// OTP requests (Fazpass reference + rate-limit tracking)
|
||||
//
|
||||
// Storage shape rationale:
|
||||
// - is_bypass : explicit intent flag — true only when a row was created by
|
||||
// the test-OTP-bypass allowlist (phone-scoped static OTP for
|
||||
// App Store reviewers). Verify routes on this flag, NOT on
|
||||
// the mere presence of code_hash.
|
||||
// - code_hash : bcrypt hash of the OTP code, present whenever the backend
|
||||
// owns verification (stub-mode rows + bypass rows). NULL when
|
||||
// Fazpass owns verification (post-cutover, non-bypass rows).
|
||||
// - CHECK constraint: bypass rows MUST have code_hash and MUST NOT carry a
|
||||
// Fazpass reference — physically prevents a bypass row from
|
||||
// ever falling into the Fazpass-verify path.
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS otp_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -414,12 +426,36 @@ const migrate = async () => {
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
code_hash VARCHAR(255)
|
||||
)
|
||||
`
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
|
||||
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`
|
||||
|
||||
// Idempotent ALTERs for DBs created before is_bypass/code_hash were added.
|
||||
await sql`
|
||||
ALTER TABLE otp_requests
|
||||
ADD COLUMN IF NOT EXISTS is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)
|
||||
`
|
||||
|
||||
// Drop-then-add lets us tighten the invariant later without writing a v2.
|
||||
// The constraint is defense-in-depth alongside the verifyOtp branching: even
|
||||
// if app code regressed, the DB refuses to insert a corrupt bypass row.
|
||||
await sql`ALTER TABLE otp_requests DROP CONSTRAINT IF EXISTS otp_requests_bypass_shape`
|
||||
await sql`
|
||||
ALTER TABLE otp_requests
|
||||
ADD CONSTRAINT otp_requests_bypass_shape CHECK (
|
||||
is_bypass = FALSE OR (
|
||||
is_bypass = TRUE
|
||||
AND code_hash IS NOT NULL
|
||||
AND fazpass_reference IS NULL
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
// Auth-related app_config defaults
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value) VALUES
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getDb } from '../../db/client.js'
|
||||
import {
|
||||
getAnonymityConfig, setAnonymityConfig,
|
||||
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
|
||||
getFreeTrialConfig, setFreeTrialConfig,
|
||||
getExtensionTimeoutConfig, setExtensionTimeoutConfig,
|
||||
getEarlyEndConfig, setEarlyEndConfig,
|
||||
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
|
||||
@@ -16,6 +15,8 @@ import {
|
||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||
getSupportHandles, setSupportHandles,
|
||||
getTestOtpBypass, setTestOtpBypassEnabled, addTestOtpBypassEntry,
|
||||
updateTestOtpBypassEntry, deleteTestOtpBypassEntry,
|
||||
} from '../../services/config.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
@@ -111,22 +112,6 @@ export const internalConfigRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 3: Free Trial ---
|
||||
app.get('/free-trial', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (request, reply) => {
|
||||
const config = await getFreeTrialConfig()
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
app.patch('/free-trial', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { enabled, duration_minutes } = request.body ?? {}
|
||||
const config = await setFreeTrialConfig({ enabled, duration_minutes })
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 3: Extension Timeout ---
|
||||
app.get('/extension-timeout', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
@@ -735,6 +720,102 @@ export const internalConfigRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
// --- Test OTP bypass allowlist ---
|
||||
//
|
||||
// Phone-scoped static-OTP entries for Apple App Store reviewers / pre-launch
|
||||
// QA. See config.service.js for the storage shape and security rationale.
|
||||
// Writes publish 'config:invalidate' so peer instances drop any future cache;
|
||||
// today every read hits the DB, so this is mostly future-proofing.
|
||||
|
||||
const sendError = (reply, err) => {
|
||||
const status = err.statusCode || 500
|
||||
const payload = {
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code || 'INTERNAL',
|
||||
message: err.message,
|
||||
...(err.field && { field: err.field }),
|
||||
},
|
||||
}
|
||||
return reply.code(status).send(payload)
|
||||
}
|
||||
|
||||
app.get('/test-otp-bypass', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getTestOtpBypass() })
|
||||
})
|
||||
|
||||
app.patch('/test-otp-bypass/enabled', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { enabled } = request.body ?? {}
|
||||
try {
|
||||
const data = await setTestOtpBypassEnabled(enabled)
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
return reply.send({ success: true, data })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/test-otp-bypass/entries', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { phone, otp, user_type, label, expires_at } = request.body ?? {}
|
||||
try {
|
||||
const entry = await addTestOtpBypassEntry({ phone, otp, user_type, label, expires_at })
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
request.log.info({
|
||||
event: 'test_otp_bypass.entry_created',
|
||||
entry_id: entry.id,
|
||||
label: entry.label,
|
||||
phone_last4: entry.phone.slice(-4),
|
||||
user_type: entry.user_type,
|
||||
actor_cc_user_id: request.auth.userId,
|
||||
}, 'test OTP bypass entry created')
|
||||
return reply.code(201).send({ success: true, data: entry })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.patch('/test-otp-bypass/entries/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
const entry = await updateTestOtpBypassEntry(id, request.body ?? {})
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
request.log.info({
|
||||
event: 'test_otp_bypass.entry_updated',
|
||||
entry_id: entry.id,
|
||||
actor_cc_user_id: request.auth.userId,
|
||||
}, 'test OTP bypass entry updated')
|
||||
return reply.send({ success: true, data: entry })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/test-otp-bypass/entries/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
const result = await deleteTestOtpBypassEntry(id)
|
||||
await publishConfigInvalidate('test_otp_bypass')
|
||||
request.log.info({
|
||||
event: 'test_otp_bypass.entry_deleted',
|
||||
entry_id: id,
|
||||
actor_cc_user_id: request.auth.userId,
|
||||
}, 'test OTP bypass entry deleted')
|
||||
return reply.send({ success: true, data: result })
|
||||
} catch (err) {
|
||||
return sendError(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
app.get('/support-handles', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
|
||||
@@ -64,6 +64,7 @@ export const clientAuthRoutes = async (app) => {
|
||||
userType: UserType.CUSTOMER,
|
||||
ipAddress: request.ip,
|
||||
channel,
|
||||
logger: request.log,
|
||||
})
|
||||
return reply.send({ success: true, data: result })
|
||||
} catch (err) {
|
||||
@@ -74,7 +75,7 @@ export const clientAuthRoutes = async (app) => {
|
||||
app.post('/otp/verify', async (request, reply) => {
|
||||
const { otp_request_id, code } = request.body || {}
|
||||
try {
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code })
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code, logger: request.log })
|
||||
if (user_type !== UserType.CUSTOMER) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
|
||||
@@ -30,6 +30,7 @@ export const mitraAuthRoutes = async (app) => {
|
||||
userType: UserType.MITRA,
|
||||
ipAddress: request.ip,
|
||||
channel,
|
||||
logger: request.log,
|
||||
})
|
||||
return reply.send({ success: true, data: result })
|
||||
} catch (err) {
|
||||
@@ -40,7 +41,7 @@ export const mitraAuthRoutes = async (app) => {
|
||||
app.post('/otp/verify', async (request, reply) => {
|
||||
const { otp_request_id, code } = request.body || {}
|
||||
try {
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code })
|
||||
const { phone, user_type } = await verifyOtp({ otpRequestId: otp_request_id, code, logger: request.log })
|
||||
if (user_type !== UserType.MITRA) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import crypto from 'node:crypto'
|
||||
import { getDb } from '../db/client.js'
|
||||
import { ExtensionTimeoutAction } from '../constants.js'
|
||||
import { ExtensionTimeoutAction, UserType } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// bcrypt cost for the per-entry static OTP. Same rationale as
|
||||
// otp.service.js OTP_BCRYPT_COST — 10 keeps the verify SLA tight without
|
||||
// meaningfully reducing protection (OTPs are 6 digits; cost mostly buys time
|
||||
// against an offline DB-dump brute force, which the 5-min TTL already bounds).
|
||||
const TEST_OTP_BYPASS_BCRYPT_COST = 10
|
||||
|
||||
export const getAnonymityConfig = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'`
|
||||
return { anonymity_enabled: row?.value?.enabled ?? false }
|
||||
@@ -35,49 +43,6 @@ export const setMaxCustomersPerMitra = async (value) => {
|
||||
return { max_customers_per_mitra: value }
|
||||
}
|
||||
|
||||
// --- Phase 4: First-session discount config (back-compat shim) ---
|
||||
//
|
||||
// The canonical source of truth for the first-session discount lives in the
|
||||
// `pricing_promotions` table (eligibility = 'first_session'). The CC settings
|
||||
// page still calls `/internal/config/free-trial`, which exposes a slim
|
||||
// {enabled, duration_minutes} view — kept as a back-compat shim until the CC
|
||||
// UI is migrated to the richer /internal/config/first-session-discount handler.
|
||||
// Reads and writes go directly against `pricing_promotions` so operator edits
|
||||
// stay in sync with the customer-facing pricing payload.
|
||||
//
|
||||
// The legacy `first_session_discount_*` keys in `app_config` were retired in
|
||||
// Stage 5 (deleted by migrate.js) — do NOT reintroduce them.
|
||||
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const [row] = await sql`
|
||||
SELECT enabled, duration_minutes FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return {
|
||||
enabled: row?.enabled ?? true,
|
||||
duration_minutes: row?.duration_minutes ?? 12,
|
||||
}
|
||||
}
|
||||
|
||||
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||
// Build a sparse UPDATE so undefined fields are left alone (matches the prior
|
||||
// semantics where missing patch fields were no-ops). Use COALESCE on each
|
||||
// column with the sentinel-when-undefined pattern; postgres.js parameterizes
|
||||
// null/undefined identically, so we branch on which fields the caller sent.
|
||||
if (enabled === undefined && duration_minutes === undefined) {
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = ${enabled === undefined ? sql`enabled` : enabled},
|
||||
duration_minutes = ${duration_minutes === undefined ? sql`duration_minutes` : duration_minutes},
|
||||
updated_at = NOW()
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
|
||||
export const getSupportHandles = async () => {
|
||||
@@ -177,6 +142,25 @@ export const getValkeyOnlineMirrorSweepSeconds = () => {
|
||||
return Number.isFinite(parsed) && parsed >= 30 ? parsed : 300
|
||||
}
|
||||
|
||||
// --- Fazpass (OTP provider) ---
|
||||
//
|
||||
// Env-driven per backend/CLAUDE.md Config-Source Convention. Read at call
|
||||
// time (not module load) so test setups can inject via vi.stubEnv. When
|
||||
// `enabled` is true, otp.service.js routes both /request and /verify through
|
||||
// Fazpass; when false, the in-process stub plays the role of provider.
|
||||
export const getFazpassConfig = () => {
|
||||
const rawTimeout = Number.parseInt(process.env.FAZPASS_TIMEOUT_MS ?? '', 10)
|
||||
// Trim — dotenv preserves leading whitespace after `=` and a stray space
|
||||
// would corrupt the `Authorization: Bearer …` header silently.
|
||||
return {
|
||||
enabled: process.env.FAZPASS_ENABLED === 'true',
|
||||
baseUrl: (process.env.FAZPASS_BASE_URL || 'https://api.fazpass.com').trim(),
|
||||
merchantKey: (process.env.FAZPASS_MERCHANT_KEY ?? '').trim(),
|
||||
gatewayKey: (process.env.FAZPASS_GATEWAY_KEY ?? '').trim(),
|
||||
timeoutMs: Number.isFinite(rawTimeout) && rawTimeout >= 1000 ? rawTimeout : 10_000,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 5: Xendit integration ---
|
||||
//
|
||||
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values
|
||||
@@ -390,3 +374,242 @@ export const setPairingBlastTimeoutSeconds = async (value) => {
|
||||
`
|
||||
return { pairing_blast_timeout_seconds: value }
|
||||
}
|
||||
|
||||
// --- Test OTP bypass allowlist ---
|
||||
//
|
||||
// Phone-scoped static-OTP allowlist for Apple App Store reviewers and similar
|
||||
// pre-launch QA. When the phone in requestOtp() matches an entry here, the
|
||||
// backend skips Fazpass entirely and plants the entry's pre-hashed OTP into
|
||||
// otp_requests so the existing verify path works unchanged.
|
||||
//
|
||||
// Storage shape:
|
||||
// {
|
||||
// enabled: boolean, // global kill switch
|
||||
// entries: [
|
||||
// {
|
||||
// id: uuid,
|
||||
// phone: "+E.164",
|
||||
// user_type: "client" | "mitra",
|
||||
// otp_hash: "$2b$10$...", // bcrypt; plaintext NEVER stored
|
||||
// label: "Apple Reviewer #1",
|
||||
// expires_at: "ISO-8601", // per-entry auto-disable
|
||||
// created_at: "ISO-8601",
|
||||
// },
|
||||
// ...
|
||||
// ],
|
||||
// }
|
||||
//
|
||||
// Plaintext OTP is accepted by setTestOtpBypass at write time, bcrypt-hashed
|
||||
// before persisting, and is never readable again — list/get returns hashes
|
||||
// only, callers re-create entries to rotate the secret.
|
||||
|
||||
const TEST_OTP_BYPASS_KEY = 'test_otp_bypass'
|
||||
|
||||
const PHONE_E164_RE = /^\+[1-9]\d{6,14}$/
|
||||
const STATIC_OTP_RE = /^\d{4,8}$/
|
||||
|
||||
const isValidIsoDate = (v) => {
|
||||
if (typeof v !== 'string') return false
|
||||
const d = new Date(v)
|
||||
return !Number.isNaN(d.getTime())
|
||||
}
|
||||
|
||||
const sanitizeEntry = (entry) => ({
|
||||
id: entry.id,
|
||||
phone: entry.phone,
|
||||
user_type: entry.user_type,
|
||||
label: entry.label,
|
||||
// otp_hash is intentionally returned so the CC can show "hash on file" but
|
||||
// never the plaintext. We could redact further if the CC ever leaks logs.
|
||||
otp_hash: entry.otp_hash,
|
||||
expires_at: entry.expires_at,
|
||||
created_at: entry.created_at,
|
||||
})
|
||||
|
||||
const loadRawBypass = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = ${TEST_OTP_BYPASS_KEY}`
|
||||
const value = row?.value ?? { enabled: false, entries: [] }
|
||||
return {
|
||||
enabled: value.enabled === true,
|
||||
entries: Array.isArray(value.entries) ? value.entries : [],
|
||||
}
|
||||
}
|
||||
|
||||
const persistBypass = async ({ enabled, entries }) => {
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${TEST_OTP_BYPASS_KEY}, ${sql.json({ enabled, entries })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
|
||||
export const getTestOtpBypass = async () => {
|
||||
const raw = await loadRawBypass()
|
||||
return {
|
||||
enabled: raw.enabled,
|
||||
entries: raw.entries.map(sanitizeEntry),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot-path matcher used by requestOtp(). Returns the matching entry (with
|
||||
* otp_hash) if (kill switch on) + (phone exact match) + (not expired) +
|
||||
* (user_type matches). Returns null otherwise.
|
||||
*
|
||||
* Every call is a fresh DB SELECT — same pattern as the other config getters.
|
||||
* Cache TBD (see project memory: `config_cache_pending`).
|
||||
*/
|
||||
export const getTestOtpBypassMatch = async ({ phone, userType }) => {
|
||||
const raw = await loadRawBypass()
|
||||
if (!raw.enabled) return null
|
||||
const now = Date.now()
|
||||
for (const entry of raw.entries) {
|
||||
if (entry.phone !== phone) continue
|
||||
if (entry.user_type !== userType) continue
|
||||
if (!entry.expires_at) continue
|
||||
const exp = new Date(entry.expires_at).getTime()
|
||||
if (!Number.isFinite(exp) || exp <= now) continue
|
||||
return entry
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const setTestOtpBypassEnabled = async (enabled) => {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw Object.assign(new Error('enabled must be a boolean'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422,
|
||||
})
|
||||
}
|
||||
const raw = await loadRawBypass()
|
||||
await persistBypass({ ...raw, enabled })
|
||||
return getTestOtpBypass()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an entry. `otp` is plaintext (4-8 digits); we hash before persisting
|
||||
* and do not return it after. Phone must be E.164. user_type must match the
|
||||
* UserType enum (client | mitra). expires_at is required and must be in the
|
||||
* future. Duplicate (phone, user_type) is rejected.
|
||||
*/
|
||||
export const addTestOtpBypassEntry = async ({ phone, otp, user_type, label, expires_at }) => {
|
||||
if (typeof phone !== 'string' || !PHONE_E164_RE.test(phone)) {
|
||||
throw Object.assign(new Error('phone must be E.164 (e.g. +628...)'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'phone',
|
||||
})
|
||||
}
|
||||
if (typeof otp !== 'string' || !STATIC_OTP_RE.test(otp)) {
|
||||
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
|
||||
})
|
||||
}
|
||||
if (user_type !== UserType.CUSTOMER && user_type !== UserType.MITRA) {
|
||||
throw Object.assign(new Error(`user_type must be "${UserType.CUSTOMER}" or "${UserType.MITRA}"`), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'user_type',
|
||||
})
|
||||
}
|
||||
if (typeof label !== 'string' || label.trim().length === 0) {
|
||||
throw Object.assign(new Error('label is required'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
|
||||
})
|
||||
}
|
||||
if (!isValidIsoDate(expires_at)) {
|
||||
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
if (new Date(expires_at).getTime() <= Date.now()) {
|
||||
throw Object.assign(new Error('expires_at must be in the future'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
|
||||
const raw = await loadRawBypass()
|
||||
if (raw.entries.some((e) => e.phone === phone && e.user_type === user_type)) {
|
||||
throw Object.assign(new Error('An entry for this phone + user_type already exists'), {
|
||||
code: 'DUPLICATE_ENTRY', statusCode: 422, field: 'phone',
|
||||
})
|
||||
}
|
||||
|
||||
const otpHash = await bcrypt.hash(otp, TEST_OTP_BYPASS_BCRYPT_COST)
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
phone,
|
||||
user_type,
|
||||
label: label.trim(),
|
||||
otp_hash: otpHash,
|
||||
expires_at: new Date(expires_at).toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
raw.entries.push(entry)
|
||||
await persistBypass(raw)
|
||||
return sanitizeEntry(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch an entry by id. Supported fields: label, expires_at, otp (plaintext
|
||||
* → rehashed). Phone and user_type are immutable — delete + re-add to change
|
||||
* them, so the audit trail stays clean.
|
||||
*/
|
||||
export const updateTestOtpBypassEntry = async (id, patch) => {
|
||||
if (typeof id !== 'string' || id.length === 0) {
|
||||
throw Object.assign(new Error('id is required'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'id',
|
||||
})
|
||||
}
|
||||
const raw = await loadRawBypass()
|
||||
const idx = raw.entries.findIndex((e) => e.id === id)
|
||||
if (idx < 0) {
|
||||
throw Object.assign(new Error('Entry not found'), {
|
||||
code: 'NOT_FOUND', statusCode: 404,
|
||||
})
|
||||
}
|
||||
const current = raw.entries[idx]
|
||||
const next = { ...current }
|
||||
|
||||
if (patch.label !== undefined) {
|
||||
if (typeof patch.label !== 'string' || patch.label.trim().length === 0) {
|
||||
throw Object.assign(new Error('label must be a non-empty string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'label',
|
||||
})
|
||||
}
|
||||
next.label = patch.label.trim()
|
||||
}
|
||||
if (patch.expires_at !== undefined) {
|
||||
if (!isValidIsoDate(patch.expires_at)) {
|
||||
throw Object.assign(new Error('expires_at must be an ISO-8601 datetime string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
if (new Date(patch.expires_at).getTime() <= Date.now()) {
|
||||
throw Object.assign(new Error('expires_at must be in the future'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'expires_at',
|
||||
})
|
||||
}
|
||||
next.expires_at = new Date(patch.expires_at).toISOString()
|
||||
}
|
||||
if (patch.otp !== undefined) {
|
||||
if (typeof patch.otp !== 'string' || !STATIC_OTP_RE.test(patch.otp)) {
|
||||
throw Object.assign(new Error('otp must be a 4-8 digit numeric string'), {
|
||||
code: 'VALIDATION_ERROR', statusCode: 422, field: 'otp',
|
||||
})
|
||||
}
|
||||
next.otp_hash = await bcrypt.hash(patch.otp, TEST_OTP_BYPASS_BCRYPT_COST)
|
||||
}
|
||||
|
||||
raw.entries[idx] = next
|
||||
await persistBypass(raw)
|
||||
return sanitizeEntry(next)
|
||||
}
|
||||
|
||||
export const deleteTestOtpBypassEntry = async (id) => {
|
||||
const raw = await loadRawBypass()
|
||||
const before = raw.entries.length
|
||||
raw.entries = raw.entries.filter((e) => e.id !== id)
|
||||
if (raw.entries.length === before) {
|
||||
throw Object.assign(new Error('Entry not found'), {
|
||||
code: 'NOT_FOUND', statusCode: 404,
|
||||
})
|
||||
}
|
||||
await persistBypass(raw)
|
||||
return { deleted: true, id }
|
||||
}
|
||||
|
||||
192
backend/src/services/fazpass.service.js
Normal file
192
backend/src/services/fazpass.service.js
Normal file
@@ -0,0 +1,192 @@
|
||||
// Fazpass OTP provider client.
|
||||
//
|
||||
// Two endpoints per Fazpass docs:
|
||||
// POST /v1/otp/request — Fazpass creates the OTP, masks it in response,
|
||||
// owns verification (mandatory at provider).
|
||||
// POST /v1/otp/verify — submit { otp_id, otp } back to provider.
|
||||
//
|
||||
// Auth: Authorization: Bearer <MERCHANT_KEY>. Channel selection is via
|
||||
// `gateway_key` in the body — one gateway per provider (we use a single
|
||||
// gateway today, so the client-supplied OtpChannel is informational).
|
||||
//
|
||||
// Error policy:
|
||||
// - Transport/timeout failures → throw FazpassError (502 upstream)
|
||||
// - 4xx with parseable body → throw FazpassError with the body's
|
||||
// `code` + `message` for log triage
|
||||
// - 2xx with `status: false` → success-shaped failure path; for
|
||||
// /request this is a provider reject
|
||||
// (throws), for /verify this is the
|
||||
// normal "wrong OTP" (returns {valid:false})
|
||||
// - 2xx with `status: true` (or undefined on legacy responses)
|
||||
// → success
|
||||
//
|
||||
// We do NOT trust HTTP status alone — Fazpass occasionally returns 200 OK with
|
||||
// `status: false` for legitimate "wrong code" responses, which must not be
|
||||
// reported as outages. The verify path differentiates this from a real outage
|
||||
// by always parsing the body and only escalating to FazpassError if the body
|
||||
// is unparseable or the http status is non-2xx.
|
||||
|
||||
import { getFazpassConfig } from './config.service.js'
|
||||
|
||||
export class FazpassError extends Error {
|
||||
constructor(message, { httpStatus, providerCode, providerMessage, cause } = {}) {
|
||||
super(message)
|
||||
this.name = 'FazpassError'
|
||||
this.code = 'OTP_PROVIDER_FAILED'
|
||||
this.statusCode = 502
|
||||
this.httpStatus = httpStatus ?? null
|
||||
this.providerCode = providerCode ?? null
|
||||
this.providerMessage = providerMessage ?? null
|
||||
if (cause) this.cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
const buildHeaders = (merchantKey) => ({
|
||||
'Authorization': `Bearer ${merchantKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
})
|
||||
|
||||
const parseJsonSafe = async (res) => {
|
||||
try {
|
||||
return await res.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const postJson = async ({ url, body, headers, timeoutMs, logger }) => {
|
||||
let res
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
})
|
||||
} catch (err) {
|
||||
// AbortError (timeout) or network failure — log once, then escalate.
|
||||
logger?.error({ err: { message: err?.message, name: err?.name }, url }, 'Fazpass HTTP error')
|
||||
throw new FazpassError(`Fazpass request failed: ${err?.message ?? 'unknown error'}`, { cause: err })
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an OTP from Fazpass. Returns { reference, channel_used, provider }.
|
||||
* Throws FazpassError on transport, non-2xx, or status:false response.
|
||||
*/
|
||||
export const fazpassRequestOtp = async ({ phone, logger }) => {
|
||||
const cfg = getFazpassConfig()
|
||||
if (!cfg.enabled) {
|
||||
throw new FazpassError('Fazpass is not enabled (FAZPASS_ENABLED=false)')
|
||||
}
|
||||
if (!cfg.merchantKey || !cfg.gatewayKey) {
|
||||
throw new FazpassError('Fazpass credentials missing — set FAZPASS_MERCHANT_KEY and FAZPASS_GATEWAY_KEY')
|
||||
}
|
||||
|
||||
const url = `${cfg.baseUrl}/v1/otp/request`
|
||||
const res = await postJson({
|
||||
url,
|
||||
headers: buildHeaders(cfg.merchantKey),
|
||||
body: { phone, gateway_key: cfg.gatewayKey },
|
||||
timeoutMs: cfg.timeoutMs,
|
||||
logger,
|
||||
})
|
||||
const json = await parseJsonSafe(res)
|
||||
|
||||
if (!res.ok) {
|
||||
logger?.warn({
|
||||
event: 'fazpass.request.non_2xx',
|
||||
http_status: res.status,
|
||||
provider_code: json?.code ?? null,
|
||||
provider_message: json?.message ?? null,
|
||||
}, 'Fazpass /request non-2xx')
|
||||
throw new FazpassError(`Fazpass /request returned HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
providerCode: json?.code ?? null,
|
||||
providerMessage: json?.message ?? null,
|
||||
})
|
||||
}
|
||||
if (json?.status !== true || !json?.data?.id) {
|
||||
logger?.warn({
|
||||
event: 'fazpass.request.bad_shape',
|
||||
http_status: res.status,
|
||||
provider_code: json?.code ?? null,
|
||||
provider_message: json?.message ?? null,
|
||||
has_id: !!json?.data?.id,
|
||||
}, 'Fazpass /request returned status:false or missing id')
|
||||
throw new FazpassError('Fazpass /request returned a non-success body', {
|
||||
httpStatus: res.status,
|
||||
providerCode: json?.code ?? null,
|
||||
providerMessage: json?.message ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
reference: json.data.id,
|
||||
channel_used: json.data.channel ?? null,
|
||||
provider: json.data.provider ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an OTP via Fazpass. Returns { valid, providerCode, providerMessage }.
|
||||
* - 2xx + status:true → { valid: true }
|
||||
* - 2xx + status:false → { valid: false } (normal "wrong code")
|
||||
* - non-2xx or bad shape → throws FazpassError
|
||||
*
|
||||
* The throw policy intentionally separates "wrong code" (a normal UX path,
|
||||
* returns valid:false) from "provider outage / our state is corrupt" (a 502).
|
||||
* If Fazpass starts using 4xx for legitimate wrong-code responses we'll need
|
||||
* to re-classify based on their `code` field — surfaced in the logs.
|
||||
*/
|
||||
export const fazpassVerifyOtp = async ({ reference, code, logger }) => {
|
||||
const cfg = getFazpassConfig()
|
||||
if (!cfg.enabled) {
|
||||
throw new FazpassError('Fazpass is not enabled (FAZPASS_ENABLED=false)')
|
||||
}
|
||||
if (!cfg.merchantKey) {
|
||||
throw new FazpassError('Fazpass credentials missing — set FAZPASS_MERCHANT_KEY')
|
||||
}
|
||||
|
||||
const url = `${cfg.baseUrl}/v1/otp/verify`
|
||||
const res = await postJson({
|
||||
url,
|
||||
headers: buildHeaders(cfg.merchantKey),
|
||||
body: { otp_id: reference, otp: code },
|
||||
timeoutMs: cfg.timeoutMs,
|
||||
logger,
|
||||
})
|
||||
const json = await parseJsonSafe(res)
|
||||
|
||||
if (!res.ok) {
|
||||
logger?.warn({
|
||||
event: 'fazpass.verify.non_2xx',
|
||||
http_status: res.status,
|
||||
provider_code: json?.code ?? null,
|
||||
provider_message: json?.message ?? null,
|
||||
}, 'Fazpass /verify non-2xx')
|
||||
throw new FazpassError(`Fazpass /verify returned HTTP ${res.status}`, {
|
||||
httpStatus: res.status,
|
||||
providerCode: json?.code ?? null,
|
||||
providerMessage: json?.message ?? null,
|
||||
})
|
||||
}
|
||||
if (json == null || typeof json.status !== 'boolean') {
|
||||
logger?.warn({
|
||||
event: 'fazpass.verify.bad_shape',
|
||||
http_status: res.status,
|
||||
body_keys: json ? Object.keys(json) : null,
|
||||
}, 'Fazpass /verify returned malformed body')
|
||||
throw new FazpassError('Fazpass /verify returned a malformed body', {
|
||||
httpStatus: res.status,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
valid: json.status === true,
|
||||
providerCode: json.code ?? null,
|
||||
providerMessage: json.message ?? null,
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
import crypto from 'node:crypto'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { getDb } from '../db/client.js'
|
||||
import { getOtpRateLimits } from './config.service.js'
|
||||
import { getOtpRateLimits, getTestOtpBypassMatch, getFazpassConfig } from './config.service.js'
|
||||
import { fazpassRequestOtp, fazpassVerifyOtp, FazpassError } from './fazpass.service.js'
|
||||
import { OtpChannel, UserType } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const OTP_TTL_MINUTES = 5
|
||||
|
||||
// bcrypt cost for OTP codes. Lower than password (12) because OTPs live 5 min
|
||||
// and the verify call happens once per attempt — total budget ~80ms per verify
|
||||
// is fine, and the lower cost makes the verify SLA tighter on slow Cloud Run
|
||||
// cold starts.
|
||||
const OTP_BCRYPT_COST = 10
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// ⚠ Fazpass integration — STUB until real API docs are obtained.
|
||||
// Fazpass integration — STUB until real API docs are obtained.
|
||||
//
|
||||
// In production, Fazpass is the source of truth for the OTP code.
|
||||
// We will only ever handle a reference ID (string) returned by Fazpass,
|
||||
// never the raw code. For now, we generate a 6-digit code locally and
|
||||
// store its bcrypt hash in the metadata field of otp_requests via
|
||||
// fazpass_reference (reused as "<reference>:<hash>") so the stub can
|
||||
// round-trip without schema changes.
|
||||
// In production, Fazpass is the source of truth for the OTP code: the backend
|
||||
// never sees the plaintext code. The stub generates a 6-digit code locally,
|
||||
// bcrypt-hashes it into otp_requests.code_hash, and ships the plaintext only
|
||||
// to in-memory (for /peek-otp dev convenience) and to the dev console log.
|
||||
//
|
||||
// When real docs arrive, replace fazpassSendStub + fazpassVerifyStub
|
||||
// with real HTTP calls and drop the local code generation.
|
||||
// When real docs arrive: replace fazpassSendStub with a real HTTP call, and
|
||||
// stop writing code_hash on the normal path (Fazpass owns verification then).
|
||||
// The bypass path keeps writing code_hash exactly as it does today — that's
|
||||
// the only place backend-owned verification survives post-cutover.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const generate6DigitCode = () => {
|
||||
@@ -47,10 +55,6 @@ const fazpassSendStub = async ({ phone, channel }) => {
|
||||
console.log(`[OTP STUB] phone=${phone} channel=${channel} code=${code} ref=${reference}`)
|
||||
return { reference, channel_used: channel, code } // `code` only present in stub
|
||||
}
|
||||
|
||||
const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
|
||||
return { valid: code === expectedCode }
|
||||
}
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export class OtpError extends Error {
|
||||
@@ -132,7 +136,7 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
||||
* Start an OTP flow. Returns { otp_request_id, channel_used, expires_at }.
|
||||
* Does NOT return the code to the caller.
|
||||
*/
|
||||
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP }) => {
|
||||
export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChannel.WHATSAPP, logger }) => {
|
||||
if (!phone || !/^\+[1-9]\d{6,14}$/.test(phone)) {
|
||||
throw new OtpError('Invalid phone format (E.164 expected)', 'PHONE_INVALID', 422)
|
||||
}
|
||||
@@ -143,19 +147,92 @@ export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChan
|
||||
const limits = await getOtpRateLimits()
|
||||
await checkRateLimits({ phone, ipAddress, limits })
|
||||
|
||||
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
|
||||
// Test-user bypass: when this phone is in the CC-managed allowlist,
|
||||
// plant a pre-hashed static OTP and skip Fazpass entirely. Logged loudly so
|
||||
// any successful bypass is visible in audit pipelines.
|
||||
const bypassEntry = await getTestOtpBypassMatch({ phone, userType })
|
||||
if (bypassEntry) {
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (
|
||||
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
|
||||
is_bypass, code_hash
|
||||
)
|
||||
VALUES (
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, NULL, ${channel},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
|
||||
TRUE, ${bypassEntry.otp_hash}
|
||||
)
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
if (logger) {
|
||||
logger.info({
|
||||
event: 'test_otp_bypass.request',
|
||||
otp_request_id: row.id,
|
||||
label: bypassEntry.label,
|
||||
phone_last4: phone.slice(-4),
|
||||
user_type: userType,
|
||||
}, 'test OTP bypass triggered')
|
||||
}
|
||||
return {
|
||||
otp_request_id: row.id,
|
||||
channel_used: channel,
|
||||
expires_at: row.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Store the reference. In stub mode, we also store the expected code appended
|
||||
// after a colon so the verify stub can compare. Real Fazpass flow will NOT store
|
||||
// the code; Fazpass itself holds it. This line is the main place to change
|
||||
// when switching to real Fazpass.
|
||||
const storedReference = code ? `${reference}:${code}` : reference
|
||||
// Live Fazpass path. Provider owns the code AND verification — we only
|
||||
// hold the reference. code_hash MUST stay NULL so verifyOtp's branching
|
||||
// routes this row to Fazpass (the DB CHECK constraint also relies on the
|
||||
// is_bypass=false shape we set here).
|
||||
const fazpass = getFazpassConfig()
|
||||
if (fazpass.enabled) {
|
||||
const { reference, channel_used: providerChannel, provider } = await fazpassRequestOtp({
|
||||
phone, logger,
|
||||
})
|
||||
if (logger) {
|
||||
logger.info({
|
||||
event: 'fazpass.request.ok',
|
||||
phone_last4: phone.slice(-4),
|
||||
provider, provider_channel: providerChannel, requested_channel: channel,
|
||||
}, 'Fazpass OTP request succeeded')
|
||||
}
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (
|
||||
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
|
||||
is_bypass, code_hash
|
||||
)
|
||||
VALUES (
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
|
||||
FALSE, NULL
|
||||
)
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
return {
|
||||
otp_request_id: row.id,
|
||||
// Echo the client-requested channel for backwards compatibility — apps
|
||||
// already render this in user-facing strings. Provider's internal
|
||||
// channel code lives in logs only.
|
||||
channel_used: channel,
|
||||
expires_at: row.expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
// Stub fallback (FAZPASS_ENABLED=false). Generates a local 6-digit code,
|
||||
// stores its bcrypt hash, and lets the in-memory peek endpoint expose the
|
||||
// plaintext for Maestro / dev. Removed once Fazpass is the only path.
|
||||
const { reference, channel_used, code } = await fazpassSendStub({ phone, channel })
|
||||
const codeHash = await bcrypt.hash(code, OTP_BCRYPT_COST)
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (phone, ip_address, user_type, fazpass_reference, channel, expires_at)
|
||||
INSERT INTO otp_requests (
|
||||
phone, ip_address, user_type, fazpass_reference, channel, expires_at,
|
||||
is_bypass, code_hash
|
||||
)
|
||||
VALUES (
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, ${storedReference}, ${channel_used},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval
|
||||
${phone}, ${ipAddress ?? null}, ${userType}, ${reference}, ${channel_used},
|
||||
NOW() + (${OTP_TTL_MINUTES} || ' minutes')::interval,
|
||||
FALSE, ${codeHash}
|
||||
)
|
||||
RETURNING id, expires_at
|
||||
`
|
||||
@@ -171,7 +248,7 @@ export const requestOtp = async ({ phone, userType, ipAddress, channel = OtpChan
|
||||
* Verify an OTP code. Returns { phone, user_type } on success.
|
||||
* Throws OtpError on failure.
|
||||
*/
|
||||
export const verifyOtp = async ({ otpRequestId, code }) => {
|
||||
export const verifyOtp = async ({ otpRequestId, code, logger }) => {
|
||||
if (typeof code !== 'string' || !/^\d{4,8}$/.test(code)) {
|
||||
throw new OtpError('Invalid code format', 'CODE_INVALID', 422)
|
||||
}
|
||||
@@ -179,7 +256,8 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
|
||||
const limits = await getOtpRateLimits()
|
||||
|
||||
const [row] = await sql`
|
||||
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at
|
||||
SELECT id, phone, user_type, fazpass_reference, attempts, used_at, expires_at,
|
||||
is_bypass, code_hash
|
||||
FROM otp_requests
|
||||
WHERE id = ${otpRequestId}
|
||||
`
|
||||
@@ -198,9 +276,86 @@ export const verifyOtp = async ({ otpRequestId, code }) => {
|
||||
|
||||
await sql`UPDATE otp_requests SET attempts = attempts + 1 WHERE id = ${otpRequestId}`
|
||||
|
||||
// Stub: fazpass_reference is stored as "<ref>:<code>"
|
||||
const [reference, expectedCode] = (row.fazpass_reference || '').split(':')
|
||||
const { valid } = await fazpassVerifyStub({ reference, code, expectedCode })
|
||||
// Verification routing: the is_bypass flag is sovereign — never use the
|
||||
// mere presence of code_hash to decide which verifier runs, because a
|
||||
// bug or errant migration could leave code_hash populated on a normal row.
|
||||
let valid = false
|
||||
if (row.is_bypass) {
|
||||
if (!row.code_hash) {
|
||||
// DB CHECK constraint should make this impossible, but defend anyway.
|
||||
if (logger) {
|
||||
logger.error({ otp_request_id: row.id }, 'bypass row missing code_hash — refusing')
|
||||
}
|
||||
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
|
||||
}
|
||||
valid = await bcrypt.compare(code, row.code_hash)
|
||||
if (valid && logger) {
|
||||
logger.info({
|
||||
event: 'test_otp_bypass.verify_success',
|
||||
otp_request_id: row.id,
|
||||
phone_last4: row.phone.slice(-4),
|
||||
user_type: row.user_type,
|
||||
}, 'test OTP bypass verified')
|
||||
}
|
||||
} else {
|
||||
// Normal row. Routing depends on which mode wrote it:
|
||||
// - stub-mode row → code_hash is set, bcrypt-compare locally
|
||||
// - Fazpass-live row → code_hash is NULL, defer to provider
|
||||
// Distinguishing by code_hash presence is safe here because the
|
||||
// is_bypass=true case is already handled above; this branch only sees
|
||||
// normal rows where the writer's mode is encoded by which fields they
|
||||
// populated (CHECK constraint ensures bypass rows can't reach here).
|
||||
if (row.code_hash) {
|
||||
valid = await bcrypt.compare(code, row.code_hash)
|
||||
} else {
|
||||
if (!row.fazpass_reference) {
|
||||
// Both code_hash AND fazpass_reference are NULL — row is unverifiable
|
||||
// (a bug, partial write, or someone tampering). Don't fall through to
|
||||
// "valid by default"; reject and alert.
|
||||
if (logger) {
|
||||
logger.error({ otp_request_id: row.id }, 'non-bypass row has no code_hash and no fazpass_reference — unverifiable')
|
||||
}
|
||||
throw new OtpError('OTP system error', 'OTP_CORRUPT', 500)
|
||||
}
|
||||
try {
|
||||
const result = await fazpassVerifyOtp({
|
||||
reference: row.fazpass_reference,
|
||||
code,
|
||||
logger,
|
||||
})
|
||||
valid = result.valid
|
||||
if (!valid && logger) {
|
||||
logger.info({
|
||||
event: 'fazpass.verify.invalid',
|
||||
otp_request_id: row.id,
|
||||
provider_code: result.providerCode,
|
||||
provider_message: result.providerMessage,
|
||||
}, 'Fazpass reported invalid OTP — surfacing as CODE_MISMATCH')
|
||||
}
|
||||
} catch (err) {
|
||||
// Provider outage / our state corrupt / Fazpass schema drift.
|
||||
// Distinct from "wrong code" — preserve attempt increment but throw
|
||||
// 502 so the client distinguishes "retry the code" from "retry later".
|
||||
if (err instanceof FazpassError) {
|
||||
if (logger) {
|
||||
logger.error({
|
||||
err: {
|
||||
message: err.message,
|
||||
provider_code: err.providerCode,
|
||||
provider_message: err.providerMessage,
|
||||
http_status: err.httpStatus,
|
||||
},
|
||||
otp_request_id: row.id,
|
||||
}, 'Fazpass verify failed (provider-side)')
|
||||
}
|
||||
throw new OtpError('OTP verification temporarily unavailable', 'OTP_PROVIDER_FAILED', 502, {
|
||||
provider_code: err.providerCode,
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
throw new OtpError('Incorrect code', 'CODE_MISMATCH', 401)
|
||||
|
||||
@@ -203,12 +203,6 @@ export const getExtensionPriceTiers = async (customerId) => {
|
||||
|
||||
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ----
|
||||
|
||||
/**
|
||||
* @deprecated Use isCustomerEligibleForFirstSessionDiscount.
|
||||
* Kept so route handlers and migrated services still resolve while we cut over.
|
||||
*/
|
||||
export const isCustomerEligibleForFreeTrial = isCustomerEligibleForFirstSessionDiscount
|
||||
|
||||
/**
|
||||
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
|
||||
* Returns chat tiers in the legacy shape (single array, no group wrapper).
|
||||
|
||||
176
backend/test/services/fazpass.service.test.js
Normal file
176
backend/test/services/fazpass.service.test.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
|
||||
// Imported lazily after env stubs so the config getter reads the test values.
|
||||
let fazpassRequestOtp
|
||||
let fazpassVerifyOtp
|
||||
let FazpassError
|
||||
|
||||
const setFazpassEnv = () => {
|
||||
vi.stubEnv('FAZPASS_ENABLED', 'true')
|
||||
vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example')
|
||||
vi.stubEnv('FAZPASS_MERCHANT_KEY', 'test-merchant-key')
|
||||
vi.stubEnv('FAZPASS_GATEWAY_KEY', 'test-gateway-key')
|
||||
vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000')
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
setFazpassEnv()
|
||||
// Re-import so the module's top-level closures use the stubbed env.
|
||||
// getFazpassConfig reads process.env at call time so this is mostly a safety
|
||||
// belt — but it also ensures the test isn't depending on import order.
|
||||
const mod = await import('../../src/services/fazpass.service.js')
|
||||
fazpassRequestOtp = mod.fazpassRequestOtp
|
||||
fazpassVerifyOtp = mod.fazpassVerifyOtp
|
||||
FazpassError = mod.FazpassError
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl)
|
||||
|
||||
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
describe('fazpassRequestOtp', () => {
|
||||
it('POSTs phone + gateway_key with Bearer auth and returns reference', async () => {
|
||||
let captured
|
||||
mockFetch(async (url, init) => {
|
||||
captured = { url, init }
|
||||
return jsonResponse(200, {
|
||||
status: true,
|
||||
message: 'Request generated successfully',
|
||||
code: '2000200',
|
||||
data: {
|
||||
id: 'abc-123', otp: 'XXXXXX', otp_length: 6,
|
||||
channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'testing',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fazpassRequestOtp({ phone: '+628111' })
|
||||
|
||||
expect(result).toEqual({
|
||||
reference: 'abc-123',
|
||||
channel_used: 'WA_GENERIC_OTP',
|
||||
provider: 'Fazpass',
|
||||
})
|
||||
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/request')
|
||||
expect(captured.init.method).toBe('POST')
|
||||
expect(captured.init.headers.Authorization).toBe('Bearer test-merchant-key')
|
||||
expect(captured.init.headers['Content-Type']).toBe('application/json')
|
||||
expect(JSON.parse(captured.init.body)).toEqual({
|
||||
phone: '+628111',
|
||||
gateway_key: 'test-gateway-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws FazpassError on non-2xx with provider code surfaced', async () => {
|
||||
mockFetch(async () => jsonResponse(400, { status: false, code: '4000400', message: 'bad gateway_key' }))
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({
|
||||
code: 'OTP_PROVIDER_FAILED',
|
||||
statusCode: 502,
|
||||
httpStatus: 400,
|
||||
providerCode: '4000400',
|
||||
providerMessage: 'bad gateway_key',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws FazpassError when 2xx body has status:false', async () => {
|
||||
mockFetch(async () => jsonResponse(200, { status: false, code: '5000500', message: 'gateway down' }))
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', providerCode: '5000500' })
|
||||
})
|
||||
|
||||
it('throws FazpassError when 2xx body is missing data.id', async () => {
|
||||
mockFetch(async () => jsonResponse(200, { status: true, data: { otp: 'XXXXXX' } }))
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toBeInstanceOf(FazpassError)
|
||||
})
|
||||
|
||||
it('throws FazpassError on transport / timeout error', async () => {
|
||||
mockFetch(async () => { throw new Error('network down') })
|
||||
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', httpStatus: null })
|
||||
})
|
||||
|
||||
it('throws when FAZPASS_ENABLED is false', async () => {
|
||||
vi.stubEnv('FAZPASS_ENABLED', 'false')
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
|
||||
})
|
||||
|
||||
it('throws when merchantKey or gatewayKey are blank', async () => {
|
||||
vi.stubEnv('FAZPASS_MERCHANT_KEY', '')
|
||||
await expect(fazpassRequestOtp({ phone: '+628111' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('fazpassVerifyOtp', () => {
|
||||
it('POSTs otp_id + otp with Bearer auth and returns valid:true on status:true', async () => {
|
||||
let captured
|
||||
mockFetch(async (url, init) => {
|
||||
captured = { url, init }
|
||||
return jsonResponse(200, {
|
||||
status: true, message: 'Validate otp successfully', code: '2000200',
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '424242' })
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: true,
|
||||
providerCode: '2000200',
|
||||
providerMessage: 'Validate otp successfully',
|
||||
})
|
||||
expect(captured.url).toBe('https://api.fazpass.example/v1/otp/verify')
|
||||
expect(JSON.parse(captured.init.body)).toEqual({
|
||||
otp_id: 'abc-123', otp: '424242',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns valid:false on 2xx + status:false (the "wrong OTP" path)', async () => {
|
||||
mockFetch(async () => jsonResponse(200, {
|
||||
status: false, message: 'Invalid OTP', code: '4000401',
|
||||
}))
|
||||
|
||||
const result = await fazpassVerifyOtp({ reference: 'abc-123', code: '000000' })
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.providerCode).toBe('4000401')
|
||||
})
|
||||
|
||||
it('throws FazpassError on non-2xx (provider outage)', async () => {
|
||||
mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }))
|
||||
|
||||
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
|
||||
.rejects.toMatchObject({
|
||||
code: 'OTP_PROVIDER_FAILED',
|
||||
httpStatus: 503,
|
||||
providerCode: '5030503',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws FazpassError on malformed body (no status field)', async () => {
|
||||
mockFetch(async () => new Response('not json', { status: 200, headers: { 'Content-Type': 'text/plain' } }))
|
||||
|
||||
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
|
||||
.rejects.toBeInstanceOf(FazpassError)
|
||||
})
|
||||
|
||||
it('throws FazpassError on network error', async () => {
|
||||
mockFetch(async () => { throw new Error('connection reset') })
|
||||
|
||||
await expect(fazpassVerifyOtp({ reference: 'abc-123', code: '000000' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED' })
|
||||
})
|
||||
})
|
||||
436
backend/test/services/otp.service.test.js
Normal file
436
backend/test/services/otp.service.test.js
Normal file
@@ -0,0 +1,436 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import bcrypt from 'bcrypt'
|
||||
|
||||
const { requestOtp, verifyOtp, OtpError } = await import('../../src/services/otp.service.js')
|
||||
const {
|
||||
addTestOtpBypassEntry,
|
||||
setTestOtpBypassEnabled,
|
||||
getTestOtpBypass,
|
||||
} = await import('../../src/services/config.service.js')
|
||||
const { db, resetDb } = await import('../helpers/db.js')
|
||||
|
||||
// Unique phone per test so rate-limit (3 per hour per phone) doesn't poison
|
||||
// tests that reuse otp_requests rows. resetDb() truncates otp_requests but
|
||||
// keeps the rate-limit guarantee tight regardless.
|
||||
const uniquePhone = () => {
|
||||
const digits = randomUUID().replace(/[^0-9]/g, '').slice(0, 10).padEnd(10, '0')
|
||||
return `+628${digits}`
|
||||
}
|
||||
|
||||
const clearBypassConfig = async () => {
|
||||
const sql = db()
|
||||
await sql`DELETE FROM app_config WHERE key = 'test_otp_bypass'`
|
||||
}
|
||||
|
||||
const peekOtpRow = async (id) => {
|
||||
const sql = db()
|
||||
const [row] = await sql`
|
||||
SELECT id, phone, fazpass_reference, is_bypass, code_hash, used_at, expires_at
|
||||
FROM otp_requests WHERE id = ${id}
|
||||
`
|
||||
return row
|
||||
}
|
||||
|
||||
describe('otp.service — hash-at-rest (stub mode)', () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
await clearBypassConfig()
|
||||
})
|
||||
|
||||
it('stores bcrypt(code_hash) instead of plaintext after requestOtp', async () => {
|
||||
const phone = uniquePhone()
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.1',
|
||||
})
|
||||
const row = await peekOtpRow(otp_request_id)
|
||||
|
||||
expect(row).toBeDefined()
|
||||
expect(row.is_bypass).toBe(false)
|
||||
expect(row.code_hash).toMatch(/^\$2[aby]\$/) // bcrypt signature
|
||||
// fazpass_reference holds ONLY the stub reference now — no ":code" suffix.
|
||||
expect(row.fazpass_reference).toMatch(/^stub_/)
|
||||
expect(row.fazpass_reference).not.toContain(':')
|
||||
})
|
||||
|
||||
it('verifyOtp succeeds against the same plaintext code (via stub peek)', async () => {
|
||||
const phone = uniquePhone()
|
||||
// Pin the stub to a known code so we don't depend on the in-memory Map.
|
||||
vi.stubEnv('OTP_STATIC_CODE', '424242')
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.1',
|
||||
})
|
||||
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' })
|
||||
expect(result).toEqual({ phone, user_type: 'customer' })
|
||||
|
||||
const used = await peekOtpRow(otp_request_id)
|
||||
expect(used.used_at).not.toBeNull()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('verifyOtp rejects a wrong code with CODE_MISMATCH', async () => {
|
||||
const phone = uniquePhone()
|
||||
vi.stubEnv('OTP_STATIC_CODE', '111111')
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.1',
|
||||
})
|
||||
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '999999' }))
|
||||
.rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 })
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
|
||||
describe('otp.service — DB-level CHECK constraint', () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
await clearBypassConfig()
|
||||
})
|
||||
|
||||
it('rejects an insert with is_bypass=true and code_hash NULL', async () => {
|
||||
const sql = db()
|
||||
await expect(sql`
|
||||
INSERT INTO otp_requests (phone, user_type, channel, expires_at, is_bypass, code_hash)
|
||||
VALUES ('+628999999991', 'customer', 'whatsapp', NOW() + INTERVAL '5 min', true, NULL)
|
||||
`).rejects.toMatchObject({ code: '23514' }) // PG check_violation
|
||||
})
|
||||
|
||||
it('rejects an insert with is_bypass=true and fazpass_reference set', async () => {
|
||||
const sql = db()
|
||||
await expect(sql`
|
||||
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
|
||||
is_bypass, code_hash, fazpass_reference)
|
||||
VALUES ('+628999999992', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
|
||||
true, '$2b$10$abcdefghijklmnopqrstuv', 'leak_ref')
|
||||
`).rejects.toMatchObject({ code: '23514' })
|
||||
})
|
||||
|
||||
it('allows is_bypass=false with code_hash NULL (Fazpass-live shape) at insert time', async () => {
|
||||
const sql = db()
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
|
||||
is_bypass, code_hash, fazpass_reference)
|
||||
VALUES ('+628999999993', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
|
||||
false, NULL, 'fazpass_ref_xyz')
|
||||
RETURNING id
|
||||
`
|
||||
expect(row.id).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('otp.service — verify anomaly refusal', () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
await clearBypassConfig()
|
||||
})
|
||||
|
||||
it('rejects verify on a row missing BOTH code_hash and fazpass_reference (unverifiable)', async () => {
|
||||
const sql = db()
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
|
||||
is_bypass, code_hash, fazpass_reference)
|
||||
VALUES ('+628999999994', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
|
||||
false, NULL, NULL)
|
||||
RETURNING id
|
||||
`
|
||||
await expect(verifyOtp({ otpRequestId: row.id, code: '123456' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_CORRUPT', statusCode: 500 })
|
||||
})
|
||||
|
||||
it('returns OTP_PROVIDER_FAILED when row has fazpass_reference but Fazpass is disabled', async () => {
|
||||
const sql = db()
|
||||
const [row] = await sql`
|
||||
INSERT INTO otp_requests (phone, user_type, channel, expires_at,
|
||||
is_bypass, code_hash, fazpass_reference)
|
||||
VALUES ('+628999999998', 'customer', 'whatsapp', NOW() + INTERVAL '5 min',
|
||||
false, NULL, 'fazpass_ref_xyz')
|
||||
RETURNING id
|
||||
`
|
||||
// FAZPASS_ENABLED is unset/false in tests; fazpassVerifyOtp throws
|
||||
// FazpassError, which otp.service.js converts to OTP_PROVIDER_FAILED 502.
|
||||
await expect(verifyOtp({ otpRequestId: row.id, code: '123456' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('otp.service — test OTP bypass allowlist', () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
await clearBypassConfig()
|
||||
})
|
||||
afterEach(async () => {
|
||||
await clearBypassConfig()
|
||||
})
|
||||
|
||||
it('plants a bypass row that verifies against the configured static OTP', async () => {
|
||||
const phone = uniquePhone()
|
||||
await setTestOtpBypassEnabled(true)
|
||||
await addTestOtpBypassEntry({
|
||||
phone, otp: '294857', user_type: 'customer', label: 'Apple Reviewer #1',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.2',
|
||||
})
|
||||
|
||||
const row = await peekOtpRow(otp_request_id)
|
||||
expect(row.is_bypass).toBe(true)
|
||||
expect(row.fazpass_reference).toBeNull()
|
||||
expect(row.code_hash).toMatch(/^\$2[aby]\$/)
|
||||
|
||||
// Verify against the configured static OTP succeeds.
|
||||
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '294857' })
|
||||
expect(result).toEqual({ phone, user_type: 'customer' })
|
||||
})
|
||||
|
||||
it('does not match when user_type differs (same phone for customer + mitra is distinct)', async () => {
|
||||
const phone = uniquePhone()
|
||||
await setTestOtpBypassEnabled(true)
|
||||
await addTestOtpBypassEntry({
|
||||
phone, otp: '111111', user_type: 'mitra', label: 'Internal QA Mitra',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
|
||||
// Customer request to the same phone → falls through to stub.
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.3',
|
||||
})
|
||||
const row = await peekOtpRow(otp_request_id)
|
||||
expect(row.is_bypass).toBe(false)
|
||||
expect(row.fazpass_reference).toMatch(/^stub_/)
|
||||
})
|
||||
|
||||
it('does not match when the entry has expired', async () => {
|
||||
const phone = uniquePhone()
|
||||
await setTestOtpBypassEnabled(true)
|
||||
// addTestOtpBypassEntry refuses past dates, so set a valid future date,
|
||||
// then manually backdate the entry via SQL — emulating "this entry has
|
||||
// been sitting in the list for too long".
|
||||
await addTestOtpBypassEntry({
|
||||
phone, otp: '222222', user_type: 'customer', label: 'Old Reviewer',
|
||||
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
||||
})
|
||||
const sql = db()
|
||||
await sql`
|
||||
UPDATE app_config
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{entries,0,expires_at}',
|
||||
to_jsonb(${new Date(Date.now() - 60_000).toISOString()}::text)
|
||||
)
|
||||
WHERE key = 'test_otp_bypass'
|
||||
`
|
||||
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.4',
|
||||
})
|
||||
const row = await peekOtpRow(otp_request_id)
|
||||
expect(row.is_bypass).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match when the global kill switch is off', async () => {
|
||||
const phone = uniquePhone()
|
||||
await setTestOtpBypassEnabled(true)
|
||||
await addTestOtpBypassEntry({
|
||||
phone, otp: '333333', user_type: 'customer', label: 'Disabled later',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
// Flip the kill switch off — entries remain but no longer match.
|
||||
await setTestOtpBypassEnabled(false)
|
||||
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.5',
|
||||
})
|
||||
const row = await peekOtpRow(otp_request_id)
|
||||
expect(row.is_bypass).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an entry whose plaintext OTP is malformed', async () => {
|
||||
await expect(addTestOtpBypassEntry({
|
||||
phone: '+628999999995', otp: 'abc', user_type: 'customer', label: 'Bad OTP',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||
})
|
||||
|
||||
it('rejects an entry whose expires_at is in the past', async () => {
|
||||
await expect(addTestOtpBypassEntry({
|
||||
phone: '+628999999996', otp: '123456', user_type: 'customer', label: 'Stale',
|
||||
expires_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
})).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||
})
|
||||
|
||||
it('rejects a duplicate (phone, user_type) entry', async () => {
|
||||
const phone = uniquePhone()
|
||||
const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
||||
await addTestOtpBypassEntry({
|
||||
phone, otp: '101010', user_type: 'customer', label: 'First',
|
||||
expires_at: future,
|
||||
})
|
||||
await expect(addTestOtpBypassEntry({
|
||||
phone, otp: '202020', user_type: 'customer', label: 'Second',
|
||||
expires_at: future,
|
||||
})).rejects.toMatchObject({ code: 'DUPLICATE_ENTRY' })
|
||||
})
|
||||
|
||||
// No new tests in this describe — see "Fazpass-live mode" below for the
|
||||
// request/verify integration coverage.
|
||||
|
||||
it('getTestOtpBypass returns the bcrypt hash, not the plaintext OTP', async () => {
|
||||
const phone = uniquePhone()
|
||||
await addTestOtpBypassEntry({
|
||||
phone, otp: '424242', user_type: 'customer', label: 'Apple #1',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
const list = await getTestOtpBypass()
|
||||
expect(list.entries).toHaveLength(1)
|
||||
const entry = list.entries[0]
|
||||
expect(entry.otp_hash).toMatch(/^\$2[aby]\$/)
|
||||
// Defense-in-depth: serialised object must not contain the plaintext anywhere.
|
||||
expect(JSON.stringify(entry)).not.toContain('424242')
|
||||
// And the hash actually matches the plaintext (so verify works downstream).
|
||||
expect(await bcrypt.compare('424242', entry.otp_hash)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('otp.service — Fazpass-live mode (FAZPASS_ENABLED=true)', () => {
|
||||
const setFazpassEnv = () => {
|
||||
vi.stubEnv('FAZPASS_ENABLED', 'true')
|
||||
vi.stubEnv('FAZPASS_BASE_URL', 'https://api.fazpass.example')
|
||||
vi.stubEnv('FAZPASS_MERCHANT_KEY', 'mkey')
|
||||
vi.stubEnv('FAZPASS_GATEWAY_KEY', 'gkey')
|
||||
vi.stubEnv('FAZPASS_TIMEOUT_MS', '5000')
|
||||
}
|
||||
|
||||
const mockFetch = (impl) => vi.spyOn(globalThis, 'fetch').mockImplementation(impl)
|
||||
const jsonResponse = (status, body) => new Response(JSON.stringify(body), {
|
||||
status, headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
setFazpassEnv()
|
||||
await resetDb()
|
||||
await clearBypassConfig()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('requestOtp stores fazpass_reference + leaves code_hash NULL when Fazpass returns success', async () => {
|
||||
mockFetch(async () => jsonResponse(200, {
|
||||
status: true,
|
||||
data: {
|
||||
id: 'fzp-ref-001', otp: 'XXXXXX', otp_length: 6,
|
||||
channel: 'WA_GENERIC_OTP', provider: 'Fazpass', purpose: 'production',
|
||||
},
|
||||
}))
|
||||
|
||||
const phone = uniquePhone()
|
||||
const { otp_request_id, channel_used } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.10', channel: 'whatsapp',
|
||||
})
|
||||
|
||||
expect(channel_used).toBe('whatsapp') // API contract: echoes client-requested channel
|
||||
|
||||
const row = await peekOtpRow(otp_request_id)
|
||||
expect(row.is_bypass).toBe(false)
|
||||
expect(row.fazpass_reference).toBe('fzp-ref-001')
|
||||
expect(row.code_hash).toBeNull()
|
||||
})
|
||||
|
||||
it('requestOtp propagates Fazpass error and does NOT insert a row', async () => {
|
||||
mockFetch(async () => jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' }))
|
||||
|
||||
const phone = uniquePhone()
|
||||
await expect(requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.11', channel: 'whatsapp',
|
||||
})).rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
|
||||
|
||||
const sql = db()
|
||||
const [{ n }] = await sql`SELECT COUNT(*)::int AS n FROM otp_requests WHERE phone = ${phone}`
|
||||
expect(n).toBe(0)
|
||||
})
|
||||
|
||||
it('verifyOtp delegates to Fazpass and succeeds on status:true', async () => {
|
||||
// Sequence: 1st fetch = /request, 2nd fetch = /verify.
|
||||
let call = 0
|
||||
mockFetch(async (url) => {
|
||||
call++
|
||||
if (url.endsWith('/v1/otp/request')) {
|
||||
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-002' } })
|
||||
}
|
||||
if (url.endsWith('/v1/otp/verify')) {
|
||||
return jsonResponse(200, { status: true, code: '2000200' })
|
||||
}
|
||||
throw new Error(`unexpected url ${url}`)
|
||||
})
|
||||
|
||||
const phone = uniquePhone()
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.12', channel: 'whatsapp',
|
||||
})
|
||||
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '424242' })
|
||||
|
||||
expect(result).toEqual({ phone, user_type: 'customer' })
|
||||
expect(call).toBe(2)
|
||||
const used = await peekOtpRow(otp_request_id)
|
||||
expect(used.used_at).not.toBeNull()
|
||||
})
|
||||
|
||||
it('verifyOtp surfaces wrong OTP as CODE_MISMATCH when Fazpass returns status:false', async () => {
|
||||
mockFetch(async (url) => {
|
||||
if (url.endsWith('/v1/otp/request')) {
|
||||
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-003' } })
|
||||
}
|
||||
return jsonResponse(200, { status: false, code: '4000401', message: 'Invalid OTP' })
|
||||
})
|
||||
|
||||
const phone = uniquePhone()
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.13', channel: 'whatsapp',
|
||||
})
|
||||
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '000000' }))
|
||||
.rejects.toMatchObject({ code: 'CODE_MISMATCH', statusCode: 401 })
|
||||
|
||||
// Row stays unused — attempts incremented but not marked.
|
||||
const row = await peekOtpRow(otp_request_id)
|
||||
expect(row.used_at).toBeNull()
|
||||
})
|
||||
|
||||
it('verifyOtp returns OTP_PROVIDER_FAILED 502 on Fazpass outage (distinct from wrong code)', async () => {
|
||||
mockFetch(async (url) => {
|
||||
if (url.endsWith('/v1/otp/request')) {
|
||||
return jsonResponse(200, { status: true, data: { id: 'fzp-ref-004' } })
|
||||
}
|
||||
return jsonResponse(503, { status: false, code: '5030503', message: 'gateway unavailable' })
|
||||
})
|
||||
|
||||
const phone = uniquePhone()
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.14', channel: 'whatsapp',
|
||||
})
|
||||
await expect(verifyOtp({ otpRequestId: otp_request_id, code: '424242' }))
|
||||
.rejects.toMatchObject({ code: 'OTP_PROVIDER_FAILED', statusCode: 502 })
|
||||
})
|
||||
|
||||
it('test-OTP bypass still works even when FAZPASS_ENABLED=true (skips Fazpass entirely)', async () => {
|
||||
const fetchSpy = mockFetch(async () => {
|
||||
throw new Error('Fazpass MUST NOT be called for bypass rows')
|
||||
})
|
||||
|
||||
const phone = uniquePhone()
|
||||
await setTestOtpBypassEnabled(true)
|
||||
await addTestOtpBypassEntry({
|
||||
phone, otp: '999000', user_type: 'customer', label: 'Apple #1',
|
||||
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
})
|
||||
|
||||
const { otp_request_id } = await requestOtp({
|
||||
phone, userType: 'customer', ipAddress: '10.0.0.15', channel: 'whatsapp',
|
||||
})
|
||||
const result = await verifyOtp({ otpRequestId: otp_request_id, code: '999000' })
|
||||
|
||||
expect(result).toEqual({ phone, user_type: 'customer' })
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -23,17 +23,6 @@ const updateMaxCustomersConfig = async (max_customers_per_mitra) => {
|
||||
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 res = await apiClient.get('/internal/config/extension-timeout')
|
||||
return res.data.data
|
||||
@@ -160,6 +149,30 @@ const updateSupportHandles = async (patch) => {
|
||||
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() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['config-anonymity'], queryFn: fetchAnonymityConfig })
|
||||
@@ -179,16 +192,6 @@ export default function SettingsPage() {
|
||||
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
|
||||
const { data: etData, isLoading: etLoading } = useQuery({
|
||||
queryKey: ['config-extension-timeout'],
|
||||
@@ -331,10 +334,21 @@ export default function SettingsPage() {
|
||||
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 (
|
||||
isLoading || maxLoading || ftLoading || etLoading || eeLoading || mpLoading || senLoading ||
|
||||
isLoading || maxLoading || etLoading || eeLoading || mpLoading || senLoading ||
|
||||
pbtLoading || pstLoading || rctLoading || edaLoading ||
|
||||
fsdLoading || ptLoading || shLoading
|
||||
fsdLoading || ptLoading || shLoading || tobLoading
|
||||
) return <div>Loading...</div>
|
||||
|
||||
return (
|
||||
@@ -376,36 +390,6 @@ export default function SettingsPage() {
|
||||
{maxMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||
</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 }}>
|
||||
<h2>Extension Timeout</h2>
|
||||
<p>Waktu tunggu untuk customer memutuskan dan mitra mengkonfirmasi perpanjangan sesi.</p>
|
||||
@@ -645,6 +629,15 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</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 */}
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2>Support Handles (Tanya Admin)</h2>
|
||||
@@ -1107,3 +1100,293 @@ function FirstSessionDiscountSection({ data, mutation, toast, onDismissToast })
|
||||
</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