CI deploy f7ddbcd
Browse files- amanpay/notifications/service.py +53 -13
- api/dependencies.py +5 -2
- api/main.py +2 -0
- api/routers/__init__.py +0 -0
- api/routers/notifications.py +68 -0
- api/routes.py +3 -41
- api/services.py +38 -0
- build_info.json +1 -1
- web/e2e/biometrics.spec.ts +15 -0
- web/src/i18n/ar.ts +1 -0
- web/src/i18n/en.ts +1 -0
- web/src/pages/OobPage.test.tsx +14 -0
- web/src/pages/OobPage.tsx +26 -6
amanpay/notifications/service.py
CHANGED
|
@@ -22,6 +22,7 @@ import hashlib
|
|
| 22 |
import logging
|
| 23 |
import os
|
| 24 |
import secrets
|
|
|
|
| 25 |
from dataclasses import dataclass, field
|
| 26 |
from typing import Dict, List, Optional
|
| 27 |
|
|
@@ -173,6 +174,13 @@ class NotificationService:
|
|
| 173 |
def __init__(self) -> None:
|
| 174 |
self._prefs: Dict[str, _Prefs] = {}
|
| 175 |
self._subs: Dict[str, dict] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
self.console = ConsoleProvider()
|
| 177 |
self.sms = SMSProvider()
|
| 178 |
self.call = CallProvider()
|
|
@@ -228,7 +236,7 @@ class NotificationService:
|
|
| 228 |
# PENDING_TTL bounds the confirmation window (timeout handling).
|
| 229 |
from amanpay.storage.kv import get_kv
|
| 230 |
get_kv().set(f"oob:{cid}", {"code": code, "amount": amount, "merchant": merchant,
|
| 231 |
-
"approved": None, "ts": now},
|
| 232 |
ttl=self.PENDING_TTL, now=now)
|
| 233 |
msg = (f"AmanPay: confirm payment of ${amount:.2f} to {merchant}. "
|
| 234 |
f"Code {code}. If this wasn't you, deny it in the app.")
|
|
@@ -250,19 +258,51 @@ class NotificationService:
|
|
| 250 |
|
| 251 |
def respond(self, confirmation_id: str, approve: bool,
|
| 252 |
code: Optional[str] = None, now: float = 0.0) -> Dict:
|
| 253 |
-
"""Approve/deny a
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
from amanpay.storage.kv import get_kv
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
def is_approved(self, confirmation_id: Optional[str], now: float = 0.0) -> bool:
|
| 268 |
if not confirmation_id:
|
|
|
|
| 22 |
import logging
|
| 23 |
import os
|
| 24 |
import secrets
|
| 25 |
+
import threading
|
| 26 |
from dataclasses import dataclass, field
|
| 27 |
from typing import Dict, List, Optional
|
| 28 |
|
|
|
|
| 174 |
def __init__(self) -> None:
|
| 175 |
self._prefs: Dict[str, _Prefs] = {}
|
| 176 |
self._subs: Dict[str, dict] = {}
|
| 177 |
+
# Serializes the read-check-write in respond() so two concurrent responses
|
| 178 |
+
# (FastAPI runs sync handlers in a threadpool) can't both pass the pending
|
| 179 |
+
# check — first terminal transition wins. For a future shared Redis-backed
|
| 180 |
+
# store this critical section must move to a Lua compare-and-set (cf. the
|
| 181 |
+
# `_CONSUME` script in storage/kv.py); the single respond() entry point keeps
|
| 182 |
+
# that swap localized.
|
| 183 |
+
self._respond_lock = threading.Lock()
|
| 184 |
self.console = ConsoleProvider()
|
| 185 |
self.sms = SMSProvider()
|
| 186 |
self.call = CallProvider()
|
|
|
|
| 236 |
# PENDING_TTL bounds the confirmation window (timeout handling).
|
| 237 |
from amanpay.storage.kv import get_kv
|
| 238 |
get_kv().set(f"oob:{cid}", {"code": code, "amount": amount, "merchant": merchant,
|
| 239 |
+
"approved": None, "finalized_at": None, "ts": now},
|
| 240 |
ttl=self.PENDING_TTL, now=now)
|
| 241 |
msg = (f"AmanPay: confirm payment of ${amount:.2f} to {merchant}. "
|
| 242 |
f"Code {code}. If this wasn't you, deny it in the app.")
|
|
|
|
| 258 |
|
| 259 |
def respond(self, confirmation_id: str, approve: bool,
|
| 260 |
code: Optional[str] = None, now: float = 0.0) -> Dict:
|
| 261 |
+
"""Approve/deny a confirmation, enforcing TERMINAL-STATE IMMUTABILITY.
|
| 262 |
+
|
| 263 |
+
- unknown/expired id -> {success: False, status: "expired"}.
|
| 264 |
+
- already finalized, SAME decision -> idempotent: returns the UNCHANGED final
|
| 265 |
+
state (success: True, idempotent: True) — decision + finalized_at preserved.
|
| 266 |
+
- already finalized, CONFLICTING decision -> {success: False, conflict: True,
|
| 267 |
+
error_code: "oob_already_finalized"} (route maps to HTTP 409); state unchanged.
|
| 268 |
+
- pending -> requires the dynamic-link code, then finalizes (first writer wins).
|
| 269 |
+
|
| 270 |
+
The whole read-check-write runs under a lock so concurrent approve/deny can't
|
| 271 |
+
both succeed. If a code is given it must match (dynamic linking)."""
|
| 272 |
from amanpay.storage.kv import get_kv
|
| 273 |
+
approve = bool(approve)
|
| 274 |
+
key = f"oob:{confirmation_id}"
|
| 275 |
+
with self._respond_lock:
|
| 276 |
+
kv = get_kv()
|
| 277 |
+
pend = kv.get(key, now=now)
|
| 278 |
+
if pend is None:
|
| 279 |
+
return {"success": False, "status": "expired",
|
| 280 |
+
"reason": "unknown or expired confirmation"}
|
| 281 |
+
finalized = pend.get("approved")
|
| 282 |
+
if finalized is not None:
|
| 283 |
+
current = "approved" if finalized else "rejected"
|
| 284 |
+
if approve == finalized:
|
| 285 |
+
# Idempotent retry — return the existing final state, unchanged.
|
| 286 |
+
return {"success": True, "approved": finalized, "status": current,
|
| 287 |
+
"amount": pend["amount"], "merchant": pend["merchant"],
|
| 288 |
+
"finalized_at": pend.get("finalized_at"), "idempotent": True}
|
| 289 |
+
# Conflicting response after a terminal state — reject, do not mutate.
|
| 290 |
+
return {"success": False, "conflict": True,
|
| 291 |
+
"error_code": "oob_already_finalized", "status": current,
|
| 292 |
+
"confirmation_id": confirmation_id,
|
| 293 |
+
"finalized_at": pend.get("finalized_at"),
|
| 294 |
+
"reason": "confirmation already finalized"}
|
| 295 |
+
# Still pending — enforce dynamic linking, then finalize.
|
| 296 |
+
if code is not None and code.upper() != pend["code"]:
|
| 297 |
+
return {"success": False, "status": "pending",
|
| 298 |
+
"reason": "code mismatch (dynamic-link failed)"}
|
| 299 |
+
pend["approved"] = approve
|
| 300 |
+
pend["finalized_at"] = now
|
| 301 |
+
kv.set(key, pend, ttl=self.PENDING_TTL, now=now)
|
| 302 |
+
return {"success": True, "approved": approve,
|
| 303 |
+
"status": "approved" if approve else "rejected",
|
| 304 |
+
"amount": pend["amount"], "merchant": pend["merchant"],
|
| 305 |
+
"finalized_at": now, "idempotent": False}
|
| 306 |
|
| 307 |
def is_approved(self, confirmation_id: Optional[str], now: float = 0.0) -> bool:
|
| 308 |
if not confirmation_id:
|
api/dependencies.py
CHANGED
|
@@ -39,7 +39,7 @@ class ModelState:
|
|
| 39 |
from amanpay.banking.wallet import WalletRegistry
|
| 40 |
from amanpay.banking.risk import RiskEngine
|
| 41 |
from amanpay.banking.geo import GeoRegistry
|
| 42 |
-
from
|
| 43 |
self.passkeys = PasskeyRegistry()
|
| 44 |
self.voice = VoiceRegistry()
|
| 45 |
self.liveness = LivenessRegistry()
|
|
@@ -47,7 +47,10 @@ class ModelState:
|
|
| 47 |
self.wallet = WalletRegistry()
|
| 48 |
self.risk = RiskEngine()
|
| 49 |
self.geo = GeoRegistry()
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 51 |
# Datastore: a real SQL DB (SQLite/Postgres) when DATABASE_URL is set —
|
| 52 |
# per-user rows, audit log, cascade erasure (P1). Otherwise the HF Dataset
|
| 53 |
# store (keeps the zero-config HF Space demo working).
|
|
|
|
| 39 |
from amanpay.banking.wallet import WalletRegistry
|
| 40 |
from amanpay.banking.risk import RiskEngine
|
| 41 |
from amanpay.banking.geo import GeoRegistry
|
| 42 |
+
from api import services
|
| 43 |
self.passkeys = PasskeyRegistry()
|
| 44 |
self.voice = VoiceRegistry()
|
| 45 |
self.liveness = LivenessRegistry()
|
|
|
|
| 47 |
self.wallet = WalletRegistry()
|
| 48 |
self.risk = RiskEngine()
|
| 49 |
self.geo = GeoRegistry()
|
| 50 |
+
# Share the torch-free notification singleton (the /notify router uses the same
|
| 51 |
+
# instance) and wire durable-persistence + audit hooks into it.
|
| 52 |
+
self.notify = services.notifications
|
| 53 |
+
services.wire(persist=self.persist, audit=self.audit)
|
| 54 |
# Datastore: a real SQL DB (SQLite/Postgres) when DATABASE_URL is set —
|
| 55 |
# per-user rows, audit log, cascade erasure (P1). Otherwise the HF Dataset
|
| 56 |
# store (keeps the zero-config HF Space demo working).
|
api/main.py
CHANGED
|
@@ -30,6 +30,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 30 |
from api.dependencies import state
|
| 31 |
from api.observability import (init_sentry, metrics_middleware, metrics_response,
|
| 32 |
record_readiness, setup_logging)
|
|
|
|
| 33 |
from api.routes import router
|
| 34 |
|
| 35 |
logging.basicConfig(level=logging.INFO,
|
|
@@ -110,6 +111,7 @@ def readyz():
|
|
| 110 |
|
| 111 |
|
| 112 |
app.include_router(router)
|
|
|
|
| 113 |
|
| 114 |
|
| 115 |
_ROOT = os.path.dirname(os.path.dirname(__file__))
|
|
|
|
| 30 |
from api.dependencies import state
|
| 31 |
from api.observability import (init_sentry, metrics_middleware, metrics_response,
|
| 32 |
record_readiness, setup_logging)
|
| 33 |
+
from api.routers.notifications import router as notifications_router
|
| 34 |
from api.routes import router
|
| 35 |
|
| 36 |
logging.basicConfig(level=logging.INFO,
|
|
|
|
| 111 |
|
| 112 |
|
| 113 |
app.include_router(router)
|
| 114 |
+
app.include_router(notifications_router) # torch-free /notify/* endpoints
|
| 115 |
|
| 116 |
|
| 117 |
_ROOT = os.path.dirname(os.path.dirname(__file__))
|
api/routers/__init__.py
ADDED
|
File without changes
|
api/routers/notifications.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Notification / out-of-band confirmation endpoints — a torch-free router.
|
| 2 |
+
|
| 3 |
+
Isolated from the monolithic `api.routes` (which imports the biometric models/torch)
|
| 4 |
+
so these endpoints and their API tests run in the lightweight, torch-free environment.
|
| 5 |
+
Paths, methods and request/response contracts are UNCHANGED from the previous
|
| 6 |
+
definitions in `api.routes`."""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Body
|
| 13 |
+
from fastapi.responses import JSONResponse
|
| 14 |
+
|
| 15 |
+
from api.services import audit_event, notifications, persist_prefs
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.post("/notify/prefs")
|
| 21 |
+
def notify_set_prefs(body: dict = Body(...)) -> dict:
|
| 22 |
+
r = notifications.set_prefs(body["user_id"], channels=body.get("channels"),
|
| 23 |
+
email=body.get("email"), phone=body.get("phone"))
|
| 24 |
+
persist_prefs()
|
| 25 |
+
return r
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/notify/prefs")
|
| 29 |
+
def notify_get_prefs(user_id: str) -> dict:
|
| 30 |
+
return notifications.get_prefs(user_id)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.post("/notify/subscribe")
|
| 34 |
+
def notify_subscribe(body: dict = Body(...)) -> dict:
|
| 35 |
+
"""Store a Web Push subscription (from the browser) for the user."""
|
| 36 |
+
return notifications.subscribe_push(body["user_id"], body["subscription"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.post("/notify/request")
|
| 40 |
+
def notify_request(body: dict = Body(...)) -> dict:
|
| 41 |
+
"""Create a demo out-of-band payment confirmation (dynamic-linked to amount+merchant)
|
| 42 |
+
using the existing notification service, so the React OOB demo can exercise the
|
| 43 |
+
request -> respond -> status workflow without going through /wallet/pay."""
|
| 44 |
+
return notifications.send_payment_confirmation(
|
| 45 |
+
body["user_id"], float(body.get("amount", 0.0)),
|
| 46 |
+
str(body.get("merchant", "")), now=time.time())
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/notify/status")
|
| 50 |
+
def notify_status(confirmation_id: str) -> dict:
|
| 51 |
+
"""Lifecycle of an OOB confirmation for UI polling: pending/approved/rejected/expired."""
|
| 52 |
+
return {"confirmation_id": confirmation_id,
|
| 53 |
+
"status": notifications.confirmation_status(confirmation_id, now=time.time())}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/notify/respond")
|
| 57 |
+
def notify_respond(body: dict = Body(...)):
|
| 58 |
+
"""Approve/deny an OOB confirmation. Enforces terminal-state immutability: a
|
| 59 |
+
conflicting response after a terminal state returns HTTP 409 (oob_already_finalized)
|
| 60 |
+
and leaves the final state unchanged; a same-decision retry is idempotent."""
|
| 61 |
+
r = notifications.respond(body["confirmation_id"], bool(body.get("approve", False)),
|
| 62 |
+
code=body.get("code"), now=time.time())
|
| 63 |
+
if r.get("conflict"):
|
| 64 |
+
# Safe security-audit event (no code/secret) for the rejected conflicting transition.
|
| 65 |
+
audit_event("oob", "oob_conflict_rejected",
|
| 66 |
+
{"confirmation_id": r.get("confirmation_id"), "final_state": r.get("status")})
|
| 67 |
+
return JSONResponse(status_code=409, content=r)
|
| 68 |
+
return r
|
api/routes.py
CHANGED
|
@@ -298,47 +298,9 @@ def pad_verify(body: dict = Body(...)) -> dict:
|
|
| 298 |
|
| 299 |
|
| 300 |
# ---- Notification preferences + out-of-band payment confirmation ----
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
email=body.get("email"), phone=body.get("phone"))
|
| 305 |
-
state.persist()
|
| 306 |
-
return r
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
@router.get("/notify/prefs")
|
| 310 |
-
def notify_get_prefs(user_id: str) -> dict:
|
| 311 |
-
return state.notify.get_prefs(user_id)
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
@router.post("/notify/subscribe")
|
| 315 |
-
def notify_subscribe(body: dict = Body(...)) -> dict:
|
| 316 |
-
"""Store a Web Push subscription (from the browser) for the user."""
|
| 317 |
-
return state.notify.subscribe_push(body["user_id"], body["subscription"])
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
@router.post("/notify/request")
|
| 321 |
-
def notify_request(body: dict = Body(...)) -> dict:
|
| 322 |
-
"""Create a demo out-of-band payment confirmation (dynamic-linked to amount+merchant)
|
| 323 |
-
using the existing notification service, so the React OOB demo can exercise the
|
| 324 |
-
request -> respond -> status workflow without going through /wallet/pay."""
|
| 325 |
-
return state.notify.send_payment_confirmation(
|
| 326 |
-
body["user_id"], float(body.get("amount", 0.0)),
|
| 327 |
-
str(body.get("merchant", "")), now=time.time())
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
@router.get("/notify/status")
|
| 331 |
-
def notify_status(confirmation_id: str) -> dict:
|
| 332 |
-
"""Lifecycle of an OOB confirmation for UI polling: pending/approved/rejected/expired."""
|
| 333 |
-
return {"confirmation_id": confirmation_id,
|
| 334 |
-
"status": state.notify.confirmation_status(confirmation_id, now=time.time())}
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
@router.post("/notify/respond")
|
| 338 |
-
def notify_respond(body: dict = Body(...)) -> dict:
|
| 339 |
-
"""User approves/denies a pending out-of-band confirmation (dynamic-linked)."""
|
| 340 |
-
return state.notify.respond(body["confirmation_id"], bool(body.get("approve", False)),
|
| 341 |
-
code=body.get("code"), now=time.time())
|
| 342 |
|
| 343 |
|
| 344 |
# ---- Device-native biometric (platform authenticator: Touch ID / Face ID / fingerprint) ----
|
|
|
|
| 298 |
|
| 299 |
|
| 300 |
# ---- Notification preferences + out-of-band payment confirmation ----
|
| 301 |
+
# Notification / out-of-band confirmation endpoints (/notify/*) live in the torch-free
|
| 302 |
+
# api.routers.notifications router (included by api.main), so they and their API tests
|
| 303 |
+
# stay isolated from the biometric-model imports. Paths/contracts are unchanged.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
|
| 306 |
# ---- Device-native biometric (platform authenticator: Touch ID / Face ID / fingerprint) ----
|
api/services.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Torch-free application services shared between the model-heavy dependency layer
|
| 2 |
+
and the lightweight routers. Importing this module never pulls torch or the biometric
|
| 3 |
+
models, so the notification/OOB router (and its API tests) stay torch-free.
|
| 4 |
+
|
| 5 |
+
The `notifications` singleton IS the instance the full app uses (ModelState.notify
|
| 6 |
+
references it), so production and tests share one service + state. Persistence and
|
| 7 |
+
audit are optional hooks the full app wires at startup; they no-op when unwired
|
| 8 |
+
(e.g. in the light CI test app), matching the demo store's behavior."""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Callable, Optional
|
| 13 |
+
|
| 14 |
+
from amanpay.notifications.service import NotificationService
|
| 15 |
+
|
| 16 |
+
# Single shared notification/OOB service (referenced by ModelState.notify).
|
| 17 |
+
notifications = NotificationService()
|
| 18 |
+
|
| 19 |
+
_persist: Optional[Callable[[], None]] = None
|
| 20 |
+
_audit: Optional[Callable[[str, str, dict], None]] = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def wire(persist: Optional[Callable[[], None]] = None,
|
| 24 |
+
audit: Optional[Callable[[str, str, dict], None]] = None) -> None:
|
| 25 |
+
"""Wire durable-persistence and audit callbacks from the full app (torch side)."""
|
| 26 |
+
global _persist, _audit
|
| 27 |
+
_persist = persist
|
| 28 |
+
_audit = audit
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def persist_prefs() -> None:
|
| 32 |
+
if _persist is not None:
|
| 33 |
+
_persist()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def audit_event(user_id: str, action: str, detail: dict) -> None:
|
| 37 |
+
if _audit is not None:
|
| 38 |
+
_audit(user_id, action, detail)
|
build_info.json
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"commit":"
|
|
|
|
| 1 |
+
{"commit":"f7ddbcd","build_time":"2026-07-12T16:08:09Z","frontend":"1.0.0"}
|
web/e2e/biometrics.spec.ts
CHANGED
|
@@ -71,6 +71,21 @@ test('OOB confirmation: request -> approve -> approved', async ({ page }) => {
|
|
| 71 |
await expect(page.getByText('Approved.')).toBeVisible()
|
| 72 |
})
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
test('report card shows benchmark-unavailable (not a fabricated available)', async ({ page }) => {
|
| 75 |
await page.goto('/#/biometrics/reportcard')
|
| 76 |
await expect(page.getByText('Benchmark data unavailable')).toBeVisible()
|
|
|
|
| 71 |
await expect(page.getByText('Approved.')).toBeVisible()
|
| 72 |
})
|
| 73 |
|
| 74 |
+
test('OOB stale conflicting response keeps the approved state (409 handled)', async ({ page }) => {
|
| 75 |
+
// Simulate the confirmation already finalized elsewhere -> backend returns 409.
|
| 76 |
+
await page.route('**/notify/respond', (r) => r.fulfill({
|
| 77 |
+
status: 409, contentType: 'application/json',
|
| 78 |
+
body: JSON.stringify({ success: false, conflict: true, error_code: 'oob_already_finalized', status: 'approved' }),
|
| 79 |
+
}))
|
| 80 |
+
await page.route('**/notify/status**', (r) => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'approved' }) }))
|
| 81 |
+
await page.goto('/#/biometrics/oob')
|
| 82 |
+
await page.getByRole('button', { name: 'Send confirmation' }).click()
|
| 83 |
+
await page.getByRole('button', { name: 'Approve' }).click()
|
| 84 |
+
await expect(page.getByText('Approved.')).toBeVisible() // refreshed to the real terminal state
|
| 85 |
+
await expect(page.getByText(/already finalized/i)).toBeVisible() // informative, not a failure
|
| 86 |
+
await expect(page.getByText('Rejected.')).toHaveCount(0) // did NOT flip to rejected
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
test('report card shows benchmark-unavailable (not a fabricated available)', async ({ page }) => {
|
| 90 |
await page.goto('/#/biometrics/reportcard')
|
| 91 |
await expect(page.getByText('Benchmark data unavailable')).toBeVisible()
|
web/src/i18n/ar.ts
CHANGED
|
@@ -256,4 +256,5 @@ export const ar: Record<MessageKey, string> = {
|
|
| 256 |
'oob.status.approved': 'تمت الموافقة.',
|
| 257 |
'oob.status.rejected': 'مرفوض.',
|
| 258 |
'oob.status.expired': 'منتهي الصلاحية.',
|
|
|
|
| 259 |
}
|
|
|
|
| 256 |
'oob.status.approved': 'تمت الموافقة.',
|
| 257 |
'oob.status.rejected': 'مرفوض.',
|
| 258 |
'oob.status.expired': 'منتهي الصلاحية.',
|
| 259 |
+
'oob.alreadyFinalized': 'تم إنهاء هذا التأكيد مسبقًا — يتم عرض نتيجته الحالية.',
|
| 260 |
}
|
web/src/i18n/en.ts
CHANGED
|
@@ -254,6 +254,7 @@ export const en = {
|
|
| 254 |
'oob.status.approved': 'Approved.',
|
| 255 |
'oob.status.rejected': 'Rejected.',
|
| 256 |
'oob.status.expired': 'Expired.',
|
|
|
|
| 257 |
} as const
|
| 258 |
|
| 259 |
export type MessageKey = keyof typeof en
|
|
|
|
| 254 |
'oob.status.approved': 'Approved.',
|
| 255 |
'oob.status.rejected': 'Rejected.',
|
| 256 |
'oob.status.expired': 'Expired.',
|
| 257 |
+
'oob.alreadyFinalized': 'This confirmation was already finalized — showing its current result.',
|
| 258 |
} as const
|
| 259 |
|
| 260 |
export type MessageKey = keyof typeof en
|
web/src/pages/OobPage.test.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
| 3 |
import { I18nProvider } from '../i18n'
|
| 4 |
import { OobPage } from './OobPage'
|
| 5 |
import * as oob from '../api/oob'
|
|
|
|
| 6 |
|
| 7 |
vi.mock('../api/oob')
|
| 8 |
|
|
@@ -37,4 +38,17 @@ describe('OobPage', () => {
|
|
| 37 |
await waitFor(() => expect(oob.respondConfirmation).toHaveBeenCalledWith('cnf_1', true, 'AB12CD'))
|
| 38 |
expect(await screen.findByText('Approved.')).toBeInTheDocument()
|
| 39 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
})
|
|
|
|
| 3 |
import { I18nProvider } from '../i18n'
|
| 4 |
import { OobPage } from './OobPage'
|
| 5 |
import * as oob from '../api/oob'
|
| 6 |
+
import { ApiRequestError } from '../api/client'
|
| 7 |
|
| 8 |
vi.mock('../api/oob')
|
| 9 |
|
|
|
|
| 38 |
await waitFor(() => expect(oob.respondConfirmation).toHaveBeenCalledWith('cnf_1', true, 'AB12CD'))
|
| 39 |
expect(await screen.findByText('Approved.')).toBeInTheDocument()
|
| 40 |
})
|
| 41 |
+
|
| 42 |
+
it('handles a 409 already-finalized by refreshing to the terminal state, not a generic error', async () => {
|
| 43 |
+
renderOob()
|
| 44 |
+
fireEvent.click(screen.getByRole('button', { name: 'Send confirmation' }))
|
| 45 |
+
await screen.findByRole('button', { name: 'Approve' })
|
| 46 |
+
// Simulate a stale/conflicting response: backend already finalized -> 409.
|
| 47 |
+
vi.spyOn(oob, 'respondConfirmation').mockRejectedValue(new ApiRequestError({ status: 409, message: 'conflict' }))
|
| 48 |
+
vi.spyOn(oob, 'getConfirmationStatus').mockResolvedValue({ status: 'approved' })
|
| 49 |
+
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
| 50 |
+
expect(await screen.findByText('Approved.')).toBeInTheDocument() // shows the real terminal state
|
| 51 |
+
expect(screen.getByText(/already finalized/i)).toBeInTheDocument() // informative, not a failure
|
| 52 |
+
expect(screen.queryByText(/Something went wrong/i)).toBeNull() // no generic error
|
| 53 |
+
})
|
| 54 |
})
|
web/src/pages/OobPage.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
| 4 |
type OobConfirmation, type OobStatus,
|
| 5 |
} from '../api/oob'
|
| 6 |
import { Button, Callout, Field, Spinner } from '../components/ui'
|
|
|
|
| 7 |
import { localizeError } from '../utils/errors'
|
| 8 |
import { useI18n } from '../i18n'
|
| 9 |
import type { MessageKey } from '../i18n/en'
|
|
@@ -21,6 +22,7 @@ export function OobPage() {
|
|
| 21 |
const [status, setStatus] = useState<OobStatus | null>(null)
|
| 22 |
const [busy, setBusy] = useState(false)
|
| 23 |
const [error, setError] = useState<string | null>(null)
|
|
|
|
| 24 |
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
| 25 |
|
| 26 |
function stopPolling() { if (timer.current) { clearInterval(timer.current); timer.current = null } }
|
|
@@ -28,7 +30,7 @@ export function OobPage() {
|
|
| 28 |
|
| 29 |
async function send() {
|
| 30 |
if (busy) return
|
| 31 |
-
setBusy(true); setError(null); setStatus(null); setConf(null); stopPolling()
|
| 32 |
try {
|
| 33 |
await setNotifyPrefs(userId.trim() || 'alice', channels)
|
| 34 |
const c = await requestConfirmation(userId.trim() || 'alice', Number(amount) || 0, merchant.trim())
|
|
@@ -44,18 +46,35 @@ export function OobPage() {
|
|
| 44 |
} catch (e) { setError(localizeError(e, t, 'error.generic')) } finally { setBusy(false) }
|
| 45 |
}
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
async function respond(approve: boolean) {
|
| 48 |
if (!conf) return
|
| 49 |
-
setBusy(true); setError(null)
|
| 50 |
try {
|
| 51 |
const r = await respondConfirmation(conf.confirmation_id, approve, conf.code)
|
| 52 |
if (!r.success) { setError(t('error.generic')); return }
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
-
function reset() { stopPolling(); setConf(null); setStatus(null); setError(null) }
|
| 59 |
|
| 60 |
const statusTone = status === 'approved' ? 'ok' : status === 'pending' ? 'warn' : 'bad'
|
| 61 |
|
|
@@ -94,6 +113,7 @@ export function OobPage() {
|
|
| 94 |
<dt>{t('oob.code')}</dt><dd className="mono" dir="ltr">{conf.code}</dd>
|
| 95 |
</dl>
|
| 96 |
<p className="hint">{t('oob.dynamicLink')}</p>
|
|
|
|
| 97 |
{status && <Callout tone={statusTone}>{t((`oob.status.${status}`) as MessageKey)}</Callout>}
|
| 98 |
{status === 'pending' && (
|
| 99 |
<div className="row">
|
|
|
|
| 4 |
type OobConfirmation, type OobStatus,
|
| 5 |
} from '../api/oob'
|
| 6 |
import { Button, Callout, Field, Spinner } from '../components/ui'
|
| 7 |
+
import { ApiRequestError } from '../api/client'
|
| 8 |
import { localizeError } from '../utils/errors'
|
| 9 |
import { useI18n } from '../i18n'
|
| 10 |
import type { MessageKey } from '../i18n/en'
|
|
|
|
| 22 |
const [status, setStatus] = useState<OobStatus | null>(null)
|
| 23 |
const [busy, setBusy] = useState(false)
|
| 24 |
const [error, setError] = useState<string | null>(null)
|
| 25 |
+
const [notice, setNotice] = useState<string | null>(null)
|
| 26 |
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
|
| 27 |
|
| 28 |
function stopPolling() { if (timer.current) { clearInterval(timer.current); timer.current = null } }
|
|
|
|
| 30 |
|
| 31 |
async function send() {
|
| 32 |
if (busy) return
|
| 33 |
+
setBusy(true); setError(null); setNotice(null); setStatus(null); setConf(null); stopPolling()
|
| 34 |
try {
|
| 35 |
await setNotifyPrefs(userId.trim() || 'alice', channels)
|
| 36 |
const c = await requestConfirmation(userId.trim() || 'alice', Number(amount) || 0, merchant.trim())
|
|
|
|
| 46 |
} catch (e) { setError(localizeError(e, t, 'error.generic')) } finally { setBusy(false) }
|
| 47 |
}
|
| 48 |
|
| 49 |
+
async function refreshStatus(): Promise<OobStatus | null> {
|
| 50 |
+
if (!conf) return null
|
| 51 |
+
try {
|
| 52 |
+
const s = (await getConfirmationStatus(conf.confirmation_id)).status
|
| 53 |
+
setStatus(s); if (TERMINAL.has(s)) stopPolling()
|
| 54 |
+
return s
|
| 55 |
+
} catch { return null }
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
async function respond(approve: boolean) {
|
| 59 |
if (!conf) return
|
| 60 |
+
setBusy(true); setError(null); setNotice(null)
|
| 61 |
try {
|
| 62 |
const r = await respondConfirmation(conf.confirmation_id, approve, conf.code)
|
| 63 |
if (!r.success) { setError(t('error.generic')); return }
|
| 64 |
+
await refreshStatus()
|
| 65 |
+
} catch (e) {
|
| 66 |
+
// 409 = already finalized (stale page / double click / another tab / late network).
|
| 67 |
+
// Refresh to the real terminal state and show it — NOT a generic failure.
|
| 68 |
+
if (e instanceof ApiRequestError && e.status === 409) {
|
| 69 |
+
setNotice(t('oob.alreadyFinalized'))
|
| 70 |
+
await refreshStatus()
|
| 71 |
+
return
|
| 72 |
+
}
|
| 73 |
+
setError(localizeError(e, t, 'error.generic'))
|
| 74 |
+
} finally { setBusy(false) }
|
| 75 |
}
|
| 76 |
|
| 77 |
+
function reset() { stopPolling(); setConf(null); setStatus(null); setError(null); setNotice(null) }
|
| 78 |
|
| 79 |
const statusTone = status === 'approved' ? 'ok' : status === 'pending' ? 'warn' : 'bad'
|
| 80 |
|
|
|
|
| 113 |
<dt>{t('oob.code')}</dt><dd className="mono" dir="ltr">{conf.code}</dd>
|
| 114 |
</dl>
|
| 115 |
<p className="hint">{t('oob.dynamicLink')}</p>
|
| 116 |
+
{notice && <Callout tone="info">{notice}</Callout>}
|
| 117 |
{status && <Callout tone={statusTone}>{t((`oob.status.${status}`) as MessageKey)}</Callout>}
|
| 118 |
{status === 'pending' && (
|
| 119 |
<div className="row">
|