From affd46c8c4f7baafc001189a4288a2a12cb998a5 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 15:58:21 -0400 Subject: [PATCH 1/2] fix(refunds): verify refund PIN asynchronously outside write transaction (#1227) --- main/routes/refunds.ts | 123 +++++- main/services/refund.ts | 190 ++++++--- package.json | 5 +- tests/async-refund-pin-event-loop.test.ts | 485 ++++++++++++++++++++++ 4 files changed, 742 insertions(+), 61 deletions(-) create mode 100644 tests/async-refund-pin-event-loop.test.ts diff --git a/main/routes/refunds.ts b/main/routes/refunds.ts index f89d9f12e..7594cd8d8 100644 --- a/main/routes/refunds.ts +++ b/main/routes/refunds.ts @@ -1,9 +1,16 @@ import { createHash, randomUUID } from 'crypto'; import { Router, Request, Response } from 'express'; -import { getDatabase, now, withTxn } from '../db'; +import { getDatabase, now, verifyPinAsync, withTxn } from '../db'; import { requirePermission } from '../services/authorization'; +import { ROLE_ACCESS } from '../../shared/role-permissions'; import { checkPinRateLimit, isPinRateLimited, clearPinRateLimit } from './orders'; -import { createRefund, getTenantCurrency, RefundRequest } from '../services/refund'; +import { + createRefund, + getTenantCurrency, + RefundRequest, + validateRefundPreconditions, + PreVerifiedRefundApprover, +} from '../services/refund'; import { getCurrencyFractionDigits, getCurrencyMinorUnitFactor } from '../countries'; import { cloudSync } from '../services/cloud-sync'; import { isOperationReference, serializeOperationFailure } from '../lib/operation-failure'; @@ -12,6 +19,31 @@ const router = Router(); const MAX_IDEMPOTENCY_KEY_LENGTH = 128; const MAX_REASON_LENGTH = 500; +const refundPinLocks = new Map>(); + +export async function withRefundPinLock(key: string, fn: () => Promise): Promise { + const previous = refundPinLocks.get(key) || Promise.resolve(); + let resolveCurrent!: () => void; + const current = new Promise((resolve) => { + resolveCurrent = resolve; + }); + refundPinLocks.set(key, current); + + try { + await previous.catch(() => {}); + return await fn(); + } finally { + resolveCurrent(); + if (refundPinLocks.get(key) === current) { + refundPinLocks.delete(key); + } + } +} + +export function resetRefundPinLocksForTests(): void { + refundPinLocks.clear(); +} + function refundIdempotencyKey(req: Request): string | null { const supplied = req.get('Idempotency-Key')?.trim(); if (!supplied) return null; @@ -103,7 +135,7 @@ function structuredRefundFailure( }; } -router.post('/', requirePermission('refunds.initiate'), (req: Request, res: Response) => { +router.post('/', requirePermission('refunds.initiate'), async (req: Request, res: Response) => { let operationCommitted = false; let transactionBodyCompleted = false; try { @@ -206,6 +238,89 @@ router.post('/', requirePermission('refunds.initiate'), (req: Request, res: Resp const userId = String((req as any).user.userId); const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + // 1. Check idempotency replay outside withTxn + if (idempotencyKey) { + const prior = db + .prepare(` + SELECT bill_id, request_hash, response_json + FROM refund_idempotency + WHERE user_id = ? AND idempotency_key = ? + `) + .get(userId, idempotencyKey) as + | { bill_id: string; request_hash: string; response_json: string } + | undefined; + if (prior) { + if (String(prior.bill_id) !== String(billId) || prior.request_hash !== requestHash) { + throw Object.assign( + new Error('Idempotency-Key was already used for a different refund request'), + { statusCode: 409, diagnosticCode: 'idempotency_conflict' }, + ); + } + return res.status(201).json(JSON.parse(prior.response_json)); + } + } + + // 2. Pre-validate preconditions (business-day window, order item, amounts, session, etc.) before checking PIN + const { lateRefund } = validateRefundPreconditions(db, { + billId, + method: body.method, + amountCents, + orderItemId, + }); + + // 3. Validate PIN presence + const overridePin = body.override_pin; + if (!overridePin) { + throw Object.assign( + new Error( + lateRefund + ? 'Owner PIN required to process a refund on a completed order' + : 'Manager PIN required to process a refund', + ), + { statusCode: 400, diagnosticCode: 'approver_pin_required' }, + ); + } + + // 4. Rate-limited async PIN verification outside withTxn + const rateLimitKey = `pin:${clientIp}:refund`; + let preVerifiedApprover: PreVerifiedRefundApprover | null = null; + + await withRefundPinLock(rateLimitKey, async () => { + if (isPinRateLimited(rateLimitKey)) { + throw Object.assign(new Error('Too many PIN attempts. Try again in 15 minutes.'), { + statusCode: 429, + diagnosticCode: 'pin_rate_limited', + }); + } + + const allowedRoles = lateRefund ? ROLE_ACCESS.owner : ROLE_ACCESS.ownerManager; + const placeholders = allowedRoles.map(() => '?').join(', '); + const candidate = db + .prepare( + `SELECT id, pin_hash, role, is_active FROM users WHERE id = ? AND pin_hash IS NOT NULL AND role IN (${placeholders}) AND is_active = 1`, + ) + .get(selectedApproverId, ...allowedRoles) as any; + + let pinMatches = false; + if (candidate) { + pinMatches = await verifyPinAsync(candidate.pin_hash, overridePin); + } + + if (!pinMatches) { + checkPinRateLimit(rateLimitKey); + throw Object.assign(new Error(lateRefund ? 'Invalid owner PIN' : 'Invalid manager PIN'), { + statusCode: 403, + diagnosticCode: 'approver_pin_invalid', + }); + } + + preVerifiedApprover = { + id: candidate.id, + verifiedPinHash: candidate.pin_hash, + lateRefund, + }; + }); + const refundRequest: RefundRequest = { billId, orderItemId, @@ -222,6 +337,7 @@ router.post('/', requirePermission('refunds.initiate'), (req: Request, res: Resp clearPinRateLimit, idempotencyKey, requestHash, + preVerifiedApprover, }; const result = withTxn(() => { @@ -229,6 +345,7 @@ router.post('/', requirePermission('refunds.initiate'), (req: Request, res: Resp transactionBodyCompleted = true; return refundResult; }); + clearPinRateLimit(rateLimitKey); operationCommitted = true; res.status(201).json(result); } catch (error: any) { diff --git a/main/services/refund.ts b/main/services/refund.ts index 6ff579f04..690d1956b 100644 --- a/main/services/refund.ts +++ b/main/services/refund.ts @@ -41,6 +41,12 @@ export function getTenantCurrency(db?: Database): string { return resolveTenantCurrency(explicit, country || ''); } +export interface PreVerifiedRefundApprover { + id: string; + verifiedPinHash: string; + lateRefund: boolean; +} + export interface RefundRequest { billId: string | number; orderItemId?: number | null; @@ -48,7 +54,7 @@ export interface RefundRequest { method?: string; reason?: string | null; shiftId?: string | null; - overridePin: string; + overridePin?: string; approverId?: string | null; createdByUserId: string; clientIp: string; @@ -57,6 +63,7 @@ export interface RefundRequest { clearPinRateLimit: (key: string) => void; idempotencyKey?: string | null; requestHash?: string; + preVerifiedApprover?: PreVerifiedRefundApprover | null; } export interface RefundResult { @@ -112,37 +119,22 @@ function resolveRefundApprover( return candidate && verifyPin(candidate.pin_hash, overridePin) ? candidate : null; } -/** Validates, authorizes, and persists a refund inside an active transaction. */ -export function createRefund(db: Database, req: RefundRequest): RefundResult { - if (req.idempotencyKey) { - const prior = db - .prepare(` - SELECT bill_id, request_hash, response_json - FROM refund_idempotency - WHERE user_id = ? AND idempotency_key = ? - `) - .get(req.createdByUserId, req.idempotencyKey) as - | { bill_id: string; request_hash: string; response_json: string } - | undefined; - if (prior) { - if (String(prior.bill_id) !== String(req.billId) || prior.request_hash !== req.requestHash) { - throw httpError( - 'Idempotency-Key was already used for a different refund request', - 409, - 'idempotency_conflict', - ); - } - try { - return JSON.parse(prior.response_json); - } catch { - throw httpError('Stored refund response is invalid', 500); - } - } - } - - requireOpenSessionForCashTender(db, [{ method: req.method }]); +export interface ValidatedRefundPreconditions { + lateRefund: boolean; + bill: any; + order: any; + item: any; + amountCents: number; + currency: string; +} - const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(req.billId) as any; +/** Check whether a bill/order is in late refund status or expired. */ +export function checkIsLateRefund( + db: Database, + billId: string | number, + nowMs: number = Date.now(), +): { lateRefund: boolean; bill: any; order: any } { + const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as any; if (!bill) throw httpError('Bill not found', 404, 'bill_unavailable'); const order = db.prepare('SELECT created_at FROM orders WHERE id = ?').get(bill.order_id) as | { created_at: string } @@ -150,17 +142,8 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { if (!order) throw httpError('Order not found', 404, 'order_unavailable'); const orderCreatedAt = parseDbTimestamp(order.created_at).getTime(); if (!Number.isFinite(orderCreatedAt)) throw httpError('Order creation time is invalid', 500); - const nowMs = Date.now(); - // Past the short window, an owner-only PIN is required for the rest of the business day (docs/reference/product-invariants.md). - let lateRefund = false; + if (nowMs - orderCreatedAt > REFUND_WINDOW_MS) { - // Resolves through the country profile when the stored timezone is - // missing or invalid, matching resolveRegionalSnapshot's own contract, - // instead of letting localDateInTimezone()/dayBoundsInTimezone() silently - // fall back to UTC — at a tenant offset from UTC, that can wrongly accept - // or reject a late refund relative to the tenant's actual business-day - // end. Throws RegionalNotConfiguredError (409) only when the country - // itself is unresolvable. const timezone = resolveRegionalSnapshot({ country: getSettingValue('country') ?? undefined, currency: getSettingValue('currency') ?? undefined, @@ -176,8 +159,25 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { 'refund_window_expired', ); } - lateRefund = true; + return { lateRefund: true, bill, order }; } + return { lateRefund: false, bill, order }; +} + +/** Validates refund preconditions before PIN verification and write transaction. */ +export function validateRefundPreconditions( + db: Database, + req: { + billId: string | number; + method?: string; + amountCents?: number; + orderItemId?: number | null; + }, + nowMs: number = Date.now(), +): ValidatedRefundPreconditions { + requireOpenSessionForCashTender(db, [{ method: req.method }]); + + const { lateRefund, bill, order } = checkIsLateRefund(db, req.billId, nowMs); const currency = getTenantCurrency(db); const minorFactor = getCurrencyMinorUnitFactor(currency); @@ -253,29 +253,107 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { 'amount_exceeds_refundable', ); - if (!req.overridePin) { - throw httpError( - lateRefund - ? 'Owner PIN required to process a refund on a completed order' - : 'Manager PIN required to process a refund', - 400, - 'approver_pin_required', - ); + return { lateRefund, bill, order, item, amountCents, currency }; +} + +/** Validates, authorizes, and persists a refund inside an active transaction. */ +export function createRefund(db: Database, req: RefundRequest): RefundResult { + if (req.idempotencyKey) { + const prior = db + .prepare(` + SELECT bill_id, request_hash, response_json + FROM refund_idempotency + WHERE user_id = ? AND idempotency_key = ? + `) + .get(req.createdByUserId, req.idempotencyKey) as + | { bill_id: string; request_hash: string; response_json: string } + | undefined; + if (prior) { + if (String(prior.bill_id) !== String(req.billId) || prior.request_hash !== req.requestHash) { + throw httpError( + 'Idempotency-Key was already used for a different refund request', + 409, + 'idempotency_conflict', + ); + } + try { + return JSON.parse(prior.response_json); + } catch { + throw httpError('Stored refund response is invalid', 500); + } + } } - const rateLimitKey = `pin:${req.clientIp}:refund`; - if (req.isPinRateLimited(rateLimitKey)) { - throw httpError('Too many PIN attempts. Try again in 15 minutes.', 429, 'pin_rate_limited'); + + const nowMs = Date.now(); + const { lateRefund, bill, order, item, amountCents, currency } = validateRefundPreconditions( + db, + req, + nowMs, + ); + const minorFactor = getCurrencyMinorUnitFactor(currency); + const isStoreCreditRefund = req.method === 'wallet'; + const { paidCents, refundedCents } = getRefundableBalance(db, req.billId, currency); + + let approver: { id: string } | null = null; + if (req.preVerifiedApprover) { + if (req.preVerifiedApprover.id !== req.approverId) { + throw httpError('Pre-verified approver mismatch', 403, 'approver_pin_invalid'); + } + if (lateRefund && !req.preVerifiedApprover.lateRefund) { + throw httpError( + 'Owner PIN required to process a refund on a completed order', + 403, + 'approver_pin_invalid', + ); + } + const allowedRoles = lateRefund ? ROLE_ACCESS.owner : ROLE_ACCESS.ownerManager; + const placeholders = allowedRoles.map(() => '?').join(', '); + const candidate = db + .prepare( + `SELECT id, pin_hash, role, is_active FROM users WHERE id = ? AND pin_hash IS NOT NULL AND role IN (${placeholders}) AND is_active = 1`, + ) + .get(req.preVerifiedApprover.id, ...allowedRoles) as any; + if (!candidate || candidate.pin_hash !== req.preVerifiedApprover.verifiedPinHash) { + throw httpError( + lateRefund ? 'Invalid owner PIN' : 'Invalid manager PIN', + 403, + 'approver_pin_invalid', + ); + } + approver = candidate; + } else { + if (!req.overridePin) { + throw httpError( + lateRefund + ? 'Owner PIN required to process a refund on a completed order' + : 'Manager PIN required to process a refund', + 400, + 'approver_pin_required', + ); + } + const rateLimitKey = `pin:${req.clientIp}:refund`; + if (req.isPinRateLimited(rateLimitKey)) { + throw httpError('Too many PIN attempts. Try again in 15 minutes.', 429, 'pin_rate_limited'); + } + approver = resolveRefundApprover(db, req.overridePin, req.approverId, lateRefund); + if (!approver) { + req.checkPinRateLimit(rateLimitKey); + throw httpError( + lateRefund ? 'Invalid owner PIN' : 'Invalid manager PIN', + 403, + 'approver_pin_invalid', + ); + } + req.clearPinRateLimit(rateLimitKey); } - const approver = resolveRefundApprover(db, req.overridePin, req.approverId, lateRefund); + if (!approver) { - req.checkPinRateLimit(rateLimitKey); throw httpError( lateRefund ? 'Invalid owner PIN' : 'Invalid manager PIN', 403, 'approver_pin_invalid', ); } - req.clearPinRateLimit(rateLimitKey); const timestamp = now(); diff --git a/package.json b/package.json index 2c6fdc787..714493f4f 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "pretest": "npm run test:script-coverage && bash tests/run-test.sh npm run test:expenses && bash tests/run-test.sh npm run test:payment-methods-split && bash tests/run-test.sh npm run test:release-regressions && bash tests/run-test.sh npm run test:diagnostics && bash tests/run-test.sh npm run test:reports-export && bash tests/run-test.sh npm run test:category-addon-groups && bash tests/run-test.sh npm run test:product-variants-db && bash tests/run-test.sh npm run test:addon-inventory-db && bash tests/run-test.sh npm run test:addon-inventory-api && bash tests/run-test.sh npm run test:product-variants-api", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", - "test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:update-ipc-sender-validation && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:pos-keyboard-shortcuts && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:order-item-edit && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:desktop-trailing-slash-routing && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:customer-top-items && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:cloud-live-orders-batching && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-order-hold-payment && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:customer-display-config && bash tests/run-test.sh npm run test:customer-display-settings-route && bash tests/run-test.sh npm run test:customer-display-pairing && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle && bash tests/run-test.sh npm run test:lint-engine && bash tests/run-test.sh npm run test:oxc-contributor-checks", + "test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:update-ipc-sender-validation && bash tests/run-test.sh npm run test:menu-surface && bash tests/run-test.sh npm run test:pos-keyboard-shortcuts && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-state && bash tests/run-test.sh npm run test:update-ui && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:telemetry-install-provenance && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:phase7-setup-i18n && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:jwt-secret-cache-identity && bash tests/run-test.sh npm run test:jwt-secret-persist-before-cache && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:order-item-cancel-restore && bash tests/run-test.sh npm run test:order-item-edit && bash tests/run-test.sh npm run test:product-variants-orders && bash tests/run-test.sh npm run test:addon-inventory-pos-surface && bash tests/run-test.sh npm run test:addon-inventory-orders && bash tests/run-test.sh npm run test:addon-inventory-lifecycle && bash tests/run-test.sh npm run test:order-totals-recompute && bash tests/run-test.sh npm run test:issue-718-whatsapp-order-status && bash tests/run-test.sh npm run test:order-audit-log && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:authorization-permissions && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:desktop-trailing-slash-routing && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-soft-reactivate-bindings && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:customer-top-items && bash tests/run-test.sh npm run test:server-app-server-role && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:issue-278-fail-closed-db && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:cloud-live-orders-batching && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printing-settings && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:delivery-slip && bash tests/run-test.sh npm run test:delivery-address && bash tests/run-test.sh npm run test:thermal-capabilities && bash tests/run-test.sh npm run test:raster && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:menu-printing && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:e2e-argentina-flow && bash tests/run-test.sh npm run test:charges && bash tests/run-test.sh npm run test:charges-engine && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:orders-search && bash tests/run-test.sh npm run test:orders-layout-settings && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:receipt-column-oracle && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:async-refund-pin-event-loop && bash tests/run-test.sh npm run test:refund-completed-orders && bash tests/run-test.sh npm run test:cash-closures && bash tests/run-test.sh npm run test:cash-sessions && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && bash tests/run-test.sh npm run test:business-date && bash tests/run-test.sh npm run test:dashboard-business-date && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:order-numbering-settings && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-order-hold-payment && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:kitchen-delivery-enforcement && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:issue-255 && bash tests/run-test.sh npm run test:cart-variant-identity && bash tests/run-test.sh npm run test:product-variants-screen && bash tests/run-test.sh npm run test:variant-pos-surface && bash tests/run-test.sh npm run test:issue-788-order-attempt-storage && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-245-246-addon-groups && bash tests/run-test.sh npm run test:issue-247-catalog-invariants && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-252 && bash tests/run-test.sh npm run test:inventory-ledger && bash tests/run-test.sh npm run test:issue-printing-and-kds-resilience && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:issue-646-floor-management && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:product-images && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:migration-registry && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626 && bash tests/run-test.sh npm run test:issue-zero-balance-settlement && bash tests/run-test.sh npm run test:daily-sales-export && bash tests/run-test.sh npm run test:issue-terminal-state-guards && bash tests/run-test.sh npm run test:issue-payload-and-discount-bounds && bash tests/run-test.sh npm run test:customer-display-config && bash tests/run-test.sh npm run test:customer-display-settings-route && bash tests/run-test.sh npm run test:customer-display-pairing && bash tests/run-test.sh npm run test:units && bash tests/run-test.sh npm run test:supplies-service && bash tests/run-test.sh npm run test:recipe-order-lifecycle && bash tests/run-test.sh npm run test:lint-engine && bash tests/run-test.sh npm run test:oxc-contributor-checks", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", "test:script-coverage": "node scripts/ci/validate-test-script-coverage.cjs && node tests/test-script-coverage.test.cjs && ts-node --transpile-only -P tests/tsconfig.json tests/test-harness-assertions.test.ts", "test:lint-engine": "ts-node --transpile-only -P tests/tsconfig.json tests/lint-engine-parity.test.ts", @@ -143,7 +143,8 @@ "test:raster": "ts-node --transpile-only -P tests/tsconfig.json tests/raster-printing.test.ts && npm run test:raster-electron", "test:raster-electron": "npm run build && electron --no-sandbox tests/raster-renderer-electron.test.cjs", "test:cancel-override": "node tests/run-electron-node-test.cjs tests/cancel-override.test.ts && node tests/run-electron-node-test.cjs tests/manager-pin-rate-limit-bypass.test.ts", - "test:refunds": "node tests/run-electron-node-test.cjs tests/refunds.test.ts", + "test:refunds": "node tests/run-electron-node-test.cjs tests/refunds.test.ts && node tests/run-electron-node-test.cjs tests/async-refund-pin-event-loop.test.ts", + "test:async-refund-pin-event-loop": "node tests/run-electron-node-test.cjs tests/async-refund-pin-event-loop.test.ts", "test:refund-completed-orders": "node tests/run-electron-node-test.cjs tests/refund-completed-orders.test.ts", "test:cash-closures": "node tests/run-electron-node-test.cjs tests/cash-closures.test.ts && node tests/run-electron-node-test.cjs tests/reports-whatsapp.test.ts", "test:cash-sessions": "node tests/run-electron-node-test.cjs tests/cash-sessions.test.ts", diff --git a/tests/async-refund-pin-event-loop.test.ts b/tests/async-refund-pin-event-loop.test.ts new file mode 100644 index 000000000..c72d683ba --- /dev/null +++ b/tests/async-refund-pin-event-loop.test.ts @@ -0,0 +1,485 @@ +/** + * Test suite for Issue #1227: Asynchronous Refund PIN Verification & Event Loop Responsiveness + * + * Verifies that: + * 1. Synchronous verifyPin blocks the event loop (0 ticks), while verifyPinAsync yields + * to the event loop (ticks > 0). + * 2. POST /api/refunds performs asynchronous PIN verification outside withTxn, yielding + * to the Node.js event loop for both valid and invalid PINs. + * 3. Late refund authorization (order > 1 hour ago) verifies owner PIN asynchronously outside withTxn. + * 4. Concurrent SQLite write transactions proceed freely without transaction lock contention + * during asynchronous refund PIN evaluation. + * 5. Atomic SQLite revalidation inside withTxn prevents race conditions: + * - PIN hash changed between pre-check and commit throws 403. + * - Approver deactivated between pre-check and commit throws 403. + * - Approver demoted between pre-check and commit throws 403. + * - Regular refund transitioned to late refund between pre-check and commit throws 403 for non-owner. + * 6. withRefundPinLock serializes concurrent invalid PIN attempts and enforces rate limiting (429). + * 7. Idempotency replays return the stored response outside withTxn without re-evaluating PINs. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const Module = require('module'); +const originalLoad = Module._load; +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-async-refund-pin-')); + +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { + app: { + isPackaged: true, + getPath: () => testDir, + getVersion: () => 'test', + }, + }; + } + return originalLoad.apply(this, arguments as any); +}; + +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const request = require('supertest'); +const { + initTestDb, + createApp, + assertEqualOrThrow, + assertOrThrow, + assertGreaterThanOrThrow, + getResults, + closeDatabase, + now, +} = require('./helpers/test-setup'); + +const { refundRoutes, resetRefundPinLocksForTests } = require('../main/routes/refunds'); +const { resetPinRateLimitForTests: resetOrderPinRateLimit } = require('../main/routes/orders'); +const { getJWTSecret } = require('../main/routes/auth'); +const { verifyPin, verifyPinAsync, withTxn } = require('../main/db'); +const { createRefund } = require('../main/services/refund'); + +function makeToken(id: string, role: string, email: string) { + const token = jwt.sign({ userId: id, email, role }, getJWTSecret(), { expiresIn: '1h' }); + return { Authorization: `Bearer ${token}` }; +} + +async function run() { + console.log('Testing Asynchronous Refund PIN Verification & Event Loop (Issue #1227)...'); + console.log('='.repeat(70)); + + const db = initTestDb(); + + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + + // Create accounts + const managerPin = '1234'; + const managerHash = bcrypt.hashSync(managerPin, 8); + const managerId = 'mgr-async-1'; + db.prepare(` + INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) + VALUES (?, 'Manager 1', 'mgr1@test.com', 'dummy', 'manager', ?, 1, ?, ?) + `).run(managerId, managerHash, now(), now()); + + const ownerPin = '8888'; + const ownerHash = bcrypt.hashSync(ownerPin, 8); + const ownerId = 'owner-async-1'; + db.prepare(` + INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) + VALUES (?, 'Owner 1', 'owner1@test.com', 'dummy', 'owner', ?, 1, ?, ?) + `).run(ownerId, ownerHash, now(), now()); + + const cashierId = 'cashier-async-1'; + db.prepare(` + INSERT INTO users (id, name, email, password, role, pin_hash, is_active, created_at, updated_at) + VALUES (?, 'Cashier 1', 'cashier1@test.com', 'dummy', 'cashier', NULL, 1, ?, ?) + `).run(cashierId, now(), now()); + + // Ensure cash session is open for cash refunds + db.prepare(` + INSERT INTO cash_sessions (opened_by, opened_at, opening_float_cents, status) + VALUES (?, ?, 10000, 'open') + `).run(ownerId, now()); + + const ownerAuth = makeToken(ownerId, 'owner', 'owner1@test.com'); + const app = createApp({ '/api/refunds': refundRoutes }); + + // Helper to create a paid bill fixture + function createPaidBill(options: { total?: number; createdAt?: string } = {}) { + const total = options.total ?? 100; + const createdAt = options.createdAt ?? now(); + const orderRes = db + .prepare(` + INSERT INTO orders (order_number, type, subtotal, tax_amount, total, status, created_at, updated_at) + VALUES (?, 'dine_in', ?, 0, ?, 'completed', ?, ?) + `) + .run(`ORD-REF-${Date.now()}-${Math.random()}`, total, total, createdAt, createdAt); + const orderId = orderRes.lastInsertRowid; + + const billRes = db + .prepare(` + INSERT INTO bills (order_id, bill_number, subtotal, tax_amount, total, paid_amount, payment_status, created_at, updated_at) + VALUES (?, ?, ?, 0, ?, ?, 'paid', ?, ?) + `) + .run( + orderId, + `BILL-REF-${Date.now()}-${Math.random()}`, + total, + total, + total, + createdAt, + createdAt, + ); + const billId = billRes.lastInsertRowid; + + return { orderId, billId, total }; + } + + // Monitor event loop ticks during an async action + async function executeWithTickTracker( + action: () => Promise, + ): Promise<{ result: T; ticks: number }> { + let ticks = 0; + const timer = setInterval(() => { + ticks++; + }, 1); + try { + const result = await action(); + return { result, ticks }; + } finally { + clearInterval(timer); + } + } + + // ─── 1. Event Loop Yielding on Valid and Invalid Refund PINs ────────────── + console.log('\n--- 1. Event Loop Yielding on Refund PIN Verification ---'); + { + // A. Valid manager PIN + const fixture1 = createPaidBill(); + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + + const { result: resValid, ticks: ticksValid } = await executeWithTickTracker(() => + request(app).post('/api/refunds').set(ownerAuth).send({ + bill_id: fixture1.billId, + amount: fixture1.total, + method: 'cash', + approver_id: managerId, + override_pin: managerPin, + }), + ); + assertEqualOrThrow(resValid.status, 201, 'Valid refund request succeeds with 201'); + assertGreaterThanOrThrow( + ticksValid, + 0, + `Event loop yielded during valid refund PIN verification (${ticksValid} ticks observed)`, + ); + + // B. Invalid manager PIN + const fixture2 = createPaidBill(); + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + + const { result: resInvalid, ticks: ticksInvalid } = await executeWithTickTracker(() => + request(app).post('/api/refunds').set(ownerAuth).send({ + bill_id: fixture2.billId, + amount: fixture2.total, + method: 'cash', + approver_id: managerId, + override_pin: '9999', + }), + ); + assertEqualOrThrow(resInvalid.status, 403, 'Invalid PIN refund request rejected with 403'); + assertEqualOrThrow( + resInvalid.body.display_code, + 'approver_pin_invalid', + 'Diagnostic display code is approver_pin_invalid', + ); + assertGreaterThanOrThrow( + ticksInvalid, + 0, + `Event loop yielded during invalid refund PIN verification (${ticksInvalid} ticks observed)`, + ); + } + + // ─── 2. Late Refund PIN Verification (Owner-Only) ───────────────────────── + console.log('\n--- 2. Late Refund PIN Verification ---'); + { + // Freeze the clock so business-day fixtures cannot flake near a day boundary + const originalDateNow = Date.now; + const FIXED_NOW = Date.UTC(2025, 5, 15, 12, 0, 0); + Date.now = () => FIXED_NOW; + + try { + // Order created 1.5 hours ago (past 1-hour in-progress window, same business day) + const lateFixture = createPaidBill({ createdAt: '2025-06-15 10:30:00' }); + + // Manager PIN on late refund -> rejected with 403 + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + const resMgr = await request(app).post('/api/refunds').set(ownerAuth).send({ + bill_id: lateFixture.billId, + amount: lateFixture.total, + method: 'cash', + approver_id: managerId, + override_pin: managerPin, + }); + assertEqualOrThrow(resMgr.status, 403, 'Manager PIN cannot authorize late refund'); + + // Owner PIN on late refund -> succeeds with 201 and yields event loop + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + const { result: resOwner, ticks: ticksOwner } = await executeWithTickTracker(() => + request(app).post('/api/refunds').set(ownerAuth).send({ + bill_id: lateFixture.billId, + amount: lateFixture.total, + method: 'cash', + approver_id: ownerId, + override_pin: ownerPin, + }), + ); + assertEqualOrThrow(resOwner.status, 201, 'Owner PIN authorizes late refund'); + assertGreaterThanOrThrow( + ticksOwner, + 0, + `Event loop yielded during late refund owner PIN verification (${ticksOwner} ticks observed)`, + ); + } finally { + Date.now = originalDateNow; + } + } + + // ─── 3. Non-Blocking Concurrent SQLite Write Operations ─────────────────── + console.log('\n--- 3. Non-Blocking Concurrent SQLite Writes During PIN Verification ---'); + { + const fixtureConcurrent = createPaidBill(); + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + + // Launch refund request in background + const refundPromise = request(app).post('/api/refunds').set(ownerAuth).send({ + bill_id: fixtureConcurrent.billId, + amount: fixtureConcurrent.total, + method: 'cash', + approver_id: managerId, + override_pin: managerPin, + }); + + // While refund PIN is computing bcrypt outside withTxn, concurrent WAL write transactions execute + let writeCount = 0; + for (let i = 0; i < 5; i++) { + withTxn(() => { + db.prepare(` + INSERT INTO order_audit_log (order_id, actor_user_id, action, details_json, created_at) + VALUES (?, 'cashier-async-1', 'concurrent_write', ?, ?) + `).run(fixtureConcurrent.orderId, JSON.stringify({ iter: i }), now()); + }); + writeCount++; + } + + const refundRes = await refundPromise; + assertEqualOrThrow(refundRes.status, 201, 'Refund completed successfully'); + assertEqualOrThrow( + writeCount, + 5, + '5 concurrent write transactions committed during async refund PIN evaluation', + ); + } + + // ─── 4. Transactional Revalidation Race Condition Safeguards ────────────── + console.log('\n--- 4. Transactional Revalidation Safeguards ---'); + { + const fixtureRace = createPaidBill(); + + // A. Approver PIN hash changed between pre-verification and commit + let threwExpected = false; + try { + withTxn(() => { + createRefund(db, { + billId: fixtureRace.billId, + amountCents: fixtureRace.total * 100, + method: 'cash', + approverId: managerId, + createdByUserId: ownerId, + clientIp: '127.0.0.1', + checkPinRateLimit: () => true, + isPinRateLimited: () => false, + clearPinRateLimit: () => {}, + preVerifiedApprover: { + id: managerId, + verifiedPinHash: 'stale-hash-value', + lateRefund: false, + }, + }); + }); + } catch (e: any) { + if (e.statusCode === 403) threwExpected = true; + } + assertOrThrow(threwExpected, 'Stale PIN hash between pre-check and commit rejected with 403'); + + // B. Approver deactivated between pre-verification and commit + db.prepare('UPDATE users SET is_active = 0 WHERE id = ?').run(managerId); + threwExpected = false; + try { + withTxn(() => { + createRefund(db, { + billId: fixtureRace.billId, + amountCents: fixtureRace.total * 100, + method: 'cash', + approverId: managerId, + createdByUserId: ownerId, + clientIp: '127.0.0.1', + checkPinRateLimit: () => true, + isPinRateLimited: () => false, + clearPinRateLimit: () => {}, + preVerifiedApprover: { + id: managerId, + verifiedPinHash: managerHash, + lateRefund: false, + }, + }); + }); + } catch (e: any) { + if (e.statusCode === 403) threwExpected = true; + } + assertOrThrow( + threwExpected, + 'Deactivated approver between pre-check and commit rejected with 403', + ); + db.prepare('UPDATE users SET is_active = 1 WHERE id = ?').run(managerId); + + // C. Approver demoted between pre-verification and commit + db.prepare("UPDATE users SET role = 'cashier' WHERE id = ?").run(managerId); + threwExpected = false; + try { + withTxn(() => { + createRefund(db, { + billId: fixtureRace.billId, + amountCents: fixtureRace.total * 100, + method: 'cash', + approverId: managerId, + createdByUserId: ownerId, + clientIp: '127.0.0.1', + checkPinRateLimit: () => true, + isPinRateLimited: () => false, + clearPinRateLimit: () => {}, + preVerifiedApprover: { + id: managerId, + verifiedPinHash: managerHash, + lateRefund: false, + }, + }); + }); + } catch (e: any) { + if (e.statusCode === 403) threwExpected = true; + } + assertOrThrow(threwExpected, 'Demoted approver between pre-check and commit rejected with 403'); + db.prepare("UPDATE users SET role = 'manager' WHERE id = ?").run(managerId); + + // D. Approver ID mismatch + threwExpected = false; + try { + withTxn(() => { + createRefund(db, { + billId: fixtureRace.billId, + amountCents: fixtureRace.total * 100, + method: 'cash', + approverId: managerId, + createdByUserId: ownerId, + clientIp: '127.0.0.1', + checkPinRateLimit: () => true, + isPinRateLimited: () => false, + clearPinRateLimit: () => {}, + preVerifiedApprover: { + id: ownerId, + verifiedPinHash: ownerHash, + lateRefund: false, + }, + }); + }); + } catch (e: any) { + if (e.statusCode === 403) threwExpected = true; + } + assertOrThrow(threwExpected, 'Approver ID mismatch rejected with 403'); + } + + // ─── 5. Concurrent Invalid Burst & Rate Limiting ────────────────────────── + console.log('\n--- 5. Concurrent Burst & Rate Limiting ---'); + { + const fixtureBurst = createPaidBill(); + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + + // Send 10 concurrent requests with invalid PIN + const burstPromises = Array.from({ length: 10 }, () => + request(app).post('/api/refunds').set(ownerAuth).send({ + bill_id: fixtureBurst.billId, + amount: fixtureBurst.total, + method: 'cash', + approver_id: managerId, + override_pin: '0000', + }), + ); + + const burstResponses = await Promise.all(burstPromises); + const count403 = burstResponses.filter((r) => r.status === 403).length; + const count429 = burstResponses.filter((r) => r.status === 429).length; + + assertEqualOrThrow(count403, 5, 'Exactly 5 attempts evaluated before rate limit lockout'); + assertEqualOrThrow(count429, 5, 'Remaining 5 concurrent requests locked out with 429'); + } + + // ─── 6. Idempotency Replay Outside Transaction ──────────────────────────── + console.log('\n--- 6. Idempotency Replay Outside Transaction ---'); + { + const fixtureIdem = createPaidBill(); + resetRefundPinLocksForTests(); + resetOrderPinRateLimit(); + + const idempotencyKey = `refund-idem-test-${Date.now()}`; + const payload = { + bill_id: fixtureIdem.billId, + amount: fixtureIdem.total, + method: 'cash', + approver_id: managerId, + override_pin: managerPin, + }; + + const firstRes = await request(app) + .post('/api/refunds') + .set({ ...ownerAuth, 'Idempotency-Key': idempotencyKey }) + .send(payload); + assertEqualOrThrow(firstRes.status, 201, 'First idempotent refund succeeds'); + + // Replay with matching key & body: returns stored response even if PIN is missing or invalid! + const replayRes = await request(app) + .post('/api/refunds') + .set({ ...ownerAuth, 'Idempotency-Key': idempotencyKey }) + .send({ ...payload, override_pin: 'wrong-pin' }); + assertEqualOrThrow( + replayRes.status, + 201, + 'Idempotent replay returns 201 without re-evaluating PIN', + ); + assertEqualOrThrow( + replayRes.body.refund.id, + firstRes.body.refund.id, + 'Replay returns exact same refund ID', + ); + } + + console.log('\nAll Asynchronous Refund PIN tests passed successfully!'); + closeDatabase(); +} + +run() + .then(() => { + const results = getResults(); + console.log(`\nResults: ${results.passed} passed, ${results.failed} failed`); + process.exit(results.failed > 0 ? 1 : 0); + }) + .catch((err) => { + console.error('\nTest failed with error:', err); + process.exit(1); + }); From 89f0195e7980eee75810d19c6209b61390df5c99 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sun, 18 Oct 2026 16:12:48 -0400 Subject: [PATCH 2/2] fix(refunds): clean up unused variables and type annotations to satisfy lint budget --- main/routes/refunds.ts | 14 +++++-- main/services/refund.ts | 81 +++++++++++++++++++++++++++++------------ 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/main/routes/refunds.ts b/main/routes/refunds.ts index 7594cd8d8..791f2c9aa 100644 --- a/main/routes/refunds.ts +++ b/main/routes/refunds.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'crypto'; import { Router, Request, Response } from 'express'; -import { getDatabase, now, verifyPinAsync, withTxn } from '../db'; +import { getDatabase, verifyPinAsync, withTxn } from '../db'; import { requirePermission } from '../services/authorization'; import { ROLE_ACCESS } from '../../shared/role-permissions'; import { checkPinRateLimit, isPinRateLimited, clearPinRateLimit } from './orders'; @@ -56,7 +56,11 @@ function refundIdempotencyKey(req: Request): string | null { return supplied; } -function refundRequestHash(billId: string, body: any, approverId: string): string { +function refundRequestHash( + billId: string, + body: Record, + approverId: string, +): string { return createHash('sha256') .update( JSON.stringify({ @@ -299,14 +303,16 @@ router.post('/', requirePermission('refunds.initiate'), async (req: Request, res .prepare( `SELECT id, pin_hash, role, is_active FROM users WHERE id = ? AND pin_hash IS NOT NULL AND role IN (${placeholders}) AND is_active = 1`, ) - .get(selectedApproverId, ...allowedRoles) as any; + .get(selectedApproverId, ...allowedRoles) as + | { id: string; pin_hash: string; role: string; is_active: number } + | undefined; let pinMatches = false; if (candidate) { pinMatches = await verifyPinAsync(candidate.pin_hash, overridePin); } - if (!pinMatches) { + if (!candidate || !pinMatches) { checkPinRateLimit(rateLimitKey); throw Object.assign(new Error(lateRefund ? 'Invalid owner PIN' : 'Invalid manager PIN'), { statusCode: 403, diff --git a/main/services/refund.ts b/main/services/refund.ts index 690d1956b..25664817e 100644 --- a/main/services/refund.ts +++ b/main/services/refund.ts @@ -119,11 +119,41 @@ function resolveRefundApprover( return candidate && verifyPin(candidate.pin_hash, overridePin) ? candidate : null; } +export interface RefundBillRecord { + id: string | number; + order_id: string | number; + customer_id?: string | null; + bill_number?: string | null; + split_group_id?: string | null; + payment_status?: string | null; + [key: string]: unknown; +} + +export interface RefundOrderItemRecord { + id: string | number; + order_id: string | number; + product_id?: string | number | null; + product_name?: string | null; + product_sku?: string | null; + unit_price: number; + quantity: number; + subtotal: number; + tax_amount?: number | null; + tax_breakdown?: string | null; + tax_snapshot?: string | null; + tax_type?: string | null; + discount_amount?: number | null; + total: number; + variant_selection?: string | null; + modifier_selection?: string | null; + status: string; + [key: string]: unknown; +} + export interface ValidatedRefundPreconditions { lateRefund: boolean; - bill: any; - order: any; - item: any; + bill: RefundBillRecord; + item: RefundOrderItemRecord | null; amountCents: number; currency: string; } @@ -133,12 +163,14 @@ export function checkIsLateRefund( db: Database, billId: string | number, nowMs: number = Date.now(), -): { lateRefund: boolean; bill: any; order: any } { - const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as any; - if (!bill) throw httpError('Bill not found', 404, 'bill_unavailable'); - const order = db.prepare('SELECT created_at FROM orders WHERE id = ?').get(bill.order_id) as - | { created_at: string } +): { lateRefund: boolean; bill: RefundBillRecord } { + const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(billId) as + | RefundBillRecord | undefined; + if (!bill) throw httpError('Bill not found', 404, 'bill_unavailable'); + const order = db + .prepare('SELECT created_at FROM orders WHERE id = ?') + .get(bill.order_id as string | number) as { created_at: string } | undefined; if (!order) throw httpError('Order not found', 404, 'order_unavailable'); const orderCreatedAt = parseDbTimestamp(order.created_at).getTime(); if (!Number.isFinite(orderCreatedAt)) throw httpError('Order creation time is invalid', 500); @@ -159,9 +191,9 @@ export function checkIsLateRefund( 'refund_window_expired', ); } - return { lateRefund: true, bill, order }; + return { lateRefund: true, bill }; } - return { lateRefund: false, bill, order }; + return { lateRefund: false, bill }; } /** Validates refund preconditions before PIN verification and write transaction. */ @@ -177,15 +209,18 @@ export function validateRefundPreconditions( ): ValidatedRefundPreconditions { requireOpenSessionForCashTender(db, [{ method: req.method }]); - const { lateRefund, bill, order } = checkIsLateRefund(db, req.billId, nowMs); + const { lateRefund, bill } = checkIsLateRefund(db, req.billId, nowMs); const currency = getTenantCurrency(db); const minorFactor = getCurrencyMinorUnitFactor(currency); let amountCents = req.amountCents; - let item: any = null; + let item: RefundOrderItemRecord | null = null; if (req.orderItemId != null) { - item = db.prepare('SELECT * FROM order_items WHERE id = ?').get(req.orderItemId) as any; + item = + (db.prepare('SELECT * FROM order_items WHERE id = ?').get(req.orderItemId) as + | RefundOrderItemRecord + | undefined) ?? null; if (!item) throw httpError('Order item not found', 404, 'item_unavailable'); if (String(item.order_id) !== String(bill.order_id)) { throw httpError("Item does not belong to this bill's order", 400, 'item_bill_mismatch'); @@ -195,7 +230,9 @@ export function validateRefundPreconditions( .prepare(` SELECT quantity FROM bill_items WHERE bill_id = ? AND order_item_id = ? `) - .get(bill.id, item.id) as { quantity: number } | undefined; + .get(bill.id as string | number, item.id as string | number) as + | { quantity: number } + | undefined; if (!allocation) { throw httpError('Item is not allocated to this split bill', 400, 'split_item_unallocated'); } @@ -207,7 +244,7 @@ export function validateRefundPreconditions( ); } } - if (!REFUND_ITEM_ELIGIBLE_STATUSES.includes(item.status)) { + if (!REFUND_ITEM_ELIGIBLE_STATUSES.includes(item.status as string)) { throw httpError('Item is not eligible for refund', 409, 'item_status_ineligible'); } const itemAmountCents = Math.round(Number(item.total) * minorFactor); @@ -239,11 +276,7 @@ export function validateRefundPreconditions( ); } - const { paidCents, refundedCents, refundableCents } = getRefundableBalance( - db, - req.billId, - currency, - ); + const { refundableCents } = getRefundableBalance(db, req.billId, currency); if (refundableCents <= 0) throw httpError('Bill has nothing left to refund', 400, 'nothing_to_refund'); if (amountCents > refundableCents) @@ -253,7 +286,7 @@ export function validateRefundPreconditions( 'amount_exceeds_refundable', ); - return { lateRefund, bill, order, item, amountCents, currency }; + return { lateRefund, bill, item, amountCents, currency }; } /** Validates, authorizes, and persists a refund inside an active transaction. */ @@ -285,7 +318,7 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { } const nowMs = Date.now(); - const { lateRefund, bill, order, item, amountCents, currency } = validateRefundPreconditions( + const { lateRefund, bill, item, amountCents, currency } = validateRefundPreconditions( db, req, nowMs, @@ -312,7 +345,9 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { .prepare( `SELECT id, pin_hash, role, is_active FROM users WHERE id = ? AND pin_hash IS NOT NULL AND role IN (${placeholders}) AND is_active = 1`, ) - .get(req.preVerifiedApprover.id, ...allowedRoles) as any; + .get(req.preVerifiedApprover.id, ...allowedRoles) as + | { id: string; pin_hash: string; role: string; is_active: number } + | undefined; if (!candidate || candidate.pin_hash !== req.preVerifiedApprover.verifiedPinHash) { throw httpError( lateRefund ? 'Invalid owner PIN' : 'Invalid manager PIN',