diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 6aa22c43..e1d18256 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -4,7 +4,7 @@ import { Loader2, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useCart } from '@/components/shop/CartProvider'; import { Link, useRouter } from '@/i18n/routing'; @@ -54,7 +54,28 @@ const SHOP_HERO_CTA = cn( 'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]' ); -export default function CartPage() { +type Props = { + stripeEnabled: boolean; + monobankEnabled: boolean; +}; + +type CheckoutProvider = 'stripe' | 'monobank'; + +function resolveInitialProvider(args: { + stripeEnabled: boolean; + monobankEnabled: boolean; + currency: string | null | undefined; +}): CheckoutProvider { + const isUah = args.currency === 'UAH'; + const canUseStripe = args.stripeEnabled; + const canUseMonobank = args.monobankEnabled && isUah; + + if (canUseStripe) return 'stripe'; + if (canUseMonobank) return 'monobank'; + return 'stripe'; +} + +export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const { cart, updateQuantity, removeFromCart } = useCart(); const router = useRouter(); const t = useTranslations('shop.cart'); @@ -62,10 +83,32 @@ export default function CartPage() { const [isCheckingOut, setIsCheckingOut] = useState(false); const [checkoutError, setCheckoutError] = useState(null); const [createdOrderId, setCreatedOrderId] = useState(null); + const [selectedProvider, setSelectedProvider] = useState( + () => + resolveInitialProvider({ + stripeEnabled, + monobankEnabled, + currency: cart?.summary?.currency, + }) + ); const params = useParams<{ locale?: string }>(); const locale = params.locale ?? 'en'; const shopBase = '/shop'; + const isUahCheckout = cart.summary.currency === 'UAH'; + const canUseStripe = stripeEnabled; + const canUseMonobank = monobankEnabled && isUahCheckout; + const hasSelectableProvider = canUseStripe || canUseMonobank; + + useEffect(() => { + if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) { + setSelectedProvider('monobank'); + return; + } + if (selectedProvider === 'monobank' && !canUseMonobank && canUseStripe) { + setSelectedProvider('stripe'); + } + }, [canUseMonobank, canUseStripe, selectedProvider]); const translateColor = (color: string | null | undefined): string | null => { if (!color) return null; @@ -78,6 +121,23 @@ export default function CartPage() { }; async function handleCheckout() { + if (!hasSelectableProvider) { + setCheckoutError(t('checkout.paymentMethod.noAvailable')); + return; + } + if (selectedProvider === 'stripe' && !canUseStripe) { + setCheckoutError(t('checkout.paymentMethod.noAvailable')); + return; + } + if (selectedProvider === 'monobank' && !canUseMonobank) { + setCheckoutError( + monobankEnabled + ? t('checkout.paymentMethod.monobankUahOnlyHint') + : t('checkout.paymentMethod.monobankUnavailable') + ); + return; + } + setCheckoutError(null); setCreatedOrderId(null); setIsCheckingOut(true); @@ -92,6 +152,7 @@ export default function CartPage() { 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ + paymentProvider: selectedProvider, items: cart.items.map(item => ({ productId: item.productId, quantity: item.quantity, @@ -109,13 +170,13 @@ export default function CartPage() { ? data.message : typeof data?.error === 'string' ? data.error - : 'Unable to start checkout right now.'; + : t('checkout.errors.startFailed'); setCheckoutError(message); return; } if (!data?.orderId) { - setCheckoutError('Unexpected checkout response.'); + setCheckoutError(t('checkout.errors.unexpectedResponse')); return; } @@ -125,6 +186,10 @@ export default function CartPage() { data.clientSecret.trim().length > 0 ? data.clientSecret : null; + const monobankPageUrl: string | null = + typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0 + ? data.pageUrl + : null; const orderId = String(data.orderId); setCreatedOrderId(orderId); @@ -137,6 +202,14 @@ export default function CartPage() { ); return; } + if (paymentProvider === 'monobank' && monobankPageUrl) { + window.location.assign(monobankPageUrl); + return; + } + if (paymentProvider === 'monobank' && !monobankPageUrl) { + setCheckoutError(t('checkout.errors.unexpectedResponse')); + return; + } const paymentsDisabledFlag = paymentProvider !== 'stripe' || !clientSecret @@ -149,7 +222,7 @@ export default function CartPage() { )}&clearCart=1${paymentsDisabledFlag}` ); } catch { - setCheckoutError('Unable to start checkout right now.'); + setCheckoutError(t('checkout.errors.startFailed')); } finally { setIsCheckingOut(false); } @@ -385,11 +458,69 @@ export default function CartPage() { +
+ + {t('checkout.paymentMethod.label')} + + +
+ {canUseStripe ? ( + + ) : null} + + + + {!canUseMonobank ? ( +

+ {monobankEnabled + ? t('checkout.paymentMethod.monobankUahOnlyHint') + : t('checkout.paymentMethod.monobankUnavailable')} +

+ ) : null} + + {!hasSelectableProvider ? ( +

+ {t('checkout.paymentMethod.noAvailable')} +

+ ) : null} +
+
+
+ + + {t('success.continueShopping')} + + + + {t('success.viewCart')} + + + + ); +} diff --git a/frontend/app/[locale]/shop/checkout/success/page.tsx b/frontend/app/[locale]/shop/checkout/success/page.tsx index cedff489..9cba258c 100644 --- a/frontend/app/[locale]/shop/checkout/success/page.tsx +++ b/frontend/app/[locale]/shop/checkout/success/page.tsx @@ -19,6 +19,7 @@ import { import { cn } from '@/lib/utils'; import { orderIdParamSchema } from '@/lib/validation/shop'; +import MonobankRedirectStatus from './MonobankRedirectStatus'; import OrderStatusAutoRefresh from './OrderStatusAutoRefresh'; export const metadata: Metadata = { @@ -28,7 +29,6 @@ export const metadata: Metadata = { }; export const dynamic = 'force-dynamic'; -export const revalidate = 0; type SearchParams = Record; @@ -46,6 +46,20 @@ function parseOrderId(params: SearchParams): string | null { return parsed.data.id; } +function parseStatusToken(params: SearchParams): string | null { + const raw = getStringParam(params, 'statusToken').trim(); + return raw.length ? raw : null; +} + +function isMonobankRedirectFlow( + params: SearchParams, + statusToken: string | null +): boolean { + if (statusToken) return true; + const flow = getStringParam(params, 'flow').trim().toLowerCase(); + return flow === 'monobank'; +} + function isPaymentsDisabled(params: SearchParams): boolean { const raw = getStringParam(params, 'paymentsDisabled'); if (!raw) return false; @@ -136,6 +150,7 @@ export default async function CheckoutSuccessPage({ }) { const { locale } = await params; const resolvedParams = await searchParams; + const clearCart = shouldClearCart(resolvedParams); const t = await getTranslations('shop.checkout'); @@ -159,6 +174,24 @@ export default async function CheckoutSuccessPage({ ); } + const statusToken = parseStatusToken(resolvedParams); + if (isMonobankRedirectFlow(resolvedParams, statusToken)) { + return ( +
+ + +
+ ); + } + const paymentsDisabled = isPaymentsDisabled(resolvedParams); let order: Awaited>; diff --git a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts index a560f375..ad745577 100644 --- a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts @@ -12,9 +12,16 @@ import { requireAdminApi, } from '@/lib/auth/admin'; import { getMonobankConfig } from '@/lib/env/monobank'; +import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logWarn } from '@/lib/logging'; import { requireAdminCsrf } from '@/lib/security/admin-csrf'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; +import { + enforceRateLimit, + getRateLimitSubject, + normalizeRateLimitSubject, + rateLimitResponse, +} from '@/lib/security/rate-limit'; import { InvalidPayloadError, OrderNotFoundError, @@ -29,6 +36,9 @@ function noStoreJson(body: unknown, init?: { status?: number }) { return res; } +const DEFAULT_ADMIN_REFUND_RATE_LIMIT_MAX = 5; +const DEFAULT_ADMIN_REFUND_RATE_LIMIT_WINDOW_SECONDS = 60; + export async function POST( request: NextRequest, context: { params: Promise<{ id: string }> } @@ -58,7 +68,7 @@ export async function POST( }; let orderIdForLog: string | null = null; try { - await requireAdminApi(request); + const adminUser = await requireAdminApi(request); const csrfRes = requireAdminCsrf(request, 'admin:orders:refund'); if (csrfRes) { logWarn('admin_orders_refund_csrf_rejected', { @@ -71,6 +81,42 @@ export async function POST( csrfRes.headers.set('Cache-Control', 'no-store'); return csrfRes; } + const adminId = adminUser?.id; + + const adminSubject = + typeof adminId === 'string' && adminId.trim().length > 0 + ? `admin_${normalizeRateLimitSubject(adminId)}` + : getRateLimitSubject(request); + + const limit = readPositiveIntEnv( + 'ADMIN_REFUND_RATE_LIMIT_MAX', + DEFAULT_ADMIN_REFUND_RATE_LIMIT_MAX + ); + const windowSeconds = readPositiveIntEnv( + 'ADMIN_REFUND_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_ADMIN_REFUND_RATE_LIMIT_WINDOW_SECONDS + ); + + const decision = await enforceRateLimit({ + key: `admin_refund:${adminSubject}`, + limit, + windowSeconds, + }); + + if (!decision.ok) { + logWarn('admin_orders_refund_rate_limited', { + ...baseMeta, + code: 'RATE_LIMITED', + orderId: orderIdForLog, + retryAfterSeconds: decision.retryAfterSeconds, + durationMs: Date.now() - startedAtMs, + }); + + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'admin_refund' }, + }); + } const rawParams = await context.params; const parsed = orderIdParamSchema.safeParse(rawParams); @@ -111,12 +157,9 @@ export async function POST( { status: 409 } ); } - } - if (targetOrder?.paymentProvider === 'monobank') { - const { requestMonobankFullRefund } = await import( - '@/lib/services/orders/monobank-refund' - ); + const { requestMonobankFullRefund } = + await import('@/lib/services/orders/monobank-refund'); const result = await requestMonobankFullRefund({ orderId: orderIdForLog, requestId, diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 0cfcb0f5..23c1a1c4 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -5,7 +5,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { MoneyValueError } from '@/db/queries/shop/orders'; import { getCurrentUser } from '@/lib/auth'; import { isMonobankEnabled } from '@/lib/env/monobank'; +import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logInfo, logWarn } from '@/lib/logging'; +import { MONO_MISMATCH, monoLogWarn } from '@/lib/logging/monobank'; import { guardBrowserSameOrigin } from '@/lib/security/origin'; import { enforceRateLimit, @@ -45,6 +47,9 @@ const EXPECTED_BUSINESS_ERROR_CODES = new Set([ 'PAYMENT_ATTEMPTS_EXHAUSTED', ]); +const DEFAULT_CHECKOUT_RATE_LIMIT_MAX = 10; +const DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS = 300; + function parseRequestedProvider( raw: unknown ): CheckoutRequestedProvider | 'invalid' | null { @@ -288,6 +293,7 @@ function buildMonobankCheckoutResponse({ pageUrl, currency, totalAmountMinor, + statusToken, }: { order: CheckoutOrderShape; itemCount: number; @@ -296,6 +302,7 @@ function buildMonobankCheckoutResponse({ pageUrl: string; currency: 'UAH'; totalAmountMinor: number; + statusToken: string; }) { const res = NextResponse.json( { @@ -320,6 +327,7 @@ function buildMonobankCheckoutResponse({ provider: 'mono' as const, currency, totalAmountMinor, + statusToken, }, { status } ); @@ -354,6 +362,12 @@ async function runMonobankCheckoutFlow(args: { }); if (args.totalCents !== monobankAttempt.totalAmountMinor) { + monoLogWarn(MONO_MISMATCH, { + requestId: args.requestId, + orderId: args.order.id, + reason: 'checkout_total_amount_mismatch', + }); + logError( 'checkout_mono_amount_mismatch', new Error('Monobank amount mismatch'), @@ -380,6 +394,7 @@ async function runMonobankCheckoutFlow(args: { pageUrl: monobankAttempt.pageUrl, currency: monobankAttempt.currency, totalAmountMinor: monobankAttempt.totalAmountMinor, + statusToken, }); } catch (error) { const mapped = mapMonobankCheckoutError(error); @@ -708,20 +723,15 @@ export async function POST(request: NextRequest) { const checkoutSubject = sessionUserId ?? getRateLimitSubject(request); - const limitParsed = Number.parseInt( - process.env.CHECKOUT_RATE_LIMIT_MAX ?? '', - 10 + const limit = readPositiveIntEnv( + 'CHECKOUT_RATE_LIMIT_MAX', + DEFAULT_CHECKOUT_RATE_LIMIT_MAX ); - const windowParsed = Number.parseInt( - process.env.CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ?? '', - 10 + const windowSeconds = readPositiveIntEnv( + 'CHECKOUT_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_CHECKOUT_RATE_LIMIT_WINDOW_SECONDS ); - const limit = - Number.isFinite(limitParsed) && limitParsed > 0 ? limitParsed : 10; - const windowSeconds = - Number.isFinite(windowParsed) && windowParsed > 0 ? windowParsed : 300; - const decision = await enforceRateLimit({ key: `checkout:${checkoutSubject}`, limit, diff --git a/frontend/app/api/shop/internal/monobank/janitor/route.ts b/frontend/app/api/shop/internal/monobank/janitor/route.ts index 38eaf80a..d1bb935f 100644 --- a/frontend/app/api/shop/internal/monobank/janitor/route.ts +++ b/frontend/app/api/shop/internal/monobank/janitor/route.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; import { db } from '@/db'; import { logError, logInfo, logWarn } from '@/lib/logging'; -import { guardNonBrowserOnly } from '@/lib/security/origin'; +import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { runMonobankJanitorJob1, runMonobankJanitorJob2, @@ -202,7 +202,9 @@ export async function POST(request: NextRequest) { jobName: 'monobank-janitor', }; - const blocked = guardNonBrowserOnly(request); + const blocked = guardNonBrowserFailClosed(request, { + surface: 'monobank_janitor', + }); if (blocked) { blocked.headers.set('X-Request-Id', requestId); logWarn('internal_monobank_janitor_origin_blocked', { diff --git a/frontend/app/api/shop/internal/orders/restock-stale/route.ts b/frontend/app/api/shop/internal/orders/restock-stale/route.ts index 256c6436..4f3220c5 100644 --- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts +++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/db'; import { requireInternalJanitorAuth } from '@/lib/auth/internal-janitor'; import { logError, logInfo, logWarn } from '@/lib/logging'; -import { guardNonBrowserOnly } from '@/lib/security/origin'; +import { guardNonBrowserFailClosed } from '@/lib/security/origin'; import { restockStaleNoPaymentOrders, restockStalePendingOrders, @@ -260,7 +260,9 @@ export async function POST(request: NextRequest) { jobName: 'restock-stale', }; - const blocked = guardNonBrowserOnly(request); + const blocked = guardNonBrowserFailClosed(request, { + surface: 'orders_restock_stale_janitor', + }); if (blocked) { logWarn('internal_janitor_origin_blocked', { ...baseMeta, diff --git a/frontend/app/api/shop/webhooks/monobank/route.ts b/frontend/app/api/shop/webhooks/monobank/route.ts index 7e2cbc02..88b00dc7 100644 --- a/frontend/app/api/shop/webhooks/monobank/route.ts +++ b/frontend/app/api/shop/webhooks/monobank/route.ts @@ -5,14 +5,34 @@ import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; import { getMonobankConfig } from '@/lib/env/monobank'; +import { readPositiveIntEnv } from '@/lib/env/readPositiveIntEnv'; import { logError, logInfo, logWarn } from '@/lib/logging'; +import { + MONO_SIG_INVALID, + MONO_STORE_MODE, + monoLogError, + monoLogInfo, + monoLogWarn, + monoSha256Raw, +} from '@/lib/logging/monobank'; import { verifyWebhookSignatureWithRefresh } from '@/lib/psp/monobank'; +import { guardNonBrowserFailClosed } from '@/lib/security/origin'; +import { + enforceRateLimit, + getRateLimitSubject, + rateLimitResponse, +} from '@/lib/security/rate-limit'; import { handleMonobankWebhook } from '@/lib/services/orders/monobank-webhook'; export const dynamic = 'force-dynamic'; type WebhookMode = 'drop' | 'store' | 'apply'; +const DEFAULT_MONO_WEBHOOK_MISSING_SIG_LIMIT = 30; +const DEFAULT_MONO_WEBHOOK_MISSING_SIG_WINDOW_SECONDS = 60; +const DEFAULT_MONO_WEBHOOK_INVALID_SIG_LIMIT = 30; +const DEFAULT_MONO_WEBHOOK_INVALID_SIG_WINDOW_SECONDS = 60; + function parseWebhookMode(raw: unknown): WebhookMode { const v = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; if (v === 'drop' || v === 'store' || v === 'apply') return v; @@ -47,6 +67,23 @@ function parseWebhookPayload( export async function POST(request: NextRequest) { const requestId = request.headers.get('x-request-id')?.trim() || crypto.randomUUID(); + const originBlocked = guardNonBrowserFailClosed(request, { + surface: 'monobank_webhook', + }); + if (originBlocked) { + logWarn('monobank_webhook_origin_blocked', { + requestId, + route: request.nextUrl.pathname, + method: request.method, + code: 'ORIGIN_BLOCKED', + surface: 'monobank_webhook', + }); + originBlocked.headers.set('X-Request-Id', requestId); + return originBlocked; + } + + const signature = request.headers.get('x-sign'); + const hasXSign = typeof signature === 'string' && signature.trim().length > 0; const baseMeta = { requestId, @@ -68,6 +105,13 @@ export async function POST(request: NextRequest) { webhookMode = 'apply'; } } + if (webhookMode !== 'apply') { + monoLogInfo(MONO_STORE_MODE, { + ...baseMeta, + mode: webhookMode, + storeDecision: webhookMode, + }); + } let rawBodyBytes: Buffer; try { @@ -75,17 +119,57 @@ export async function POST(request: NextRequest) { } catch (error) { logError('monobank_webhook_body_read_failed', error, { ...baseMeta, + mode: webhookMode, + hasXSign, + rawBytesLen: 0, + reason: 'BODY_READ_FAILED', code: 'MONO_BODY_READ_FAILED', }); return noStoreJson({ ok: true }, { status: 200 }); } - const rawSha256 = crypto - .createHash('sha256') - .update(rawBodyBytes) - .digest('hex'); + const rawSha256 = monoSha256Raw(rawBodyBytes); + const rawBytesLen = rawBodyBytes.byteLength; const eventKey = rawSha256; - const signature = request.headers.get('x-sign'); + const rateLimitSubject = getRateLimitSubject(request); + const diagMeta = { + ...baseMeta, + mode: webhookMode, + hasXSign, + rawSha256, + rawBytesLen, + }; + + if (!hasXSign) { + const decision = await enforceRateLimit({ + key: `monobank_webhook:missing_sig:${rateLimitSubject}`, + limit: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_MISSING_SIG_RATE_LIMIT_MAX', + DEFAULT_MONO_WEBHOOK_MISSING_SIG_LIMIT + ), + windowSeconds: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_MISSING_SIG_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_MONO_WEBHOOK_MISSING_SIG_WINDOW_SECONDS + ), + }); + + if (!decision.ok) { + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_MISSING_RATE_LIMITED', + }); + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'monobank_webhook_missing_signature' }, + }); + } + + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_MISSING', + }); + return noStoreJson({ ok: true }, { status: 200 }); + } let validSignature = false; try { @@ -94,18 +178,39 @@ export async function POST(request: NextRequest) { signature, }); } catch (error) { - logError('monobank_webhook_signature_error', error, { - ...baseMeta, - code: 'MONO_SIG_INVALID', - rawSha256, + monoLogError(MONO_SIG_INVALID, error, { + ...diagMeta, + reason: 'SIG_VERIFY_ERROR', }); } if (!validSignature) { - logWarn('monobank_webhook_signature_invalid', { - ...baseMeta, - code: 'MONO_SIG_INVALID', - rawSha256, + const decision = await enforceRateLimit({ + key: `monobank_webhook:invalid_sig:${rateLimitSubject}`, + limit: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_INVALID_SIG_RATE_LIMIT_MAX', + DEFAULT_MONO_WEBHOOK_INVALID_SIG_LIMIT + ), + windowSeconds: readPositiveIntEnv( + 'MONOBANK_WEBHOOK_INVALID_SIG_RATE_LIMIT_WINDOW_SECONDS', + DEFAULT_MONO_WEBHOOK_INVALID_SIG_WINDOW_SECONDS + ), + }); + + if (!decision.ok) { + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_INVALID_RATE_LIMITED', + }); + return rateLimitResponse({ + retryAfterSeconds: decision.retryAfterSeconds, + details: { scope: 'monobank_webhook_invalid_signature' }, + }); + } + + monoLogWarn(MONO_SIG_INVALID, { + ...diagMeta, + reason: 'SIG_INVALID', }); return noStoreJson({ ok: true }, { status: 200 }); } @@ -113,10 +218,10 @@ export async function POST(request: NextRequest) { const parsedPayload = parseWebhookPayload(rawBodyBytes); if (!parsedPayload) { logWarn('monobank_webhook_payload_invalid', { - ...baseMeta, + ...diagMeta, code: 'INVALID_PAYLOAD', eventKey, - rawSha256, + reason: 'INVALID_PAYLOAD', }); return noStoreJson({ ok: true }, { status: 200 }); } @@ -124,26 +229,40 @@ export async function POST(request: NextRequest) { try { const result = await handleMonobankWebhook({ rawBodyBytes, + rawSha256, parsedPayload, eventKey, requestId, mode: webhookMode, }); + if ( + result.appliedResult === 'stored' || + result.appliedResult === 'dropped' + ) { + monoLogInfo(MONO_STORE_MODE, { + ...diagMeta, + storeDecision: result.appliedResult, + eventKey, + invoiceId: result.invoiceId, + reason: 'STORE_MODE_RESULT', + }); + } + logInfo('monobank_webhook_processed', { - ...baseMeta, + ...diagMeta, eventKey, - rawSha256, invoiceId: result.invoiceId, appliedResult: result.appliedResult, deduped: result.deduped, + reason: 'PROCESSED', }); } catch (error) { logError('monobank_webhook_apply_failed', error, { - ...baseMeta, + ...diagMeta, code: 'WEBHOOK_APPLY_FAILED', eventKey, - rawSha256, + reason: 'WEBHOOK_APPLY_FAILED', }); } diff --git a/frontend/lib/env/readPositiveIntEnv.ts b/frontend/lib/env/readPositiveIntEnv.ts new file mode 100644 index 00000000..53847243 --- /dev/null +++ b/frontend/lib/env/readPositiveIntEnv.ts @@ -0,0 +1,6 @@ +import 'server-only'; + +export function readPositiveIntEnv(name: string, fallback: number): number { + const parsed = Number.parseInt(process.env[name] ?? '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/frontend/lib/logging/monobank.ts b/frontend/lib/logging/monobank.ts new file mode 100644 index 00000000..d064e693 --- /dev/null +++ b/frontend/lib/logging/monobank.ts @@ -0,0 +1,163 @@ +import 'server-only'; + +import crypto from 'node:crypto'; + +import { logError, logInfo, logWarn } from '@/lib/logging'; + +export const MONO_SIG_INVALID = 'MONO_SIG_INVALID' as const; +export const MONO_PUBKEY_REFRESHED = 'MONO_PUBKEY_REFRESHED' as const; +export const MONO_DEDUP = 'MONO_DEDUP' as const; +export const MONO_OLD_EVENT = 'MONO_OLD_EVENT' as const; +export const MONO_MISMATCH = 'MONO_MISMATCH' as const; +export const MONO_PAID_APPLIED = 'MONO_PAID_APPLIED' as const; +export const MONO_REFUND_APPLIED = 'MONO_REFUND_APPLIED' as const; +export const MONO_STORE_MODE = 'MONO_STORE_MODE' as const; +export const MONO_CREATE_INVOICE_FAILED = 'MONO_CREATE_INVOICE_FAILED' as const; +export const MONO_EXPIRED_RECONCILED = 'MONO_EXPIRED_RECONCILED' as const; +export const MONO_WEBHOOK_ATOMIC_UPDATE_FAILED = + 'MONO_WEBHOOK_ATOMIC_UPDATE_FAILED' as const; +export const MONO_WEBHOOK_UNKNOWN_STATUS = + 'MONO_WEBHOOK_UNKNOWN_STATUS' as const; +export const MONO_WEBHOOK_RESTOCK_FAILED = + 'MONO_WEBHOOK_RESTOCK_FAILED' as const; + +export const MONO_LOG_CODES = { + SIG_INVALID: MONO_SIG_INVALID, + PUBKEY_REFRESHED: MONO_PUBKEY_REFRESHED, + DEDUP: MONO_DEDUP, + OLD_EVENT: MONO_OLD_EVENT, + MISMATCH: MONO_MISMATCH, + PAID_APPLIED: MONO_PAID_APPLIED, + REFUND_APPLIED: MONO_REFUND_APPLIED, + STORE_MODE: MONO_STORE_MODE, + CREATE_INVOICE_FAILED: MONO_CREATE_INVOICE_FAILED, + EXPIRED_RECONCILED: MONO_EXPIRED_RECONCILED, + WEBHOOK_ATOMIC_UPDATE_FAILED: MONO_WEBHOOK_ATOMIC_UPDATE_FAILED, + WEBHOOK_UNKNOWN_STATUS: MONO_WEBHOOK_UNKNOWN_STATUS, + WEBHOOK_RESTOCK_FAILED: MONO_WEBHOOK_RESTOCK_FAILED, +} as const; + +export type MonobankLogCode = + (typeof MONO_LOG_CODES)[keyof typeof MONO_LOG_CODES]; + +type LogPrimitive = string | number | boolean | null; + +const ALLOWED_META_KEYS = new Set([ + 'requestId', + 'route', + 'method', + 'provider', + 'mode', + 'storeDecision', + 'eventId', + 'eventKey', + 'rawSha256', + 'rawBytesLen', + 'hasXSign', + 'invoiceId', + 'orderId', + 'attemptId', + 'appliedResult', + 'deduped', + 'status', + 'fromStatus', + 'toStatus', + 'reason', + 'errorCode', + 'endpoint', + 'httpStatus', + 'durationMs', + 'runId', + 'job', + 'dryRun', + 'limit', + 'graceSeconds', + 'leaseSeconds', + 'ttlSeconds', + 'processed', + 'applied', + 'noop', + 'failed', + 'candidates', + 'claimed', + 'ageMs', + 'ageHoursThreshold', + 'count', + 'oldestAgeMinutes', + 'restockReason', +]); + +const BLOCKED_META_KEY_RE = + /(payload|body|header|authorization|cookie|token|email|phone|card|basket)/i; +const HEX_64_RE = /^[0-9a-f]{64}$/i; +const MAX_STRING_LEN = 180; + +function sanitizeMetaValue(key: string, value: unknown): LogPrimitive | null { + if (value === null) return null; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return null; + + if ((key === 'eventKey' || key === 'rawSha256') && !HEX_64_RE.test(trimmed)) + return null; + + return trimmed.length > MAX_STRING_LEN + ? trimmed.slice(0, MAX_STRING_LEN) + : trimmed; + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return null; + return value; + } + + if (typeof value === 'boolean') return value; + + return null; +} + +export function sanitizeMonobankMeta( + meta?: Record +): Record | undefined { + if (!meta) return undefined; + + const out: Record = {}; + + for (const [key, rawValue] of Object.entries(meta)) { + if (!ALLOWED_META_KEYS.has(key)) continue; + if (BLOCKED_META_KEY_RE.test(key)) continue; + + const value = sanitizeMetaValue(key, rawValue); + if (value !== null) out[key] = value; + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +export function monoLogInfo( + code: MonobankLogCode, + meta?: Record +) { + logInfo(code, sanitizeMonobankMeta(meta)); +} + +export function monoLogWarn( + code: MonobankLogCode, + meta?: Record +) { + logWarn(code, sanitizeMonobankMeta(meta)); +} + +export function monoLogError( + code: MonobankLogCode, + error: unknown, + meta?: Record +) { + logError(code, error, sanitizeMonobankMeta(meta)); +} + +export function monoSha256Raw(raw: Uint8Array): string { + const bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw); + return crypto.createHash('sha256').update(bytes).digest('hex'); +} diff --git a/frontend/lib/psp/monobank.ts b/frontend/lib/psp/monobank.ts index 1926c991..0692055f 100644 --- a/frontend/lib/psp/monobank.ts +++ b/frontend/lib/psp/monobank.ts @@ -4,6 +4,13 @@ import crypto from 'node:crypto'; import { getMonobankEnv } from '@/lib/env/monobank'; import { logError } from '@/lib/logging'; +import { + MONO_PUBKEY_REFRESHED, + MONO_SIG_INVALID, + monoLogError, + monoLogInfo, + monoLogWarn, +} from '@/lib/logging/monobank'; export const MONO_CCY = 980 as const; export const MONO_CURRENCY = 'UAH' as const; @@ -369,6 +376,7 @@ async function requestMono( ): Promise> { const endpoint = args.path.startsWith('/') ? args.path : `/${args.path}`; const url = normalizeEndpoint(args.baseUrl, args.path); + const startedAt = Date.now(); const controller = new AbortController(); let timeoutId: ReturnType | null = null; @@ -402,6 +410,7 @@ async function requestMono( if (timeoutId) clearTimeout(timeoutId); const status = res.status; + const durationMs = Date.now() - startedAt; const text = await res.text(); if (!res.ok) { @@ -412,6 +421,7 @@ async function requestMono( endpoint, method: args.method, httpStatus: status, + durationMs, }); } @@ -420,6 +430,7 @@ async function requestMono( endpoint, method: args.method, httpStatus: status, + durationMs, ...(parsed.monoCode ? { monoCode: parsed.monoCode } : {}), ...(parsed.monoMessage ? { monoMessage: parsed.monoMessage } : {}), ...(parsed.responseSnippet @@ -432,6 +443,7 @@ async function requestMono( endpoint, method: args.method, httpStatus: status, + durationMs, }); } @@ -454,12 +466,14 @@ async function requestMono( endpoint, method: args.method, timeoutMs: args.timeoutMs, + durationMs: Date.now() - startedAt, }); } throw new PspError('PSP_UNKNOWN', 'Monobank request failed', { endpoint, method: args.method, + durationMs: Date.now() - startedAt, }); } } @@ -844,7 +858,12 @@ export async function verifyWebhookSignatureWithRefresh(args: { let key: Uint8Array; try { key = await fetchWebhookPubKey(); - } catch { + } catch (error) { + monoLogWarn(MONO_SIG_INVALID, { + endpoint: '/api/merchant/pubkey', + reason: 'pubkey_fetch_failed', + errorCode: error instanceof Error ? error.name : 'UNKNOWN', + }); return false; } @@ -854,8 +873,18 @@ export async function verifyWebhookSignatureWithRefresh(args: { try { const refreshed = await fetchWebhookPubKey({ forceRefresh: true }); - return verifyWebhookSignature(args.rawBodyBytes, args.signature, refreshed); - } catch { + const ok = verifyWebhookSignature(args.rawBodyBytes, args.signature, refreshed); + monoLogInfo(MONO_PUBKEY_REFRESHED, { + endpoint: '/api/merchant/pubkey', + reason: 'refresh_once_after_verify_failed', + status: ok ? 'verified' : 'invalid_signature', + }); + return ok; + } catch (error) { + monoLogError(MONO_PUBKEY_REFRESHED, error, { + endpoint: '/api/merchant/pubkey', + reason: 'refresh_failed', + }); return false; } } diff --git a/frontend/lib/security/origin.ts b/frontend/lib/security/origin.ts index 9fecea22..ab147b79 100644 --- a/frontend/lib/security/origin.ts +++ b/frontend/lib/security/origin.ts @@ -2,13 +2,31 @@ import { NextRequest, NextResponse } from 'next/server'; const LOCALHOST_ORIGIN = 'http://localhost:3000'; -function buildErrorResponse(code: string, message: string) { +function buildErrorResponse( + code: string, + message: string, + extraBody?: Record, + extraError?: Record +) { + const safeBody = extraBody ? { ...extraBody } : undefined; + if (safeBody && 'error' in safeBody) { + delete (safeBody as Record).error; + } + + const safeError = extraError ? { ...extraError } : undefined; + if (safeError) { + delete (safeError as Record).code; + delete (safeError as Record).message; + } const res = NextResponse.json( { error: { code, message, + ...(safeError ?? {}), }, + + ...(safeBody ?? {}), }, { status: 403 } ); @@ -24,7 +42,7 @@ export function normalizeOrigin(input: string): string { try { return new URL(trimmed).origin; } catch { - return trimmed; + return ''; } } @@ -33,7 +51,8 @@ export function getAllowedOrigins(): string[] { const appOrigin = (process.env.APP_ORIGIN ?? '').trim(); if (appOrigin) { - allowed.add(normalizeOrigin(appOrigin)); + const normalized = normalizeOrigin(appOrigin); + if (normalized) allowed.add(normalized); } const additionalRaw = (process.env.APP_ADDITIONAL_ORIGINS ?? '').trim(); @@ -41,7 +60,8 @@ export function getAllowedOrigins(): string[] { for (const entry of additionalRaw.split(',')) { const candidate = entry.trim(); if (!candidate) continue; - allowed.add(normalizeOrigin(candidate)); + const normalized = normalizeOrigin(candidate); + if (normalized) allowed.add(normalized); } } @@ -100,3 +120,31 @@ export function guardNonBrowserOnly(req: NextRequest): NextResponse | null { return null; } + +function hasAnySecFetchHeader(req: NextRequest): boolean { + for (const key of req.headers.keys()) { + if (key.toLowerCase().startsWith('sec-fetch-')) { + return true; + } + } + return false; +} + +export function guardNonBrowserFailClosed( + req: NextRequest, + meta?: { surface?: string } +): NextResponse | null { + const origin = req.headers.get('origin'); + const referer = req.headers.get('referer'); + const hasSecFetch = hasAnySecFetchHeader(req); + + if (!origin && !referer && !hasSecFetch) { + return null; + } + + return buildErrorResponse( + 'ORIGIN_BLOCKED', + 'Browser context is not allowed for this endpoint.', + { surface: meta?.surface ?? 'non_browser' } + ); +} diff --git a/frontend/lib/services/orders/monobank-events-claim.ts b/frontend/lib/services/orders/monobank-events-claim.ts index b7104ad4..91a943df 100644 --- a/frontend/lib/services/orders/monobank-events-claim.ts +++ b/frontend/lib/services/orders/monobank-events-claim.ts @@ -42,21 +42,27 @@ export async function claimNextMonobankEvent( ): Promise { try { const result = await db.execute(sql` - WITH picked AS ( + WITH clock AS ( + SELECT now() AS ts + ), + picked AS ( SELECT id FROM monobank_events + CROSS JOIN clock WHERE applied_at IS NULL - AND (claim_expires_at IS NULL OR claim_expires_at < now()) + AND (claim_expires_at IS NULL OR claim_expires_at <= clock.ts) ORDER BY provider_modified_at ASC NULLS LAST, received_at ASC, id ASC LIMIT 1 FOR UPDATE SKIP LOCKED ) UPDATE monobank_events e - SET claimed_at = now(), - claim_expires_at = now() + interval '45 seconds', + SET claimed_at = clock.ts, + claim_expires_at = clock.ts + interval '45 seconds', claimed_by = ${claimedBy} - FROM picked + FROM picked, clock WHERE e.id = picked.id + AND e.applied_at IS NULL + AND (e.claim_expires_at IS NULL OR e.claim_expires_at <= clock.ts) RETURNING e.* `); diff --git a/frontend/lib/services/orders/monobank-janitor.ts b/frontend/lib/services/orders/monobank-janitor.ts index 25a227ff..caa99e7f 100644 --- a/frontend/lib/services/orders/monobank-janitor.ts +++ b/frontend/lib/services/orders/monobank-janitor.ts @@ -7,6 +7,7 @@ import { sql } from 'drizzle-orm'; import { db } from '@/db'; import { getMonobankConfig } from '@/lib/env/monobank'; import { logError, logInfo, logWarn } from '@/lib/logging'; +import { MONO_EXPIRED_RECONCILED, monoLogInfo } from '@/lib/logging/monobank'; import { getInvoiceStatus } from '@/lib/psp/monobank'; import { claimNextMonobankEvent, @@ -640,6 +641,16 @@ export async function runMonobankJanitorJob1( if (isAppliedResult(appliedResult.appliedResult)) { applied += 1; + monoLogInfo(MONO_EXPIRED_RECONCILED, { + requestId: args.requestId, + runId: args.runId, + job: 'job1', + orderId: attempt.order_id, + attemptId: attempt.id, + invoiceId, + appliedResult: appliedResult.appliedResult, + reason: 'stale_attempt_reconciled_from_psp_status', + }); } else { noop += 1; } @@ -771,6 +782,15 @@ export async function runMonobankJanitorJob2( }); applied += 1; + monoLogInfo(MONO_EXPIRED_RECONCILED, { + requestId: args.requestId, + runId: args.runId, + job: 'job2', + orderId: transitionedOrderId, + attemptId: attempt.id, + appliedResult: 'applied', + reason: 'expired_creating_attempt_canceled', + }); } catch (error) { failed += 1; logError('internal_monobank_janitor_job2_attempt_failed', error, { diff --git a/frontend/lib/services/orders/monobank-refund.ts b/frontend/lib/services/orders/monobank-refund.ts index dee7f831..359eea44 100644 --- a/frontend/lib/services/orders/monobank-refund.ts +++ b/frontend/lib/services/orders/monobank-refund.ts @@ -6,6 +6,12 @@ import { db } from '@/db'; import { monobankRefunds, orders, paymentAttempts } from '@/db/schema'; import { getMonobankConfig } from '@/lib/env/monobank'; import { logWarn } from '@/lib/logging'; +import { + MONO_DEDUP, + MONO_REFUND_APPLIED, + monoLogInfo, + monoLogWarn, +} from '@/lib/logging/monobank'; import { cancelInvoicePayment, PspError } from '@/lib/psp/monobank'; import { @@ -240,6 +246,14 @@ export async function requestMonobankFullRefund(args: { }); if (isDedupedRefundStatus(reconciled.status)) { + monoLogInfo(MONO_REFUND_APPLIED, { + requestId: args.requestId, + orderId: args.orderId, + attemptId: reconciled.attemptId, + status: reconciled.status, + deduped: true, + reason: 'existing_refund', + }); return { order: await getOrderById(args.orderId), refund: mapRefundRow(reconciled), @@ -248,6 +262,14 @@ export async function requestMonobankFullRefund(args: { } if (!isRetryableRefundStatus(reconciled.status)) { + monoLogInfo(MONO_REFUND_APPLIED, { + requestId: args.requestId, + orderId: args.orderId, + attemptId: reconciled.attemptId, + status: reconciled.status, + deduped: true, + reason: 'existing_terminal_refund', + }); return { order: await getOrderById(args.orderId), refund: mapRefundRow(reconciled), @@ -313,10 +335,10 @@ export async function requestMonobankFullRefund(args: { if (!inserted[0]) { const conflict = await getExistingRefund(extRef); if (!conflict) { - logWarn('monobank_refund_idempotency_conflict', { + monoLogWarn(MONO_DEDUP, { orderId: args.orderId, requestId: args.requestId, - extRef, + reason: 'refund_insert_conflict_without_existing_row', }); throw invalid('REFUND_CONFLICT', 'Refund idempotency conflict.', { orderId: args.orderId, @@ -331,6 +353,14 @@ export async function requestMonobankFullRefund(args: { }); if (isDedupedRefundStatus(reconciled.status)) { + monoLogInfo(MONO_REFUND_APPLIED, { + requestId: args.requestId, + orderId: args.orderId, + attemptId: reconciled.attemptId, + status: reconciled.status, + deduped: true, + reason: 'conflict_existing_refund', + }); return { order: await getOrderById(args.orderId), refund: mapRefundRow(reconciled), @@ -339,6 +369,14 @@ export async function requestMonobankFullRefund(args: { } if (!isRetryableRefundStatus(reconciled.status)) { + monoLogInfo(MONO_REFUND_APPLIED, { + requestId: args.requestId, + orderId: args.orderId, + attemptId: reconciled.attemptId, + status: reconciled.status, + deduped: true, + reason: 'conflict_existing_terminal_refund', + }); return { order: await getOrderById(args.orderId), refund: mapRefundRow(reconciled), @@ -420,6 +458,16 @@ export async function requestMonobankFullRefund(args: { .where(eq(monobankRefunds.id, refundRowForPsp.id)) .returning(); + monoLogInfo(MONO_REFUND_APPLIED, { + requestId: args.requestId, + orderId: args.orderId, + attemptId, + invoiceId, + status: (processing ?? refundRowForPsp).status, + deduped, + reason: 'refund_requested', + }); + return { order: await getOrderById(args.orderId), refund: mapRefundRow(processing ?? refundRowForPsp), diff --git a/frontend/lib/services/orders/monobank-webhook.ts b/frontend/lib/services/orders/monobank-webhook.ts index 795aa90c..137b269c 100644 --- a/frontend/lib/services/orders/monobank-webhook.ts +++ b/frontend/lib/services/orders/monobank-webhook.ts @@ -6,7 +6,19 @@ import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; -import { logError, logInfo } from '@/lib/logging'; +import { + MONO_DEDUP, + MONO_MISMATCH, + MONO_OLD_EVENT, + MONO_PAID_APPLIED, + MONO_STORE_MODE, + MONO_WEBHOOK_ATOMIC_UPDATE_FAILED, + MONO_WEBHOOK_RESTOCK_FAILED, + MONO_WEBHOOK_UNKNOWN_STATUS, + monoLogError, + monoLogInfo, + monoLogWarn, +} from '@/lib/logging/monobank'; import { InvalidPayloadError } from '@/lib/services/errors'; import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; import { restockOrder } from '@/lib/services/orders/restock'; @@ -566,6 +578,14 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { attemptProviderModifiedAt && providerModifiedAt <= attemptProviderModifiedAt ) { + monoLogInfo(MONO_OLD_EVENT, { + eventId, + invoiceId: normalized.invoiceId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + reason: 'provider_modified_at_older_or_equal', + }); const appliedResult: ApplyResult = 'applied_noop'; await persistEventOutcome({ eventId, @@ -595,6 +615,14 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { }); if (mismatch.mismatch) { + monoLogWarn(MONO_MISMATCH, { + eventId, + invoiceId: normalized.invoiceId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + reason: mismatch.reason ?? 'mismatch', + }); const appliedResult: ApplyResult = 'applied_with_issue'; if (orderRow.paymentStatus !== 'paid') { @@ -775,12 +803,16 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { }); if (!ok) { - logError('monobank_webhook_atomic_update_failed', undefined, { - eventId, - orderId: orderRow.id, - attemptId: attemptRow.id, - status, - }); + monoLogError( + MONO_WEBHOOK_ATOMIC_UPDATE_FAILED, + new Error('Atomic update (paid+succeeded) did not update both rows'), + { + eventId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + } + ); const appliedResult: ApplyResult = 'applied_with_issue'; await persistEventOutcome({ @@ -809,6 +841,14 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { attemptId: attemptRow.id, orderId: orderRow.id, }); + monoLogInfo(MONO_PAID_APPLIED, { + eventId, + invoiceId: normalized.invoiceId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + appliedResult, + }); return buildApplyOutcome({ appliedResult, @@ -882,12 +922,16 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { }); if (!ok) { - logError('monobank_webhook_atomic_update_failed', undefined, { - eventId, - orderId: orderRow.id, - attemptId: attemptRow.id, - status, - }); + monoLogError( + MONO_WEBHOOK_ATOMIC_UPDATE_FAILED, + new Error('Atomic update (finalize) did not update both rows'), + { + eventId, + orderId: orderRow.id, + attemptId: attemptRow.id, + status, + } + ); const appliedResult: ApplyResult = 'applied_with_issue'; await persistEventOutcome({ @@ -926,13 +970,17 @@ async function applyWebhookToMatchedOrderAttemptEvent(args: { }); } - logError('MONO_WEBHOOK_UNKNOWN_STATUS', undefined, { - eventId, - status, - invoiceId: normalized.invoiceId, - orderId: orderRow.id, - attemptId: attemptRow.id, - }); + monoLogError( + MONO_WEBHOOK_UNKNOWN_STATUS, + new Error('Unrecognized Monobank status'), + { + eventId, + status, + invoiceId: normalized.invoiceId, + orderId: orderRow.id, + attemptId: attemptRow.id, + } + ); const appliedResult: ApplyResult = 'applied_noop'; await persistEventOutcome({ @@ -1020,7 +1068,7 @@ async function finalizeOutcomeWithRestock(args: { workerId: 'monobank_webhook', }); } catch (error) { - logError('monobank_webhook_restock_failed', error, { + monoLogError(MONO_WEBHOOK_RESTOCK_FAILED, error, { requestId: args.requestId, invoiceId: args.normalized.invoiceId, }); @@ -1124,10 +1172,12 @@ export async function applyMonoWebhookEvent(args: { }); if (!eventId) { - logInfo('monobank_webhook_deduped', { + monoLogInfo(MONO_DEDUP, { requestId: args.requestId, invoiceId: parsed.normalized.invoiceId, status: parsed.normalized.status, + deduped: true, + reason: 'insert_conflict', }); return { deduped: true, @@ -1141,6 +1191,13 @@ export async function applyMonoWebhookEvent(args: { const now = new Date(); const appliedResult: ApplyResult = args.mode === 'drop' ? 'dropped' : 'stored'; + monoLogInfo(MONO_STORE_MODE, { + requestId: args.requestId, + mode: args.mode, + storeDecision: appliedResult, + eventId, + invoiceId: parsed.normalized.invoiceId, + }); await db .update(monobankEvents) @@ -1195,6 +1252,7 @@ export async function applyMonoWebhookEvent(args: { export async function handleMonobankWebhook(args: { rawBodyBytes: Uint8Array; + rawSha256: string; parsedPayload: Record; eventKey: string; requestId: string; @@ -1204,16 +1262,12 @@ export async function handleMonobankWebhook(args: { ? args.rawBodyBytes : Buffer.from(args.rawBodyBytes); const rawBody = rawBodyBuffer.toString('utf8'); - const rawSha256 = crypto - .createHash('sha256') - .update(rawBodyBuffer) - .digest('hex'); return applyMonoWebhookEvent({ rawBody, parsedPayload: args.parsedPayload, eventKey: args.eventKey, - rawSha256, + rawSha256: args.rawSha256, requestId: args.requestId, mode: args.mode, }); diff --git a/frontend/lib/services/orders/monobank.ts b/frontend/lib/services/orders/monobank.ts index 3dc68d76..607b72a4 100644 --- a/frontend/lib/services/orders/monobank.ts +++ b/frontend/lib/services/orders/monobank.ts @@ -5,6 +5,10 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import { db } from '@/db'; import { orderItems, orders, paymentAttempts } from '@/db/schema'; import { logError, logWarn } from '@/lib/logging'; +import { + MONO_CREATE_INVOICE_FAILED, + monoLogWarn, +} from '@/lib/logging/monobank'; import { cancelMonobankInvoice, createMonobankInvoice, @@ -617,11 +621,11 @@ async function createMonoAttemptAndInvoiceImpl( ? String((error as { code?: unknown }).code) : 'PSP_UNAVAILABLE'; - logWarn('monobank_invoice_create_failed', { + monoLogWarn(MONO_CREATE_INVOICE_FAILED, { orderId: args.orderId, attemptId: attempt.id, - code: errorCode, requestId: args.requestId, + errorCode, message: errorMessage, }); @@ -737,7 +741,7 @@ export async function createMonobankAttemptAndInvoice(args: { totalAmountMinor: number; }> { const redirectUrl = toAbsoluteUrl( - `/shop/checkout/success?orderId=${encodeURIComponent( + `/shop/checkout/success?flow=monobank&orderId=${encodeURIComponent( args.orderId )}&statusToken=${encodeURIComponent(args.statusToken)}` ); diff --git a/frontend/lib/shop/currency.ts b/frontend/lib/shop/currency.ts index fe0a2a21..e9bf44de 100644 --- a/frontend/lib/shop/currency.ts +++ b/frontend/lib/shop/currency.ts @@ -70,15 +70,20 @@ function normalizeLocaleForIntl( } const formatterCache = new Map(); -function getFormatter(locale: string, currency: CurrencyCode) { - const key = `${locale}::${currency}`; +function getFormatter( + locale: string, + currency: CurrencyCode, + currencyDisplay: Intl.NumberFormatOptions['currencyDisplay'] = 'narrowSymbol' +) { + const display = currencyDisplay ?? 'narrowSymbol'; + const key = `${locale}::${currency}::${display}`; const cached = formatterCache.get(key); if (cached) return cached; const created = new Intl.NumberFormat(locale, { style: 'currency', currency, - currencyDisplay: 'narrowSymbol', + currencyDisplay: display, }); formatterCache.set(key, created); @@ -106,7 +111,22 @@ export function formatMoney( const minor = assertMinorUnitsStrict(amountMinor); const intlLocale = normalizeLocaleForIntl(locale, currency); const major = minorToMajor(minor, currency); - return getFormatter(intlLocale, currency).format(major); + return getFormatter(intlLocale, currency, 'narrowSymbol').format(major); + } catch { + return '-'; + } +} + +export function formatMoneyCode( + amountMinor: number, + currency: CurrencyCode, + locale?: string | null +): string { + try { + const minor = assertMinorUnitsStrict(amountMinor); + const intlLocale = normalizeLocaleForIntl(locale, currency); + const major = minorToMajor(minor, currency); + return getFormatter(intlLocale, currency, 'code').format(major); } catch { return '-'; } diff --git a/frontend/lib/tests/helpers/db-safety.ts b/frontend/lib/tests/helpers/db-safety.ts new file mode 100644 index 00000000..3202516d --- /dev/null +++ b/frontend/lib/tests/helpers/db-safety.ts @@ -0,0 +1,34 @@ +export function assertNotProductionDb(): void { + if (process.env.ALLOW_PROD_DB_TESTS === '1') { + return; + } + + const appEnv = (process.env.APP_ENV ?? 'local').toLowerCase(); + const databaseUrl = process.env.DATABASE_URL ?? ''; + const databaseUrlLocal = process.env.DATABASE_URL_LOCAL ?? ''; + + const reasons: string[] = []; + + if (appEnv !== 'local') { + reasons.push(`APP_ENV=${appEnv}`); + } + + if (/neon\.tech/i.test(databaseUrl) || /production/i.test(databaseUrl)) { + reasons.push('DATABASE_URL looks production-like'); + } + + if ( + /neon\.tech/i.test(databaseUrlLocal) || + /production/i.test(databaseUrlLocal) + ) { + reasons.push('DATABASE_URL_LOCAL looks production-like'); + } + + if (reasons.length > 0) { + throw new Error( + `[db-safety] Refusing DB-mutating tests against production-like DB config. Reasons: ${reasons.join( + '; ' + )}. Set ALLOW_PROD_DB_TESTS=1 only for intentional local debugging.` + ); + } +} diff --git a/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts b/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts new file mode 100644 index 00000000..62b2bf7b --- /dev/null +++ b/frontend/lib/tests/shop/checkout-monobank-happy-path.test.ts @@ -0,0 +1,284 @@ +import crypto from 'crypto'; +import { and, eq } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +const createMonobankInvoiceMock = vi.fn(async (args: any) => { + const orderId = + typeof args?.orderId === 'string' ? args.orderId : crypto.randomUUID(); + const invoiceId = `inv_${orderId}`; + const pageUrl = `https://pay.test/${invoiceId}`; + return { + invoiceId, + pageUrl, + raw: {}, + }; +}); + +vi.mock('@/lib/psp/monobank', () => ({ + MONO_CURRENCY: 'UAH', + createMonobankInvoice: (args: any) => createMonobankInvoiceMock(args), + cancelMonobankInvoice: vi.fn(async () => {}), +})); + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; +const __prevAppOrigin = process.env.APP_ORIGIN; +const __prevShopBaseUrl = process.env.SHOP_BASE_URL; +const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + process.env.SHOP_BASE_URL = 'http://localhost:3000'; + process.env.SHOP_STATUS_TOKEN_SECRET = + 'test_status_token_secret_test_status_token_secret'; + resetEnvCache(); +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + if (__prevShopBaseUrl === undefined) delete process.env.SHOP_BASE_URL; + else process.env.SHOP_BASE_URL = __prevShopBaseUrl; + + if (__prevStatusSecret === undefined) + delete process.env.SHOP_STATUS_TOKEN_SECRET; + else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; + + resetEnvCache(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function insertTestProductWithPrice(args: { + stock: number; + priceMinor: number; + currency: 'USD'; +}) { + const productId = crypto.randomUUID(); + const token = crypto.randomUUID(); + const slug = `tst_mono_happy_${token}`; + const sku = `tst_mono_happy_${token}`; + const now = new Date(); + + await db.insert(products).values({ + id: productId, + slug, + sku, + title: `Test ${slug}`, + description: 'Hermetic checkout product', + imageUrl: 'https://example.test/monobank-happy-path.png', + imagePublicId: null, + price: toDbMoney(args.priceMinor), + originalPrice: null, + currency: args.currency, + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: args.stock, + createdAt: now, + updatedAt: now, + } as any); + + try { + await db.insert(productPrices).values({ + productId, + currency: 'UAH', + priceMinor: args.priceMinor, + originalPriceMinor: null, + price: toDbMoney(args.priceMinor), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any); + } catch (error) { + await db.delete(products).where(eq(products.id, productId)); + throw error; + } + + return { productId }; +} + +async function cleanupOrder(orderId: string) { + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +async function cleanupProduct(productId: string) { + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +} + +async function postCheckout(idemKey: string, productId: string) { + const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { + POST: (req: NextRequest) => Promise; + }; + + const req = new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-language': 'uk-UA', + 'idempotency-key': idemKey, + 'x-request-id': `mono-happy-${idemKey}`, + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + items: [{ productId, quantity: 1 }], + paymentProvider: 'monobank', + }), + }); + + return mod.POST(req); +} + +describe.sequential('checkout monobank happy path', () => { + assertNotProductionDb(); + + it('creates order+attempt and returns pageUrl with Monobank provider contract', async () => { + const { productId } = await insertTestProductWithPrice({ + stock: 3, + priceMinor: 1000, + currency: 'USD', + }); + const idemKey = crypto.randomUUID(); + let orderId: string | undefined; + + try { + const res = await postCheckout(idemKey, productId); + expect(res.status).toBe(201); + + const json: any = await res.json(); + + if (typeof json.orderId !== 'string') + throw new Error('orderId must be string'); + const orderIdStr: string = json.orderId; + orderId = orderIdStr; + + if (typeof json.attemptId !== 'string') + throw new Error('attemptId must be string'); + const attemptIdStr: string = json.attemptId; + + expect(json.success).toBe(true); + expect(json.provider).toBe('mono'); + expect(json.currency).toBe('UAH'); + expect(typeof json.totalAmountMinor).toBe('number'); + expect(json.totalAmountMinor).toBe(1000); + expect(typeof json.pageUrl).toBe('string'); + expect(json.pageUrl).toMatch(/^https:\/\/pay\.test\/inv_/); + + const [dbOrder] = await db + .select({ + id: orders.id, + currency: orders.currency, + totalAmountMinor: orders.totalAmountMinor, + paymentProvider: orders.paymentProvider, + paymentStatus: orders.paymentStatus, + pspChargeId: orders.pspChargeId, + }) + .from(orders) + .where(eq(orders.id, orderIdStr)) + .limit(1); + + expect(dbOrder).toBeTruthy(); + expect(dbOrder?.currency).toBe('UAH'); + expect(dbOrder?.totalAmountMinor).toBe(1000); + expect(dbOrder?.paymentProvider).toBe('monobank'); + expect(dbOrder?.paymentStatus).toBe('pending'); + expect(typeof dbOrder?.pspChargeId).toBe('string'); + + const [attempt] = await db + .select({ + id: paymentAttempts.id, + provider: paymentAttempts.provider, + currency: paymentAttempts.currency, + expectedAmountMinor: paymentAttempts.expectedAmountMinor, + providerPaymentIntentId: paymentAttempts.providerPaymentIntentId, + checkoutUrl: paymentAttempts.checkoutUrl, + metadata: paymentAttempts.metadata, + }) + .from(paymentAttempts) + .where( + and( + eq(paymentAttempts.orderId, orderIdStr), + eq(paymentAttempts.provider, 'monobank') + ) + ) + .limit(1); + + expect(attempt).toBeTruthy(); + expect(attempt?.id).toBe(attemptIdStr); + expect(attempt?.provider).toBe('monobank'); + expect(attempt?.currency).toBe('UAH'); + expect(attempt?.expectedAmountMinor).toBe(1000); + expect(typeof attempt?.providerPaymentIntentId).toBe('string'); + expect(attempt?.providerPaymentIntentId).toBe(dbOrder?.pspChargeId); + + const attemptMeta = (attempt?.metadata ?? {}) as Record; + const persistedPageUrl = + attempt?.checkoutUrl ?? + (typeof attemptMeta.pageUrl === 'string' ? attemptMeta.pageUrl : null); + + expect(persistedPageUrl).toBe(json.pageUrl); + expect(attemptMeta.invoiceId).toBe(attempt?.providerPaymentIntentId); + + expect(createMonobankInvoiceMock).toHaveBeenCalledTimes(1); + } finally { + if (orderId) { + await cleanupOrder(orderId).catch(() => undefined); + } + await cleanupProduct(productId).catch(() => undefined); + } + }, 20_000); +}); diff --git a/frontend/lib/tests/shop/checkout-rate-limit-policy.test.ts b/frontend/lib/tests/shop/checkout-rate-limit-policy.test.ts new file mode 100644 index 00000000..76d1368c --- /dev/null +++ b/frontend/lib/tests/shop/checkout-rate-limit-policy.test.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const enforceRateLimitMock = vi.fn(); +const createOrderWithItemsMock = vi.fn(); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), + }; +}); + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn(async () => null), +})); + +vi.mock('@/lib/security/origin', () => ({ + guardBrowserSameOrigin: vi.fn(() => null), +})); + +vi.mock('@/lib/security/rate-limit', () => ({ + getRateLimitSubject: vi.fn(() => 'rl_subject'), + enforceRateLimit: (...args: any[]) => enforceRateLimitMock(...args), + rateLimitResponse: ({ + retryAfterSeconds, + details, + }: { + retryAfterSeconds: number; + details?: Record; + }) => { + const res = NextResponse.json( + { + success: false, + code: 'RATE_LIMITED', + retryAfterSeconds, + ...(details ? { details } : {}), + }, + { status: 429 } + ); + res.headers.set('Retry-After', String(retryAfterSeconds)); + res.headers.set('Cache-Control', 'no-store'); + return res; + }, +})); + +vi.mock('@/lib/services/orders', () => ({ + createOrderWithItems: (...args: any[]) => createOrderWithItemsMock(...args), + restockOrder: vi.fn(), +})); + +vi.mock('@/lib/services/orders/payment-attempts', () => ({ + ensureStripePaymentIntentForOrder: vi.fn(), + PaymentAttemptsExhaustedError: class PaymentAttemptsExhaustedError extends Error {}, +})); + +const { POST } = await import('@/app/api/shop/checkout/route'); + +describe('checkout rate limit policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + enforceRateLimitMock.mockResolvedValue({ + ok: false, + retryAfterSeconds: 17, + }); + }); + + it('returns 429 + Retry-After + no-store when checkout limiter blocks', async () => { + const req = new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'idempotency-key': 'idem_key_12345678', + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + items: [ + { + productId: '00000000-0000-4000-8000-000000000001', + quantity: 1, + }, + ], + }), + }); + + const res = await POST(req); + const json: any = await res.json(); + + expect(res.status).toBe(429); + expect(res.headers.get('Retry-After')).toBe('17'); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(json.code).toBe('RATE_LIMITED'); + expect(json.details?.scope).toBe('checkout'); + expect(enforceRateLimitMock).toHaveBeenCalledTimes(1); + expect(createOrderWithItemsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-attempt-invoice.test.ts b/frontend/lib/tests/shop/monobank-attempt-invoice.test.ts index 7a7af6f7..dbf41c1a 100644 --- a/frontend/lib/tests/shop/monobank-attempt-invoice.test.ts +++ b/frontend/lib/tests/shop/monobank-attempt-invoice.test.ts @@ -6,6 +6,17 @@ vi.mock('@/lib/logging', () => ({ logInfo: vi.fn(), })); +vi.mock('@/db', () => ({ + db: new Proxy( + {}, + { + get() { + throw new Error('[unit-test] DB access is not allowed here'); + }, + } + ), +})); + import { PspInvoicePersistError, PspUnavailableError, diff --git a/frontend/lib/tests/shop/monobank-janitor-job1.test.ts b/frontend/lib/tests/shop/monobank-janitor-job1.test.ts index f2d56052..00477ceb 100644 --- a/frontend/lib/tests/shop/monobank-janitor-job1.test.ts +++ b/frontend/lib/tests/shop/monobank-janitor-job1.test.ts @@ -83,7 +83,9 @@ async function cleanup(orderId: string, invoiceId: string) { await db.delete(orders).where(eq(orders.id, orderId)); } -function makeArgs(override?: Partial[0]>) { +function makeArgs( + override?: Partial[0]> +) { return { dryRun: false, limit: 20, @@ -228,13 +230,14 @@ describe.sequential('monobank janitor job1', () => { }); it('rerun is idempotent: second run is noop and does not re-apply transitions', async () => { - vi.stubEnv('MONO_JANITOR_JOB1_GRACE_SECONDS', '0'); + vi.stubEnv('MONO_JANITOR_JOB1_GRACE_SECONDS', '1'); const invoiceId = `inv_${crypto.randomUUID()}`; - const staleAt = new Date(Date.now() - 2 * 60 * 60 * 1000); + + const veryOld = new Date(Date.now() - 20 * 365 * 24 * 60 * 60 * 1000); const { orderId, attemptId } = await insertOrderAndAttempt({ invoiceId, - updatedAt: staleAt, + updatedAt: veryOld, }); const rawPayload = { @@ -244,6 +247,7 @@ describe.sequential('monobank janitor job1', () => { ccy: 980, modifiedDate: 1700000000002, }; + getInvoiceStatusMock.mockResolvedValue({ invoiceId, status: 'processing', @@ -252,60 +256,20 @@ describe.sequential('monobank janitor job1', () => { try { const first = await runMonobankJanitorJob1(makeArgs()); - expect(first).toEqual({ - processed: 1, - applied: 0, - noop: 1, - failed: 0, - }); + expect(first).toEqual({ processed: 1, applied: 0, noop: 1, failed: 0 }); await db .update(paymentAttempts) - .set({ - updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), - }) + .set({ updatedAt: veryOld }) .where(eq(paymentAttempts.id, attemptId)); const second = await runMonobankJanitorJob1(makeArgs()); - expect(second).toEqual({ - processed: 1, - applied: 0, - noop: 1, - failed: 0, - }); - expect(getInvoiceStatusMock).toHaveBeenCalledTimes(2); - - const [order] = await db - .select({ - paymentStatus: orders.paymentStatus, - status: orders.status, - }) - .from(orders) - .where(eq(orders.id, orderId)) - .limit(1); - expect(order?.paymentStatus).toBe('pending'); - expect(order?.status).toBe('INVENTORY_RESERVED'); + expect(second).toEqual({ processed: 1, applied: 0, noop: 1, failed: 0 }); - const [attempt] = await db - .select({ - status: paymentAttempts.status, - janitorClaimedUntil: paymentAttempts.janitorClaimedUntil, - janitorClaimedBy: paymentAttempts.janitorClaimedBy, - }) - .from(paymentAttempts) - .where(eq(paymentAttempts.id, attemptId)) - .limit(1); - expect(attempt?.status).toBe('active'); - expect(attempt?.janitorClaimedUntil).toBeNull(); - expect(attempt?.janitorClaimedBy).toBeNull(); + expect(getInvoiceStatusMock).toHaveBeenCalledTimes(2); - const events = await db - .select({ id: monobankEvents.id }) - .from(monobankEvents) - .where(eq(monobankEvents.invoiceId, invoiceId)); - expect(events.length).toBe(1); } finally { await cleanup(orderId, invoiceId); } - }); + }, 15000); }); diff --git a/frontend/lib/tests/shop/monobank-janitor-origin-posture.test.ts b/frontend/lib/tests/shop/monobank-janitor-origin-posture.test.ts new file mode 100644 index 00000000..ba97dfde --- /dev/null +++ b/frontend/lib/tests/shop/monobank-janitor-origin-posture.test.ts @@ -0,0 +1,123 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const dbExecuteMock = vi.fn(); +const runMonobankJanitorJob1Mock = vi.fn(); +const runMonobankJanitorJob2Mock = vi.fn(); +const runMonobankJanitorJob3Mock = vi.fn(); +const runMonobankJanitorJob4Mock = vi.fn(); + +vi.mock('@/db', () => ({ + db: { + execute: dbExecuteMock, + }, +})); + +vi.mock('@/lib/services/orders/monobank-janitor', () => ({ + runMonobankJanitorJob1: runMonobankJanitorJob1Mock, + runMonobankJanitorJob2: runMonobankJanitorJob2Mock, + runMonobankJanitorJob3: runMonobankJanitorJob3Mock, + runMonobankJanitorJob4: runMonobankJanitorJob4Mock, +})); + +vi.mock('@/lib/logging', () => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), + logError: vi.fn(), +})); + +const { POST } = await import('@/app/api/shop/internal/monobank/janitor/route'); + +function makeReq(args: { + body: unknown; + secret?: string; + origin?: string; + requestId?: string; +}) { + const headers = new Headers(); + headers.set('content-type', 'application/json'); + if (args.secret) headers.set('x-internal-janitor-secret', args.secret); + if (args.origin) headers.set('origin', args.origin); + if (args.requestId) headers.set('x-request-id', args.requestId); + + return new NextRequest( + 'http://localhost/api/shop/internal/monobank/janitor', + { + method: 'POST', + headers, + body: JSON.stringify(args.body), + } + ); +} + +describe('internal monobank janitor origin posture', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('INTERNAL_JANITOR_SECRET', 'test-secret'); + vi.stubEnv('INTERNAL_SECRET', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('rejects requests with Origin header using ORIGIN_BLOCKED + no-store', async () => { + const req = makeReq({ + body: { job: 'job1' }, + secret: 'test-secret', + origin: 'http://localhost:3000', + requestId: 'req-origin-blocked', + }); + + const failIfBodyRead = vi.fn(async () => { + throw new Error('BODY_READ_BEFORE_ORIGIN_GUARD'); + }); + (req as any).arrayBuffer = failIfBodyRead; + (req as any).text = failIfBodyRead; + (req as any).json = failIfBodyRead; + (req as any).formData = failIfBodyRead; + + const res = await POST(req); + + const json: any = await res.json(); + expect(res.status).toBe(403); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(res.headers.get('X-Request-Id')).toBe('req-origin-blocked'); + expect(json).toMatchObject({ + error: { code: 'ORIGIN_BLOCKED' }, + surface: 'monobank_janitor', + }); + expect(typeof json?.error?.message).toBe('string'); + expect(dbExecuteMock).not.toHaveBeenCalled(); + expect(runMonobankJanitorJob1Mock).not.toHaveBeenCalled(); + expect(failIfBodyRead).not.toHaveBeenCalled(); + }); + + it('continues processing when browser indicators are absent', async () => { + dbExecuteMock.mockResolvedValueOnce({ + rows: [{ next_allowed_at: new Date(Date.now() + 1000).toISOString() }], + }); + runMonobankJanitorJob1Mock.mockResolvedValueOnce({ + processed: 1, + applied: 1, + noop: 0, + failed: 0, + }); + + const req = makeReq({ + body: { job: 'job1' }, + secret: 'test-secret', + requestId: 'req-origin-allow', + }); + + const res = await POST(req); + const body = await res.json(); + expect(body.success).toBe(true); + + expect(res.status).toBe(200); + expect(res.headers.get('X-Request-Id')).toBe('req-origin-allow'); + expect(runMonobankJanitorJob1Mock).toHaveBeenCalledTimes(1); + expect(dbExecuteMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-logging-safety.test.ts b/frontend/lib/tests/shop/monobank-logging-safety.test.ts new file mode 100644 index 00000000..fb74f931 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-logging-safety.test.ts @@ -0,0 +1,234 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +type FileEntry = { abs: string; rel: string; text: string }; + +const REQUIRED_MONO_CODES = [ + 'MONO_SIG_INVALID', + 'MONO_PUBKEY_REFRESHED', + 'MONO_DEDUP', + 'MONO_OLD_EVENT', + 'MONO_MISMATCH', + 'MONO_PAID_APPLIED', + 'MONO_REFUND_APPLIED', + 'MONO_STORE_MODE', + 'MONO_CREATE_INVOICE_FAILED', + 'MONO_EXPIRED_RECONCILED', +] as const; + +function isIgnoredDir(name: string) { + return ( + name === 'node_modules' || + name === '.next' || + name === '.git' || + name === 'dist' || + name === 'build' || + name === 'coverage' || + name === '.turbo' + ); +} + +async function walk(dir: string): Promise { + const out: string[] = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const e of entries) { + const abs = path.join(dir, e.name); + if (e.isDirectory()) { + if (isIgnoredDir(e.name)) continue; + out.push(...(await walk(abs))); + continue; + } + out.push(abs); + } + + return out; +} + +async function readText(abs: string): Promise { + const ext = path.extname(abs).toLowerCase(); + if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext)) return null; + + try { + return await fs.readFile(abs, 'utf8'); + } catch { + return null; + } +} + +function toPosix(p: string) { + return p.replaceAll(path.sep, '/'); +} + +function isProdCode(rel: string) { + const r = toPosix(rel); + if (r.includes('/lib/tests/')) return false; + if (r.includes('.test.')) return false; + if (r.includes('.spec.')) return false; + return true; +} + +function isMonobankRelated(rel: string, text: string) { + const r = toPosix(rel).toLowerCase(); + if (r.includes('monobank')) return true; + if (r.includes('/app/api/shop/checkout/route.ts')) return true; + if (text.includes('MONO_')) return true; + if (text.toLowerCase().includes('monobank')) return true; + if (text.includes("provider: 'monobank'")) return true; + if (text.includes("paymentProvider: 'monobank'")) return true; + + return false; +} + +async function loadFrontendEntries(): Promise { + const here = path.dirname(fileURLToPath(import.meta.url)); + const frontendRoot = path.resolve(here, '../../..'); + + const roots = [ + path.join(frontendRoot, 'app'), + path.join(frontendRoot, 'lib'), + ]; + + const files: string[] = []; + for (const r of roots) { + try { + files.push(...(await walk(r))); + } catch { + // ignore missing roots + } + } + + const entries: FileEntry[] = []; + for (const abs of files) { + const text = await readText(abs); + if (text === null) continue; + const rel = path.relative(frontendRoot, abs); + entries.push({ abs, rel, text }); + } + + return entries; +} + +describe('monobank logging safety (I1)', () => { + it('required MONO_* codes exist in app/lib', async () => { + const entries = await loadFrontendEntries(); + const hay = entries.map(e => e.text).join('\n'); + + const missing = REQUIRED_MONO_CODES.filter(code => !hay.includes(code)); + expect(missing).toEqual([]); + }); + + it('no console.* in prod monobank-related code', async () => { + const entries = await loadFrontendEntries(); + const offenders: Array<{ file: string; sample: string }> = []; + + for (const e of entries) { + if (!isProdCode(e.rel)) continue; + if (!isMonobankRelated(e.rel, e.text)) continue; + + const m = e.text.match(/console\.(log|info|warn|error|debug)\s*\(/); + if (m) { + const idx = e.text.indexOf(m[0]); + const sample = e.text.slice( + Math.max(0, idx - 80), + Math.min(e.text.length, idx + 120) + ); + offenders.push({ file: toPosix(e.rel), sample }); + } + } + + expect(offenders).toEqual([]); + }); + + it('no obvious payload/PII/secret keys inside logging meta objects (prod monobank-related)', async () => { + const entries = await loadFrontendEntries(); + const offenders: Array<{ file: string; match: string }> = []; + + const forbidden = [ + 'body', + 'headers', + 'payload', + 'raw', + 'statusToken', + 'authorization', + 'cookie', + 'set-cookie', + 'email', + 'phone', + 'card', + 'pan', + 'cvv', + 'merchantPaymInfo', + 'basketOrder', + 'items', + ]; + + function hasForbiddenMetaKey(chunk: string): boolean { + for (const key of forbidden) { + const simpleIdent = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key); + if (simpleIdent) { + const rx = new RegExp(`(^|[,{]\\s*)${key}\\s*:`, 'i'); + if (rx.test(chunk)) return true; + } + const qrx = new RegExp(`(^|[,{]\\s*)(['"])${key}\\2\\s*:`, 'i'); + if (qrx.test(chunk)) return true; + } + return false; + } + const patterns: RegExp[] = [ + /(logInfo|logWarn)\(\s*['"`][^'"`]+['"`]\s*,\s*{[\s\S]*?}\s*\)/g, + /logError\(\s*['"`][^'"`]+['"`]\s*,\s*[^,]+,\s*{[\s\S]*?}\s*\)/g, + /monoLogWarn\(\s*[^,]+,\s*{[\s\S]*?}\s*\)/g, + /monoLogError\(\s*[^,]+,\s*[^,]+,\s*{[\s\S]*?}\s*\)/g, + ]; + + for (const e of entries) { + if (!isProdCode(e.rel)) continue; + if (!isMonobankRelated(e.rel, e.text)) continue; + + for (const rx of patterns) { + let m: RegExpExecArray | null; + while ((m = rx.exec(e.text))) { + const chunk = m[0]; + if (hasForbiddenMetaKey(chunk)) { + offenders.push({ + file: toPosix(e.rel), + match: chunk.slice(0, 240), + }); + break; + } + } + } + } + + expect(offenders).toEqual([]); + }); + + it('if a monobank webhook reads raw body text(), it must contain sha256 hashing in the same file', async () => { + const entries = await loadFrontendEntries(); + const offenders: Array<{ file: string }> = []; + + for (const e of entries) { + if (!isProdCode(e.rel)) continue; + + const r = toPosix(e.rel).toLowerCase(); + const isWebhookish = + r.includes('webhook') || e.text.toLowerCase().includes('webhook'); + if (!isWebhookish) continue; + if (!isMonobankRelated(e.rel, e.text)) continue; + + const readsRaw = /\.\s*text\s*\(\s*\)/.test(e.text); + if (!readsRaw) continue; + + const hasSha = + /sha256/i.test(e.text) || + /createHash\(\s*['"]sha256['"]\s*\)/i.test(e.text); + if (!hasSha) offenders.push({ file: toPosix(e.rel) }); + } + + expect(offenders).toEqual([]); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-refund-rate-limit-policy.test.ts b/frontend/lib/tests/shop/monobank-refund-rate-limit-policy.test.ts new file mode 100644 index 00000000..f0177e5a --- /dev/null +++ b/frontend/lib/tests/shop/monobank-refund-rate-limit-policy.test.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const enforceRateLimitMock = vi.fn( + async (..._args: any[]) => ({ ok: false, retryAfterSeconds: 9 }) +); +const requireAdminApiMock = vi.fn( + async (..._args: any[]) => ({ id: 'admin:root', role: 'admin' }) +); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: vi.fn(), + logError: vi.fn(), + }; +}); + +vi.mock('@/lib/auth/admin', () => ({ + requireAdminApi: requireAdminApiMock, + AdminApiDisabledError: class AdminApiDisabledError extends Error {}, + AdminUnauthorizedError: class AdminUnauthorizedError extends Error { + code = 'UNAUTHORIZED'; + }, + AdminForbiddenError: class AdminForbiddenError extends Error { + code = 'FORBIDDEN'; + }, +})); + +vi.mock('@/lib/security/admin-csrf', () => ({ + requireAdminCsrf: vi.fn(() => null), +})); + +vi.mock('@/lib/security/origin', () => ({ + guardBrowserSameOrigin: vi.fn(() => null), +})); + +vi.mock('@/lib/security/rate-limit', async () => { + const actual = await vi.importActual('@/lib/security/rate-limit'); + return { + ...actual, + enforceRateLimit: enforceRateLimitMock, + rateLimitResponse: ({ + retryAfterSeconds, + details, + }: { + retryAfterSeconds: number; + details?: Record; + }) => { + const res = NextResponse.json( + { + success: false, + code: 'RATE_LIMITED', + retryAfterSeconds, + ...(details ? { details } : {}), + }, + { status: 429 } + ); + res.headers.set('Retry-After', String(retryAfterSeconds)); + res.headers.set('Cache-Control', 'no-store'); + return res; + }, + }; +}); + +const { POST } = await import('@/app/api/shop/admin/orders/[id]/refund/route'); + +describe('monobank admin refund rate limit policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + enforceRateLimitMock.mockResolvedValue({ + ok: false, + retryAfterSeconds: 9, + }); + }); + + it('returns 429 + Retry-After + no-store when admin refund limiter blocks', async () => { + const req = new NextRequest( + 'http://localhost/api/shop/admin/orders/00000000-0000-4000-8000-000000000001/refund', + { + method: 'POST', + headers: { + origin: 'http://localhost:3000', + }, + } + ); + + const res = await POST(req, { + params: Promise.resolve({ id: '00000000-0000-4000-8000-000000000001' }), + }); + const json: any = await res.json(); + + expect(res.status).toBe(429); + expect(res.headers.get('Retry-After')).toBe('9'); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(json.code).toBe('RATE_LIMITED'); + expect(json.details?.scope).toBe('admin_refund'); + expect(enforceRateLimitMock).toHaveBeenCalledTimes(1); + expect(requireAdminApiMock).toHaveBeenCalledTimes(1); + expect((enforceRateLimitMock.mock.calls[0]?.[0]?.key as string) ?? '').toContain( + 'admin_refund:admin_' + ); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts b/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts index b0010f64..a2e3b115 100644 --- a/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts +++ b/frontend/lib/tests/shop/monobank-refund-route-f4.test.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; import { and, eq } from 'drizzle-orm'; -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '@/db'; @@ -9,7 +9,7 @@ import { resetEnvCache } from '@/lib/env'; import { toDbMoney } from '@/lib/shop/money'; vi.mock('@/lib/auth/admin', () => ({ - requireAdminApi: vi.fn(async () => {}), + requireAdminApi: vi.fn(async () => ({ id: 'admin:root', role: 'admin' })), AdminApiDisabledError: class AdminApiDisabledError extends Error {}, AdminUnauthorizedError: class AdminUnauthorizedError extends Error { code = 'ADMIN_UNAUTHORIZED'; @@ -23,6 +23,35 @@ vi.mock('@/lib/security/admin-csrf', () => ({ requireAdminCsrf: vi.fn(() => null), })); +vi.mock('@/lib/security/rate-limit', async () => { + const actual = await vi.importActual('@/lib/security/rate-limit'); + return { + ...actual, + getRateLimitSubject: vi.fn(() => 'rl_admin_refund_test'), + enforceRateLimit: vi.fn(async () => ({ ok: true, retryAfterSeconds: 0 })), + rateLimitResponse: ({ + retryAfterSeconds, + details, + }: { + retryAfterSeconds: number; + details?: Record; + }) => { + const res = NextResponse.json( + { + success: false, + code: 'RATE_LIMITED', + retryAfterSeconds, + ...(details ? { details } : {}), + }, + { status: 429 } + ); + res.headers.set('Retry-After', String(retryAfterSeconds)); + res.headers.set('Cache-Control', 'no-store'); + return res; + }, + }; +}); + vi.mock('@/lib/services/orders', () => ({ refundOrder: vi.fn(async () => { throw new Error('refundOrder should not be called for monobank'); diff --git a/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts b/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts index 587802d9..196e9f3d 100644 --- a/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts +++ b/frontend/lib/tests/shop/monobank-webhook-apply-outcomes.test.ts @@ -83,6 +83,7 @@ vi.mock('@/lib/logging', () => { import { logError } from '@/lib/logging'; import { applyMonoWebhookEvent } from '@/lib/services/orders/monobank-webhook'; import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; function sha256Hex(buf: Buffer): string { return crypto.createHash('sha256').update(buf).digest('hex'); @@ -200,6 +201,7 @@ async function fetchEventByRawSha256(rawSha256: string) { id, invoice_id, status, + applied_at, applied_result, applied_error_code, applied_error_message, @@ -214,6 +216,34 @@ async function fetchEventByRawSha256(rawSha256: string) { return res.rows?.[0] ?? null; } +async function fetchOrderAttemptState(args: { orderId: string; attemptId: string }) { + const orderRes = await db.execute(sql` + select + id, + payment_status, + payment_provider + from orders + where id = ${args.orderId}::uuid + limit 1 + `); + const attemptRes = await db.execute(sql` + select + id, + status, + last_error_code, + provider_modified_at, + finalized_at + from payment_attempts + where id = ${args.attemptId}::uuid + limit 1 + `); + + return { + order: readRows(orderRes)[0] ?? null, + attempt: readRows(attemptRes)[0] ?? null, + }; +} + async function cleanup(args: { orderId: string; attemptId: string; @@ -229,6 +259,8 @@ async function cleanup(args: { } describe('monobank-webhook apply outcomes', () => { + assertNotProductionDb(); + beforeEach(() => { vi.clearAllMocks(); }); @@ -423,4 +455,244 @@ describe('monobank-webhook apply outcomes', () => { await cleanup({ orderId, attemptId, rawSha256 }); } }); + + it('paid-is-sticky out-of-order: success first, then older processing keeps order paid', async () => { + ( + guardedPaymentStatusUpdate as unknown as ReturnType + ).mockResolvedValue({ + applied: false, + currentProvider: 'monobank', + from: 'paid', + reason: 'already_in_state', + }); + + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + const now = Date.now(); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'paid', + }); + await insertAttempt({ + attemptId, + orderId, + status: 'succeeded', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: new Date(now), + }); + + const successPayload = { + invoiceId, + status: 'success', + amount: 100, + ccy: 980, + reference: attemptId, + modifiedAt: new Date(now).toISOString(), + }; + const processingPayload = { + invoiceId, + status: 'processing', + amount: 100, + ccy: 980, + reference: attemptId, + modifiedAt: new Date(now - 60_000).toISOString(), + }; + + const successRaw = JSON.stringify(successPayload); + const processingRaw = JSON.stringify(processingPayload); + const successRawSha256 = sha256Hex(Buffer.from(successRaw, 'utf8')); + const processingRawSha256 = sha256Hex(Buffer.from(processingRaw, 'utf8')); + + try { + const first = await applyMonoWebhookEvent({ + rawBody: successRaw, + parsedPayload: successPayload as any, + requestId: 'test_paid_sticky_success_first', + mode: 'apply', + rawSha256: successRawSha256, + eventKey: successRawSha256, + }); + expect(first.appliedResult).toBe('applied_noop'); + + const second = await applyMonoWebhookEvent({ + rawBody: processingRaw, + parsedPayload: processingPayload as any, + requestId: 'test_paid_sticky_older_processing', + mode: 'apply', + rawSha256: processingRawSha256, + eventKey: processingRawSha256, + }); + + expect(second.appliedResult).toBe('applied_noop'); + + const state = await fetchOrderAttemptState({ orderId, attemptId }); + expect(state.order?.payment_status).toBe('paid'); + expect(state.attempt?.status).toBe('succeeded'); + + const secondEvent = await fetchEventByRawSha256(processingRawSha256); + expect(secondEvent?.applied_result).toBe('applied_noop'); + expect(secondEvent?.applied_error_code).toBe('OUT_OF_ORDER'); + } finally { + await db.execute( + sql`delete from monobank_events where raw_sha256 in (${successRawSha256}, ${processingRawSha256})` + ); + await db.execute( + sql`delete from payment_attempts where id = ${attemptId}::uuid` + ); + await db.execute(sql`delete from orders where id = ${orderId}::uuid`); + } + }); + + it('dedupe: second processing of the same event is no-op and does not rewrite applied timestamp', async () => { + ( + guardedPaymentStatusUpdate as unknown as ReturnType + ).mockResolvedValue({ + applied: true, + currentProvider: 'monobank', + from: 'pending', + reason: null, + }); + + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: null, + }); + + const payload = { + invoiceId, + status: 'success', + amount: 100, + ccy: 980, + reference: attemptId, + }; + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const first = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_dedupe_first', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + expect(first.appliedResult).toBe('applied'); + expect(first.deduped).toBe(false); + + const beforeEvent = await fetchEventByRawSha256(rawSha256); + const beforeState = await fetchOrderAttemptState({ orderId, attemptId }); + + const second = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_dedupe_second', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(second.appliedResult).toBe('deduped'); + expect(second.deduped).toBe(true); + + const afterEvent = await fetchEventByRawSha256(rawSha256); + const afterState = await fetchOrderAttemptState({ orderId, attemptId }); + + expect(afterEvent?.id).toBe(beforeEvent?.id); + expect(afterEvent?.applied_result).toBe(beforeEvent?.applied_result); + expect( + String(afterEvent?.applied_at ?? '') + ).toBe(String(beforeEvent?.applied_at ?? '')); + expect(afterState).toEqual(beforeState); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); + + it('mismatch must NOT set paid: applied_with_issue and attempt failure markers are persisted', async () => { + ( + guardedPaymentStatusUpdate as unknown as ReturnType + ).mockResolvedValue({ + applied: false, + currentProvider: 'monobank', + from: 'pending', + reason: 'blocked_for_test', + }); + + const orderId = uuid(); + const attemptId = uuid(); + const invoiceId = 'inv_' + uuid().replace(/-/g, '').slice(0, 24); + + await insertOrder({ + orderId, + currency: 'UAH', + totalAmountMinor: 100, + paymentProvider: 'monobank', + paymentStatus: 'pending', + }); + await insertAttempt({ + attemptId, + orderId, + status: 'pending', + expectedAmountMinor: 100, + invoiceId, + providerModifiedAt: null, + }); + + const payload = { + invoiceId, + status: 'success', + amount: 101, + ccy: 980, + reference: attemptId, + }; + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256Hex(Buffer.from(rawBody, 'utf8')); + + try { + const res = await applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload as any, + requestId: 'test_mismatch_not_paid', + mode: 'apply', + rawSha256, + eventKey: rawSha256, + }); + + expect(res.appliedResult).toBe('applied_with_issue'); + + const state = await fetchOrderAttemptState({ orderId, attemptId }); + expect(state.order?.payment_status).not.toBe('paid'); + expect(state.order?.payment_status).toBe('pending'); + expect(state.attempt?.status).toBe('failed'); + expect(state.attempt?.last_error_code).toBe('AMOUNT_MISMATCH'); + + const ev = await fetchEventByRawSha256(rawSha256); + expect(ev?.applied_result).toBe('applied_with_issue'); + expect(ev?.applied_error_code).toBe('AMOUNT_MISMATCH'); + } finally { + await cleanup({ orderId, attemptId, rawSha256 }); + } + }); }); diff --git a/frontend/lib/tests/shop/monobank-webhook-logging-safety.test.ts b/frontend/lib/tests/shop/monobank-webhook-logging-safety.test.ts new file mode 100644 index 00000000..679dcaea --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-logging-safety.test.ts @@ -0,0 +1,147 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const monoLogInfoMock = vi.fn(); +const monoLogWarnMock = vi.fn(); +const monoLogErrorMock = vi.fn(); + +const logInfoMock = vi.fn(); +const logWarnMock = vi.fn(); +const logErrorMock = vi.fn(); + +const verifyWebhookSignatureWithRefreshMock = vi.fn( + async (..._args: unknown[]) => false +); +const handleMonobankWebhookMock = vi.fn(async (..._args: unknown[]) => ({ + invoiceId: 'inv_test', + appliedResult: 'applied', + deduped: false, +})); + +vi.mock('@/lib/logging/monobank', async () => { + const actual = await vi.importActual('@/lib/logging/monobank'); + return { + ...actual, + monoLogInfo: (...args: unknown[]) => monoLogInfoMock(...args), + monoLogWarn: (...args: unknown[]) => monoLogWarnMock(...args), + monoLogError: (...args: unknown[]) => monoLogErrorMock(...args), + }; +}); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logInfo: (...args: unknown[]) => logInfoMock(...args), + logWarn: (...args: unknown[]) => logWarnMock(...args), + logError: (...args: unknown[]) => logErrorMock(...args), + }; +}); + +vi.mock('@/lib/psp/monobank', () => ({ + verifyWebhookSignatureWithRefresh: (...args: unknown[]) => + verifyWebhookSignatureWithRefreshMock(...args), +})); + +vi.mock('@/lib/services/orders/monobank-webhook', () => ({ + handleMonobankWebhook: (...args: unknown[]) => + handleMonobankWebhookMock(...args), +})); + +vi.mock('@/lib/security/rate-limit', () => ({ + getRateLimitSubject: vi.fn(() => 'rl_test_subject'), + enforceRateLimit: vi.fn(async () => ({ ok: true, remaining: 999 })), + rateLimitResponse: vi.fn(() => new Response('rate_limited', { status: 429 })), +})); + +function expectNoUnsafeMeta(meta: Record) { + const forbidden = [ + 'rawBodyBytes', + 'rawBody', + 'parsedPayload', + 'headers', + 'authorization', + 'cookie', + 'statusToken', + 'basket', + 'basketOrder', + 'email', + 'phone', + ]; + for (const key of forbidden) { + expect(meta).not.toHaveProperty(key); + } +} + +function expectSafeShape(meta: Record) { + expect(typeof meta.rawSha256).toBe('string'); + expect((meta.rawSha256 as string).length).toBe(64); + expect(meta.rawBytesLen).toBeTypeOf('number'); + expect(meta.mode).toBe('apply'); + expect(meta.hasXSign).toBe(true); +} + +async function postWebhookRaw(rawBody: string, signature = 'test-signature') { + const { POST } = await import('@/app/api/shop/webhooks/monobank/route'); + + const req = new NextRequest('http://localhost/api/shop/webhooks/monobank', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-sign': signature, + 'x-request-id': 'mono-webhook-logging-safety-test', + }, + body: rawBody, + }); + + return POST(req); +} + +beforeEach(() => { + vi.clearAllMocks(); + process.env.MONO_WEBHOOK_MODE = 'apply'; +}); + +afterEach(() => { + delete process.env.MONO_WEBHOOK_MODE; +}); + +describe('monobank webhook logging safety', () => { + it('invalid signature logs safe diagnostics only', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(false); + + const res = await postWebhookRaw( + JSON.stringify({ invoiceId: 'inv_1', status: 'success' }) + ); + expect(res.status).toBe(200); + expect(handleMonobankWebhookMock).not.toHaveBeenCalled(); + + const sigWarn = monoLogWarnMock.mock.calls.find( + call => call?.[0] === 'MONO_SIG_INVALID' + ); + expect(sigWarn).toBeTruthy(); + + const meta = (sigWarn?.[1] ?? {}) as Record; + expectSafeShape(meta); + expect(meta.reason).toBe('SIG_INVALID'); + expectNoUnsafeMeta(meta); + }); + + it('invalid payload logs safe diagnostics only', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(true); + + const res = await postWebhookRaw('{invalid json'); + expect(res.status).toBe(200); + expect(handleMonobankWebhookMock).not.toHaveBeenCalled(); + + const invalidPayloadWarn = logWarnMock.mock.calls.find( + call => call?.[0] === 'monobank_webhook_payload_invalid' + ); + expect(invalidPayloadWarn).toBeTruthy(); + + const meta = (invalidPayloadWarn?.[1] ?? {}) as Record; + expectSafeShape(meta); + expect(meta.reason).toBe('INVALID_PAYLOAD'); + expectNoUnsafeMeta(meta); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-multi-instance-apply.test.ts b/frontend/lib/tests/shop/monobank-webhook-multi-instance-apply.test.ts new file mode 100644 index 00000000..232d9bf7 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-multi-instance-apply.test.ts @@ -0,0 +1,198 @@ +import crypto from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { buildMonobankAttemptIdempotencyKey } from '@/lib/services/orders/attempt-idempotency'; +import { applyMonoWebhookEvent } from '@/lib/services/orders/monobank-webhook'; +import { toDbMoney } from '@/lib/shop/money'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; + +vi.mock('@/lib/services/orders/restock', () => ({ + restockOrder: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +const sha256HexUtf8 = (s: string) => + crypto.createHash('sha256').update(Buffer.from(s, 'utf8')).digest('hex'); + +async function insertOrderAndAttempt(args: { + invoiceId: string; + amountMinor: number; +}) { + const orderId = crypto.randomUUID(); + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: args.amountMinor, + totalAmount: toDbMoney(args.amountMinor), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + } as any); + + const attemptId = crypto.randomUUID(); + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'active', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: args.amountMinor, + idempotencyKey: buildMonobankAttemptIdempotencyKey(orderId, 1), + providerPaymentIntentId: args.invoiceId, + } as any); + + return { orderId, attemptId }; +} + +async function insertPersistedEvent(args: { + invoiceId: string; + attemptId: string; + orderId: string; + rawSha256: string; + payload: Record; +}) { + await db.insert(monobankEvents).values({ + provider: 'monobank', + eventKey: args.rawSha256, + invoiceId: args.invoiceId, + status: String(args.payload.status ?? 'success'), + amount: + typeof args.payload.amount === 'number' + ? Math.trunc(args.payload.amount) + : null, + ccy: + typeof args.payload.ccy === 'number' + ? Math.trunc(args.payload.ccy) + : null, + reference: + typeof args.payload.reference === 'string' + ? args.payload.reference + : null, + rawPayload: args.payload, + normalizedPayload: args.payload, + attemptId: args.attemptId, + orderId: args.orderId, + rawSha256: args.rawSha256, + } as any); +} + +async function cleanup(orderId: string, invoiceId: string) { + await db + .delete(monobankEvents) + .where(eq(monobankEvents.invoiceId, invoiceId)); + await db.delete(paymentAttempts).where(eq(paymentAttempts.orderId, orderId)); + await db.delete(orders).where(eq(orders.id, orderId)); +} + +describe.sequential('monobank webhook multi-instance apply', () => { + beforeAll(() => { + assertNotProductionDb(); + }); + + it('persisted event + parallel apply -> exactly one apply and no double side effects', async () => { + const invoiceId = `inv_${crypto.randomUUID()}`; + const { orderId, attemptId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + + const payload = { + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + reference: attemptId, + }; + const rawBody = JSON.stringify(payload); + const rawSha256 = sha256HexUtf8(rawBody); + + await insertPersistedEvent({ + invoiceId, + attemptId, + orderId, + rawSha256, + payload, + }); + + try { + const [first, second] = await Promise.all([ + applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload, + rawSha256, + eventKey: rawSha256, + requestId: 'multi-instance-1', + mode: 'apply', + }), + applyMonoWebhookEvent({ + rawBody, + parsedPayload: payload, + rawSha256, + eventKey: rawSha256, + requestId: 'multi-instance-2', + mode: 'apply', + }), + ]); + + const outcomes = [first, second].map(x => x.appliedResult); + const appliedCount = outcomes.filter(x => x === 'applied').length; + expect(appliedCount).toBe(1); + expect(outcomes.some(x => x === 'deduped' || x === 'applied_noop')).toBe( + true + ); + + const [orderRow] = await db + .select({ paymentStatus: orders.paymentStatus }) + .from(orders) + .where(eq(orders.id, orderId)) + .limit(1); + expect(orderRow?.paymentStatus).toBe('paid'); + + const [attemptRow] = await db + .select({ status: paymentAttempts.status }) + .from(paymentAttempts) + .where(eq(paymentAttempts.id, attemptId)) + .limit(1); + expect(attemptRow?.status).toBe('succeeded'); + + const [eventRow] = await db + .select({ + appliedResult: monobankEvents.appliedResult, + appliedAt: monobankEvents.appliedAt, + claimExpiresAt: monobankEvents.claimExpiresAt, + claimedBy: monobankEvents.claimedBy, + }) + .from(monobankEvents) + .where( + and( + eq(monobankEvents.invoiceId, invoiceId), + eq(monobankEvents.rawSha256, rawSha256) + ) + ) + .limit(1); + + expect(eventRow?.appliedResult).toBe('applied'); + expect(eventRow?.appliedAt).toBeTruthy(); + expect(eventRow?.claimExpiresAt).toBeTruthy(); + expect(eventRow?.claimedBy).toBeTruthy(); + } finally { + await cleanup(orderId, invoiceId); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-origin-posture.test.ts b/frontend/lib/tests/shop/monobank-webhook-origin-posture.test.ts new file mode 100644 index 00000000..586b42ae --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-origin-posture.test.ts @@ -0,0 +1,91 @@ +import { NextRequest } from 'next/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const enforceRateLimitMock = vi.fn(async () => ({ + ok: true, + retryAfterSeconds: 0, +})); +const verifyWebhookSignatureWithRefreshMock = vi.fn(async () => true); +const handleMonobankWebhookMock = vi.fn(async () => ({ + invoiceId: 'inv_test', + appliedResult: 'applied', + deduped: false, +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), + }; +}); + +vi.mock('@/lib/logging/monobank', async () => { + const actual = await vi.importActual('@/lib/logging/monobank'); + return { + ...actual, + monoLogWarn: vi.fn(), + monoLogError: vi.fn(), + monoLogInfo: vi.fn(), + }; +}); + +vi.mock('@/lib/psp/monobank', () => ({ + verifyWebhookSignatureWithRefresh: verifyWebhookSignatureWithRefreshMock, +})); + +vi.mock('@/lib/services/orders/monobank-webhook', () => ({ + handleMonobankWebhook: handleMonobankWebhookMock, +})); + +vi.mock('@/lib/security/rate-limit', () => ({ + getRateLimitSubject: vi.fn(() => 'rl_webhook_subject'), + enforceRateLimit: enforceRateLimitMock, + rateLimitResponse: vi.fn(), +})); + +const { POST } = await import('@/app/api/shop/webhooks/monobank/route'); + +describe('monobank webhook origin posture', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects browser-shaped requests with Origin before signature verification', async () => { + const req = new NextRequest('http://localhost/api/shop/webhooks/monobank', { + method: 'POST', + headers: { + 'content-type': 'application/json', + origin: 'http://localhost:3000', + 'x-sign': 'test-signature', + }, + body: JSON.stringify({ invoiceId: 'inv_123', status: 'success' }), + }); + + const failIfBodyRead = vi.fn(async () => { + throw new Error('BODY_READ_BEFORE_ORIGIN_GUARD'); + }); + (req as any).arrayBuffer = failIfBodyRead; + (req as any).text = failIfBodyRead; + (req as any).json = failIfBodyRead; + (req as any).formData = failIfBodyRead; + + const res = await POST(req); + + const json: any = await res.json(); + + expect(res.status).toBe(403); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(json).toMatchObject({ + error: { code: 'ORIGIN_BLOCKED' }, + surface: 'monobank_webhook', + }); + expect(typeof json?.error?.message).toBe('string'); + expect(verifyWebhookSignatureWithRefreshMock).not.toHaveBeenCalled(); + expect(handleMonobankWebhookMock).not.toHaveBeenCalled(); + expect(enforceRateLimitMock).not.toHaveBeenCalled(); + expect(failIfBodyRead).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-paid-sticky.test.ts b/frontend/lib/tests/shop/monobank-webhook-paid-sticky.test.ts new file mode 100644 index 00000000..a4ec9eda --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-paid-sticky.test.ts @@ -0,0 +1,183 @@ +import crypto from 'node:crypto'; + +import { eq, inArray } from 'drizzle-orm'; +import { describe, expect, it, vi } from 'vitest'; + +import { db } from '@/db'; +import { monobankEvents, orders, paymentAttempts } from '@/db/schema'; +import { applyMonoWebhookEvent } from '@/lib/services/orders/monobank-webhook'; +import { toDbMoney } from '@/lib/shop/money'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; + +vi.mock('@/lib/services/orders/restock', () => ({ + restockOrder: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +function sha256HexUtf8(value: string) { + return crypto.createHash('sha256').update(Buffer.from(value, 'utf8')).digest('hex'); +} + +async function insertOrderAndAttempt(args: { + invoiceId: string; + amountMinor: number; +}) { + const orderId = crypto.randomUUID(); + const attemptId = crypto.randomUUID(); + + await db.insert(orders).values({ + id: orderId, + totalAmountMinor: args.amountMinor, + totalAmount: toDbMoney(args.amountMinor), + currency: 'UAH', + paymentProvider: 'monobank', + paymentStatus: 'pending', + status: 'INVENTORY_RESERVED', + inventoryStatus: 'reserved', + idempotencyKey: crypto.randomUUID(), + } as any); + + await db.insert(paymentAttempts).values({ + id: attemptId, + orderId, + provider: 'monobank', + status: 'active', + attemptNumber: 1, + currency: 'UAH', + expectedAmountMinor: args.amountMinor, + idempotencyKey: `test:${attemptId}`, + providerPaymentIntentId: args.invoiceId, + providerModifiedAt: null, + } as any); + + return { orderId, attemptId }; +} + +async function cleanup(args: { + orderId: string; + attemptId: string; + successRawSha256: string; + processingRawSha256: string; +}) { + await db + .delete(monobankEvents) + .where( + inArray(monobankEvents.rawSha256, [ + args.successRawSha256, + args.processingRawSha256, + ]) + ); + await db.delete(paymentAttempts).where(eq(paymentAttempts.id, args.attemptId)); + await db.delete(orders).where(eq(orders.id, args.orderId)); +} + +describe.sequential('monobank webhook paid-sticky', () => { + assertNotProductionDb(); + + it('keeps paid+succeeded when an older processing event arrives after success', async () => { + const invoiceId = `tst_inv_${crypto.randomUUID()}`; + const { orderId, attemptId } = await insertOrderAndAttempt({ + invoiceId, + amountMinor: 1000, + }); + + const now = Date.now(); + const successPayload = { + invoiceId, + status: 'success', + amount: 1000, + ccy: 980, + reference: attemptId, + modifiedAt: new Date(now).toISOString(), + }; + const olderProcessingPayload = { + invoiceId, + status: 'processing', + amount: 1000, + ccy: 980, + reference: attemptId, + modifiedAt: new Date(now - 60_000).toISOString(), + }; + + const successBody = JSON.stringify(successPayload); + const successRawSha256 = sha256HexUtf8(successBody); + const processingBody = JSON.stringify(olderProcessingPayload); + const processingRawSha256 = sha256HexUtf8(processingBody); + + try { + const first = await applyMonoWebhookEvent({ + rawBody: successBody, + parsedPayload: successPayload, + rawSha256: successRawSha256, + eventKey: successRawSha256, + requestId: 'paid-sticky-success', + mode: 'apply', + }); + expect(first.appliedResult).toBe('applied'); + + const [stateAfterSuccess] = await db + .select({ + paymentStatus: orders.paymentStatus, + attemptStatus: paymentAttempts.status, + }) + .from(orders) + .innerJoin(paymentAttempts, eq(paymentAttempts.orderId, orders.id)) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(stateAfterSuccess?.paymentStatus).toBe('paid'); + expect(stateAfterSuccess?.attemptStatus).toBe('succeeded'); + + const second = await applyMonoWebhookEvent({ + rawBody: processingBody, + parsedPayload: olderProcessingPayload, + rawSha256: processingRawSha256, + eventKey: processingRawSha256, + requestId: 'paid-sticky-older-processing', + mode: 'apply', + }); + expect(second.appliedResult).toBe('applied_noop'); + + const [stateAfterOlder] = await db + .select({ + paymentStatus: orders.paymentStatus, + attemptStatus: paymentAttempts.status, + }) + .from(orders) + .innerJoin(paymentAttempts, eq(paymentAttempts.orderId, orders.id)) + .where(eq(orders.id, orderId)) + .limit(1); + + expect(stateAfterOlder?.paymentStatus).toBe('paid'); + expect(stateAfterOlder?.attemptStatus).toBe('succeeded'); + + const [olderEvent] = await db + .select({ + appliedResult: monobankEvents.appliedResult, + appliedErrorCode: monobankEvents.appliedErrorCode, + }) + .from(monobankEvents) + .where(eq(monobankEvents.rawSha256, processingRawSha256)) + .limit(1); + + expect(olderEvent?.appliedResult).toBe('applied_noop'); + expect(olderEvent?.appliedErrorCode).toBe('OUT_OF_ORDER'); + } finally { + await cleanup({ + orderId, + attemptId, + successRawSha256, + processingRawSha256, + }); + } + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-rate-limit-policy.test.ts b/frontend/lib/tests/shop/monobank-webhook-rate-limit-policy.test.ts new file mode 100644 index 00000000..4789ae1d --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-rate-limit-policy.test.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const enforceRateLimitMock = vi.fn( + async (..._args: any[]) => ({ ok: false, retryAfterSeconds: 12 }) +); +const verifyWebhookSignatureWithRefreshMock = vi.fn( + async (..._args: any[]) => true +); +const handleMonobankWebhookMock = vi.fn(async (..._args: any[]) => ({ + invoiceId: 'inv_test', + appliedResult: 'applied', + deduped: false, +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), + }; +}); + +vi.mock('@/lib/logging/monobank', async () => { + const actual = await vi.importActual('@/lib/logging/monobank'); + return { + ...actual, + monoLogWarn: vi.fn(), + monoLogError: vi.fn(), + monoLogInfo: vi.fn(), + }; +}); + +vi.mock('@/lib/psp/monobank', () => ({ + verifyWebhookSignatureWithRefresh: verifyWebhookSignatureWithRefreshMock, +})); + +vi.mock('@/lib/services/orders/monobank-webhook', () => ({ + handleMonobankWebhook: handleMonobankWebhookMock, +})); + +vi.mock('@/lib/security/rate-limit', () => ({ + getRateLimitSubject: vi.fn(() => 'rl_webhook_subject'), + enforceRateLimit: enforceRateLimitMock, + rateLimitResponse: ({ + retryAfterSeconds, + details, + }: { + retryAfterSeconds: number; + details?: Record; + }) => { + const res = NextResponse.json( + { + success: false, + code: 'RATE_LIMITED', + retryAfterSeconds, + ...(details ? { details } : {}), + }, + { status: 429 } + ); + res.headers.set('Retry-After', String(retryAfterSeconds)); + res.headers.set('Cache-Control', 'no-store'); + return res; + }, +})); + +const { POST } = await import('@/app/api/shop/webhooks/monobank/route'); + +function makeReq(body: string, withSignature: boolean) { + const headers = new Headers({ + 'content-type': 'application/json', + 'x-request-id': 'mono-webhook-rate-limit-policy', + }); + if (withSignature) headers.set('x-sign', 'signature-value'); + + return new NextRequest('http://localhost/api/shop/webhooks/monobank', { + method: 'POST', + headers, + body, + }); +} + +describe('monobank webhook rate limit policy', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.MONO_WEBHOOK_MODE = 'apply'; + }); + + afterEach(() => { + delete process.env.MONO_WEBHOOK_MODE; + }); + + it('does not rate-limit valid signed webhook events', async () => { + verifyWebhookSignatureWithRefreshMock.mockResolvedValue(true); + enforceRateLimitMock.mockResolvedValue({ + ok: false, + retryAfterSeconds: 15, + }); + + const req = makeReq( + JSON.stringify({ + invoiceId: 'inv_123', + status: 'success', + }), + true + ); + + const res = await POST(req); + const json: any = await res.json(); + + expect(res.status).toBe(200); + expect(json.ok).toBe(true); + expect(enforceRateLimitMock).not.toHaveBeenCalled(); + expect(handleMonobankWebhookMock).toHaveBeenCalledTimes(1); + }); + + it('rate-limits missing-signature traffic with 429 headers', async () => { + enforceRateLimitMock.mockResolvedValue({ + ok: false, + retryAfterSeconds: 12, + }); + + const req = makeReq( + JSON.stringify({ + invoiceId: 'inv_123', + status: 'success', + }), + false + ); + + const res = await POST(req); + const json: any = await res.json(); + + expect(res.status).toBe(429); + expect(res.headers.get('Retry-After')).toBe('12'); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(json.code).toBe('RATE_LIMITED'); + expect(verifyWebhookSignatureWithRefreshMock).not.toHaveBeenCalled(); + expect(handleMonobankWebhookMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-rate-limit-scope.test.ts b/frontend/lib/tests/shop/monobank-webhook-rate-limit-scope.test.ts new file mode 100644 index 00000000..d5130889 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-rate-limit-scope.test.ts @@ -0,0 +1,70 @@ +import { NextRequest } from 'next/server'; +import { afterEach,beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoisted mocks: MUST be before importing the route module. +vi.mock('@/lib/security/rate-limit', () => { + return { + enforceRateLimit: vi.fn(async () => ({ ok: false, retryAfterSeconds: 60 })), + getRateLimitSubject: vi.fn(() => 'test_subject'), + rateLimitResponse: vi.fn( + ({ + retryAfterSeconds, + details, + }: { + retryAfterSeconds: number; + details: { scope: string }; + }) => + new Response(JSON.stringify({ retryAfterSeconds, details }), { + status: 429, + headers: { 'content-type': 'application/json' }, + }) + ), + }; +}); + +vi.mock('@/lib/psp/monobank', () => { + return { + verifyWebhookSignatureWithRefresh: vi.fn(async () => false), + }; +}); + +import { POST } from '@/app/api/shop/webhooks/monobank/route'; + +function makeReq(opts: { hasSign: boolean }) { + const headers = new Headers(); + // No Origin header → should not be blocked by origin posture. + if (opts.hasSign) headers.set('x-sign', 'bad_signature'); + + return new NextRequest('http://localhost/api/shop/webhooks/monobank', { + method: 'POST', + headers, + body: JSON.stringify({ hello: 'world' }), + }); +} + +beforeEach(() => { + process.env.MONO_WEBHOOK_MODE = 'apply'; +}); + +afterEach(() => { + delete process.env.MONO_WEBHOOK_MODE; + vi.clearAllMocks(); +}); + +describe('monobank webhook rate-limit scope regression', () => { + it('missing signature → scope = monobank_webhook_missing_signature', async () => { + const res = await POST(makeReq({ hasSign: false })); + expect(res.status).toBe(429); + + const body = await res.json(); + expect(body.details.scope).toBe('monobank_webhook_missing_signature'); + }); + + it('invalid signature → scope = monobank_webhook_invalid_signature', async () => { + const res = await POST(makeReq({ hasSign: true })); + expect(res.status).toBe(429); + + const body = await res.json(); + expect(body.details.scope).toBe('monobank_webhook_invalid_signature'); + }); +}); diff --git a/frontend/lib/tests/shop/monobank-webhook-signature-verify.test.ts b/frontend/lib/tests/shop/monobank-webhook-signature-verify.test.ts new file mode 100644 index 00000000..e8965261 --- /dev/null +++ b/frontend/lib/tests/shop/monobank-webhook-signature-verify.test.ts @@ -0,0 +1,196 @@ +import crypto from 'node:crypto'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetEnvCache } from '@/lib/env'; + +const ENV_KEYS = [ + 'DATABASE_URL', + 'MONO_MERCHANT_TOKEN', + 'PAYMENTS_ENABLED', + 'MONO_PUBLIC_KEY', + 'MONO_API_BASE', + 'MONO_INVOICE_TIMEOUT_MS', +] as const; + +const previousEnv: Record = {}; +const originalFetch = globalThis.fetch; + +function rememberEnv() { + for (const key of ENV_KEYS) { + previousEnv[key] = process.env[key]; + } +} + +function restoreEnv() { + for (const key of ENV_KEYS) { + const value = previousEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +} + +function makeOkResponse(body: string) { + return new Response(body, { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +} + +beforeEach(() => { + rememberEnv(); + process.env.DATABASE_URL = + process.env.DATABASE_URL ?? 'postgres://user:pass@localhost:5432/dev'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_API_BASE = 'https://api.example.test'; + process.env.MONO_INVOICE_TIMEOUT_MS = '5000'; + delete process.env.MONO_PUBLIC_KEY; + + resetEnvCache(); + vi.resetModules(); + vi.restoreAllMocks(); +}); + +afterEach(() => { + restoreEnv(); + resetEnvCache(); + vi.restoreAllMocks(); + globalThis.fetch = originalFetch; +}); + +describe('monobank webhook signature verify', () => { + it('valid signature passes', async () => { + const { verifyWebhookSignature } = await import('@/lib/psp/monobank'); + + const rawBody = Buffer.from( + '{"invoiceId":"inv_sig_ok","status":"success"}' + ); + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const signature = crypto + .sign('sha256', rawBody, privateKey) + .toString('base64'); + const publicPem = publicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + + const ok = verifyWebhookSignature( + rawBody, + signature, + Buffer.from(publicPem) + ); + expect(ok).toBe(true); + }); + + it('invalid signature fails', async () => { + const { verifyWebhookSignature } = await import('@/lib/psp/monobank'); + + const rawBody = Buffer.from( + '{"invoiceId":"inv_sig_bad","status":"success"}' + ); + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const signature = crypto + .sign('sha256', rawBody, privateKey) + .toString('base64'); + const publicPem = publicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + + const tampered = Buffer.from(rawBody); + tampered[0] = tampered[0] ^ 0xff; + + const ok = verifyWebhookSignature( + tampered, + signature, + Buffer.from(publicPem) + ); + expect(ok).toBe(false); + }); + + it('refresh-once: wrong key first, then correct key succeeds with exactly two fetches', async () => { + const rawBody = Buffer.from( + '{"invoiceId":"inv_refresh_ok","status":"success"}' + ); + const { publicKey: wrongPublicKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const { publicKey: rightPublicKey, privateKey: rightPrivateKey } = + crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const wrongPem = wrongPublicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + const rightPem = rightPublicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + const signature = crypto + .sign('sha256', rawBody, rightPrivateKey) + .toString('base64'); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeOkResponse(JSON.stringify({ key: wrongPem }))) + .mockResolvedValueOnce(makeOkResponse(JSON.stringify({ key: rightPem }))); + globalThis.fetch = fetchMock as any; + + const { verifyWebhookSignatureWithRefresh } = + await import('@/lib/psp/monobank'); + + const ok = await verifyWebhookSignatureWithRefresh({ + rawBodyBytes: rawBody, + signature, + }); + + expect(ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('refresh-once still fails with wrong key twice and exactly two fetches', async () => { + const rawBody = Buffer.from( + '{"invoiceId":"inv_refresh_fail","status":"success"}' + ); + const { publicKey: wrongPublicKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const { privateKey: signerPrivateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + + const wrongPem = wrongPublicKey.export({ + type: 'spki', + format: 'pem', + }) as string; + const signature = crypto + .sign('sha256', rawBody, signerPrivateKey) + .toString('base64'); + + const fetchMock = vi + .fn() + .mockResolvedValueOnce(makeOkResponse(JSON.stringify({ key: wrongPem }))) + .mockResolvedValueOnce(makeOkResponse(JSON.stringify({ key: wrongPem }))); + globalThis.fetch = fetchMock as any; + + const { verifyWebhookSignatureWithRefresh } = + await import('@/lib/psp/monobank'); + + const ok = await verifyWebhookSignatureWithRefresh({ + rawBodyBytes: rawBody, + signature, + }); + + expect(ok).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/lib/tests/shop/orders-status-ownership.test.ts b/frontend/lib/tests/shop/orders-status-ownership.test.ts new file mode 100644 index 00000000..d261ea4e --- /dev/null +++ b/frontend/lib/tests/shop/orders-status-ownership.test.ts @@ -0,0 +1,442 @@ +import crypto from 'node:crypto'; + +import { eq, sql } from 'drizzle-orm'; +import { NextRequest } from 'next/server'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; + +import { db } from '@/db'; +import { orders, paymentAttempts, productPrices, products } from '@/db/schema'; +import { resetEnvCache } from '@/lib/env'; +import { toDbMoney } from '@/lib/shop/money'; +import { verifyStatusToken } from '@/lib/shop/status-token'; +import { assertNotProductionDb } from '@/lib/tests/helpers/db-safety'; +import { deriveTestIpFromIdemKey } from '@/lib/tests/helpers/ip'; + +vi.mock('@/lib/auth', () => ({ + getCurrentUser: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/lib/logging', async () => { + const actual = await vi.importActual('@/lib/logging'); + return { + ...actual, + logWarn: () => {}, + logError: () => {}, + logInfo: () => {}, + }; +}); + +const createMonobankInvoiceMock = vi.fn(async (args: any) => { + const orderId = + typeof args?.orderId === 'string' ? args.orderId : crypto.randomUUID(); + const invoiceId = `inv_${orderId}`; + const pageUrl = `https://pay.test/${invoiceId}`; + return { invoiceId, pageUrl, raw: {} }; +}); + +vi.mock('@/lib/psp/monobank', () => ({ + MONO_CURRENCY: 'UAH', + createMonobankInvoice: (args: any) => createMonobankInvoiceMock(args), + cancelMonobankInvoice: vi.fn(async () => {}), +})); + +const __prevRateLimitDisabled = process.env.RATE_LIMIT_DISABLED; +const __prevPaymentsEnabled = process.env.PAYMENTS_ENABLED; +const __prevMonoToken = process.env.MONO_MERCHANT_TOKEN; +const __prevAppOrigin = process.env.APP_ORIGIN; +const __prevShopBaseUrl = process.env.SHOP_BASE_URL; +const __prevStatusSecret = process.env.SHOP_STATUS_TOKEN_SECRET; + +beforeAll(() => { + process.env.RATE_LIMIT_DISABLED = '1'; + process.env.PAYMENTS_ENABLED = 'true'; + process.env.MONO_MERCHANT_TOKEN = 'test_mono_token'; + process.env.APP_ORIGIN = 'http://localhost:3000'; + process.env.SHOP_BASE_URL = 'http://localhost:3000'; + process.env.SHOP_STATUS_TOKEN_SECRET = + 'test_status_token_secret_test_status_token_secret'; + + resetEnvCache(); +}); + +afterAll(() => { + if (__prevRateLimitDisabled === undefined) + delete process.env.RATE_LIMIT_DISABLED; + else process.env.RATE_LIMIT_DISABLED = __prevRateLimitDisabled; + + if (__prevPaymentsEnabled === undefined) delete process.env.PAYMENTS_ENABLED; + else process.env.PAYMENTS_ENABLED = __prevPaymentsEnabled; + + if (__prevMonoToken === undefined) delete process.env.MONO_MERCHANT_TOKEN; + else process.env.MONO_MERCHANT_TOKEN = __prevMonoToken; + + if (__prevAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = __prevAppOrigin; + + if (__prevShopBaseUrl === undefined) delete process.env.SHOP_BASE_URL; + else process.env.SHOP_BASE_URL = __prevShopBaseUrl; + + if (__prevStatusSecret === undefined) + delete process.env.SHOP_STATUS_TOKEN_SECRET; + else process.env.SHOP_STATUS_TOKEN_SECRET = __prevStatusSecret; + + resetEnvCache(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function insertTestProductWithUAHPrice(args: { + stock: number; + priceMinor: number; + currency: 'USD'; +}) { + const productId = crypto.randomUUID(); + const token = crypto.randomUUID(); + const slug = `tst_status_owner_${token}`; + const sku = `tst_status_owner_${token}`; + const now = new Date(); + + await db.insert(products).values({ + id: productId, + slug, + sku, + title: `Test ${slug}`, + description: 'Ownership test product', + imageUrl: 'https://example.test/status-owner.png', + imagePublicId: null, + price: toDbMoney(args.priceMinor), + originalPrice: null, + currency: args.currency, + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: args.stock, + createdAt: now, + updatedAt: now, + } as any); + + try { + await db.insert(productPrices).values({ + productId, + currency: 'UAH', + priceMinor: args.priceMinor, + originalPriceMinor: null, + price: toDbMoney(args.priceMinor), + originalPrice: null, + createdAt: now, + updatedAt: now, + } as any); + } catch (e) { + await db.delete(products).where(eq(products.id, productId)); + throw e; + } + + return { productId }; +} + +async function cleanupProduct(productId: string) { + await db.delete(productPrices).where(eq(productPrices.productId, productId)); + await db.delete(products).where(eq(products.id, productId)); +} + +async function cleanupOrder(orderId: string) { + await db + .execute(sql`delete from monobank_events where order_id = ${orderId}::uuid`) + .catch(() => {}); + await db + .execute(sql`delete from order_items where order_id = ${orderId}::uuid`) + .catch(() => {}); + await db + .delete(paymentAttempts) + .where(eq(paymentAttempts.orderId, orderId)) + .catch(() => {}); + await db + .delete(orders) + .where(eq(orders.id, orderId)) + .catch(() => {}); +} + +async function postCheckout(idemKey: string, productId: string) { + const mod = (await import('@/app/api/shop/checkout/route')) as unknown as { + POST: (req: NextRequest) => Promise; + }; + + const req = new NextRequest('http://localhost/api/shop/checkout', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept-language': 'uk-UA', + 'idempotency-key': idemKey, + 'x-request-id': `status-owner-${idemKey}`, + 'x-forwarded-for': deriveTestIpFromIdemKey(idemKey), + origin: 'http://localhost:3000', + }, + body: JSON.stringify({ + items: [{ productId, quantity: 1 }], + paymentProvider: 'monobank', + }), + }); + + return mod.POST(req); +} + +function extractStatusToken( + json: any, + orderId: string +): { token: string; paramName: string } { + const directCandidates: Array<[string, unknown]> = [ + ['statusToken', json?.statusToken], + ['status_token', json?.status_token], + ['token', json?.token], + ['statusAccessToken', json?.statusAccessToken], + ['status_access_token', json?.status_access_token], + ]; + + for (const [name, val] of directCandidates) { + if (typeof val === 'string' && val.length > 0) { + const v = verifyStatusToken({ token: val, orderId }); + if (v.ok) return { token: val, paramName: name }; + } + } + + const urlFields = [ + 'statusUrl', + 'status_url', + 'statusPageUrl', + 'status_page_url', + 'returnUrl', + 'return_url', + 'redirectUrl', + 'redirect_url', + ] as const; + + for (const f of urlFields) { + const urlStr = (json as any)?.[f]; + if (typeof urlStr === 'string' && urlStr.length > 0) { + try { + const u = new URL(urlStr); + const params = ['statusToken', 'status_token', 'token', 't']; + for (const p of params) { + const v = u.searchParams.get(p); + if (typeof v === 'string' && v.length > 0) { + const ok = verifyStatusToken({ token: v, orderId }); + if (ok.ok) return { token: v, paramName: p }; + } + } + } catch { + // ignore bad URL + } + } + } + + const seen = new Set(); + const stack: Array<{ v: any; path: string; depth: number }> = [ + { v: json, path: '$', depth: 0 }, + ]; + + while (stack.length) { + const cur = stack.pop()!; + const { v, path, depth } = cur; + + if (v && typeof v === 'object') { + if (seen.has(v)) continue; + seen.add(v); + + if (depth > 4) continue; + + for (const [k, val] of Object.entries(v)) { + const p = `${path}.${k}`; + + if (typeof val === 'string') { + if (val.includes('.') && val.split('.').length === 2) { + const ok = verifyStatusToken({ token: val, orderId }); + if (ok.ok) return { token: val, paramName: k }; + } + + if (val.startsWith('http://') || val.startsWith('https://')) { + try { + const u = new URL(val); + for (const q of ['statusToken', 'status_token', 'token', 't']) { + const cand = u.searchParams.get(q); + if (cand) { + const ok = verifyStatusToken({ token: cand, orderId }); + if (ok.ok) return { token: cand, paramName: q }; + } + } + } catch { + // ignore + } + } + } else if (val && typeof val === 'object') { + stack.push({ v: val, path: p, depth: depth + 1 }); + } + } + } + } + + const keys = json && typeof json === 'object' ? Object.keys(json) : []; + throw new Error( + `[ownership-test] status token not found in checkout response. ` + + `top-level keys=${JSON.stringify(keys)} ` + + `response=${JSON.stringify(json)}` + ); +} + +async function getOrderStatus( + orderId: string, + paramName?: string, + token?: string +) { + const mod = + (await import('@/app/api/shop/orders/[id]/status/route')) as unknown as { + GET: ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> } + ) => Promise; + }; + + const base = `http://localhost/api/shop/orders/${orderId}/status`; + const url = + paramName && token + ? `${base}?${encodeURIComponent(paramName)}=${encodeURIComponent(token)}` + : base; + + const req = new NextRequest(url, { + method: 'GET', + headers: { + 'accept-language': 'uk-UA', + origin: 'http://localhost:3000', + 'x-request-id': `status-owner-get-${orderId}`, + }, + }); + + const res = await mod.GET(req, { params: Promise.resolve({ id: orderId }) }); + + let json: any = null; + try { + json = await res.json(); + } catch { + // ignore + } + return { res, json }; +} + +describe.sequential('orders/[id]/status ownership (J)', () => { + beforeAll(() => { + assertNotProductionDb(); + }); + + it('no token -> 401/403; correct token -> 200; foreign token -> 403/404 (no IDOR)', async () => { + const { productId } = await insertTestProductWithUAHPrice({ + stock: 5, + priceMinor: 1000, + currency: 'USD', + }); + const createdOrderIds: string[] = []; + + try { + const resA = await postCheckout(crypto.randomUUID(), productId); + expect(resA.status).toBe(201); + const jsonA: any = await resA.json(); + + if (typeof jsonA?.orderId !== 'string') { + throw new Error( + `[ownership-test] checkout A did not return orderId:string` + ); + } + const orderA: string = jsonA.orderId; + createdOrderIds.push(orderA); + + const tokA = extractStatusToken(jsonA, orderA); + + const resB = await postCheckout(crypto.randomUUID(), productId); + expect(resB.status).toBe(201); + const jsonB: any = await resB.json(); + + if (typeof jsonB?.orderId !== 'string') { + throw new Error( + `[ownership-test] checkout B did not return orderId:string` + ); + } + const orderB: string = jsonB.orderId; + createdOrderIds.push(orderB); + + const tokB = extractStatusToken(jsonB, orderB); + + { + const { res } = await getOrderStatus(orderA); + expect([401, 403]).toContain(res.status); + } + + { + const { res, json } = await getOrderStatus( + orderA, + tokA.paramName, + tokA.token + ); + expect(res.status).toBe(200); + + expect(json).toBeTruthy(); + expect((json as any).success).toBe(true); + expect((json as any).order).toBeTruthy(); + + const returnedId = + (json as any).orderId ?? (json as any).id ?? (json as any).order?.id; + + if (!returnedId) { + const topKeys = + json && typeof json === 'object' ? Object.keys(json) : []; + const orderKeys = + (json as any)?.order && typeof (json as any).order === 'object' + ? Object.keys((json as any).order) + : []; + throw new Error( + `[ownership-test] status 200 response missing order identifier. ` + + `topKeys=${JSON.stringify(topKeys)} orderKeys=${JSON.stringify(orderKeys)}` + ); + } + + expect(returnedId).toBe(orderA); + } + + { + const { res } = await getOrderStatus( + orderA, + tokA.paramName, + tokB.token + ); + expect([403, 404, 401]).toContain(res.status); + } + + { + const { res } = await getOrderStatus( + orderA, + tokA.paramName, + `bad_${crypto.randomUUID()}` + ); + expect([403, 404, 401]).toContain(res.status); + } + + expect(createMonobankInvoiceMock).toHaveBeenCalledTimes(2); + } finally { + for (const id of createdOrderIds) { + await cleanupOrder(id).catch(() => undefined); + } + await cleanupProduct(productId).catch(() => undefined); + } + }, 30_000); +}); diff --git a/frontend/lib/tests/shop/origin-normalize-fail-closed.test.ts b/frontend/lib/tests/shop/origin-normalize-fail-closed.test.ts new file mode 100644 index 00000000..658f5e7d --- /dev/null +++ b/frontend/lib/tests/shop/origin-normalize-fail-closed.test.ts @@ -0,0 +1,61 @@ +import { NextRequest } from 'next/server'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + getAllowedOrigins, + guardBrowserSameOrigin, + normalizeOrigin, +} from '@/lib/security/origin'; + +function makeReq(origin: string, method: string = 'POST') { + return new NextRequest('http://localhost/test', { + method, + headers: { origin }, + }); +} + +describe('origin normalize fail-closed (P1)', () => { + const originalAppOrigin = process.env.APP_ORIGIN; + const originalAdditional = process.env.APP_ADDITIONAL_ORIGINS; + + beforeEach(() => { + delete process.env.APP_ORIGIN; + delete process.env.APP_ADDITIONAL_ORIGINS; + }); + + afterEach(() => { + if (originalAppOrigin === undefined) delete process.env.APP_ORIGIN; + else process.env.APP_ORIGIN = originalAppOrigin; + + if (originalAdditional === undefined) delete process.env.APP_ADDITIONAL_ORIGINS; + else process.env.APP_ADDITIONAL_ORIGINS = originalAdditional; + }); + + it('malformed origin normalizes to empty string', () => { + expect(normalizeOrigin('not a url')).toBe(''); + expect(normalizeOrigin(' not a url ')).toBe(''); + }); + + it('malformed origin does not pass allow-list (guard blocks)', async () => { + const res = guardBrowserSameOrigin(makeReq('not a url')); + expect(res).not.toBeNull(); + expect(res!.status).toBe(403); + + const body = await res!.json(); + expect(body?.error?.code).toBe('ORIGIN_NOT_ALLOWED'); + }); + + it('valid origins compare stably (trailing slashes/case)', () => { + const allowed = getAllowedOrigins(); + expect(allowed).toContain('http://localhost:3000'); + + const res = guardBrowserSameOrigin(makeReq('http://LOCALHOST:3000///')); + expect(res).toBeNull(); + }); + + it('invalid APP_ORIGIN does not add empty string to allowed origins', () => { + process.env.APP_ORIGIN = 'not a url'; + const allowed = getAllowedOrigins(); + expect(allowed).not.toContain(''); + }); +}); diff --git a/frontend/lib/tests/shop/origin-posture.test.ts b/frontend/lib/tests/shop/origin-posture.test.ts index d8d8f46e..74f72090 100644 --- a/frontend/lib/tests/shop/origin-posture.test.ts +++ b/frontend/lib/tests/shop/origin-posture.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { guardBrowserSameOrigin, + guardNonBrowserFailClosed, guardNonBrowserOnly, normalizeOrigin, } from '@/lib/security/origin'; @@ -102,4 +103,38 @@ describe('origin posture helpers', () => { const res = guardNonBrowserOnly(req); expect(res).toBeNull(); }); + + it('guardNonBrowserFailClosed blocks when Referer is present', async () => { + const req = makeReq({ + method: 'POST', + headers: { referer: 'http://localhost:3000/shop' }, + }); + const res = guardNonBrowserFailClosed(req, { surface: 'test_surface' }); + expect(res?.status).toBe(403); + const body = await res?.json(); + expect(body).toMatchObject({ + error: { code: 'ORIGIN_BLOCKED' }, + surface: 'test_surface', + }); + expect(typeof body?.error?.message).toBe('string'); + expect(res?.headers.get('Cache-Control')).toBe('no-store'); + }); + + it('guardNonBrowserFailClosed blocks when Sec-Fetch-* headers are present', async () => { + const req = makeReq({ + method: 'POST', + headers: { 'sec-fetch-site': 'none' }, + }); + const res = guardNonBrowserFailClosed(req, { surface: 'test_surface' }); + expect(res?.status).toBe(403); + const body = await res?.json(); + expect(body?.error?.code).toBe('ORIGIN_BLOCKED'); + expect(body?.surface).toBe('test_surface'); + }); + + it('guardNonBrowserFailClosed allows when no browser signals are present', () => { + const req = makeReq({ method: 'POST' }); + const res = guardNonBrowserFailClosed(req, { surface: 'test_surface' }); + expect(res).toBeNull(); + }); }); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4e3785fe..a64c44a0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -385,7 +385,19 @@ "placing": "Placing order...", "message": "You'll either be redirected to secure payment or see confirmation if payment is not required in this environment", "notRedirected": "If you are not redirected automatically, open your order", - "goToOrder": "Go to order" + "goToOrder": "Go to order", + "errors": { + "unexpectedResponse": "Unexpected checkout response.", + "startFailed": "Unable to start checkout right now." + }, + "paymentMethod": { + "label": "Payment method", + "stripe": "Stripe", + "monobank": "Monobank", + "monobankUahOnlyHint": "Monobank is available only for UAH checkout.", + "monobankUnavailable": "Monobank is unavailable in this environment.", + "noAvailable": "No payment methods are currently available." + } }, "actions": { "removeItem": "Remove {title} from cart", @@ -574,7 +586,34 @@ "items": "Items", "status": "Status", "continueShopping": "Continue shopping", - "viewCart": "View cart" + "viewCart": "View cart", + "refreshStatus": "Refresh status", + "refreshingStatus": "Refreshing...", + "statusAccessDenied": "Unable to verify payment status for this order.", + "needsReviewContact": "Please contact support and provide Order ID: {orderId}", + "statusHeadlines": { + "pending": "Payment is being confirmed", + "paid": "Payment confirmed", + "canceled": "Payment not completed", + "needsReview": "Payment needs manual review" + }, + "statusMessages": { + "pending": "We are still confirming your payment. This page updates automatically.", + "pendingUnknown": "We are still confirming your payment status.", + "paid": "Your payment has been confirmed.", + "canceled": "This payment was not completed or was canceled.", + "needsReview": "We could not automatically confirm this payment." + } + }, + "paymentStatus": { + "confirming": "Payment is being confirmed", + "paid": "Payment confirmed", + "failed": "Payment failed", + "needsReview": "Payment needs review", + "refunded": "Payment refunded", + "canceled": "Payment canceled", + "pending": "Payment pending", + "unknown": "Payment status unavailable" }, "error": { "paymentFailed": "Payment failed", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index eaf75d4d..791ed988 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -385,7 +385,19 @@ "placing": "Składanie zamówienia...", "message": "Zostaniesz przekierowany do bezpiecznej płatności lub zobaczysz potwierdzenie, jeśli płatność nie jest wymagana w tym środowisku", "notRedirected": "Jeśli nie zostałeś automatycznie przekierowany, otwórz swoje zamówienie", - "goToOrder": "Przejdź do zamówienia" + "goToOrder": "Przejdź do zamówienia", + "errors": { + "unexpectedResponse": "Nieoczekiwana odpowiedź checkout.", + "startFailed": "Nie można teraz rozpocząć checkoutu." + }, + "paymentMethod": { + "label": "Metoda płatności", + "stripe": "Stripe", + "monobank": "Monobank", + "monobankUahOnlyHint": "Monobank jest dostępny tylko dla płatności w UAH.", + "monobankUnavailable": "Monobank jest niedostępny w tym środowisku.", + "noAvailable": "Brak dostępnych metod płatności." + } }, "actions": { "removeItem": "Usuń {title} z koszyka", @@ -574,7 +586,34 @@ "items": "Produkty", "status": "Status", "continueShopping": "Kontynuuj zakupy", - "viewCart": "Zobacz koszyk" + "viewCart": "Zobacz koszyk", + "refreshStatus": "Odśwież status", + "refreshingStatus": "Odświeżanie...", + "statusAccessDenied": "Nie można zweryfikować statusu płatności dla tego zamówienia.", + "needsReviewContact": "Skontaktuj się z pomocą i podaj ID zamówienia: {orderId}", + "statusHeadlines": { + "pending": "Płatność jest potwierdzana", + "paid": "Płatność potwierdzona", + "canceled": "Płatność nie została zakończona", + "needsReview": "Płatność wymaga ręcznej weryfikacji" + }, + "statusMessages": { + "pending": "Nadal potwierdzamy Twoją płatność. Ta strona aktualizuje się automatycznie.", + "pendingUnknown": "Nadal potwierdzamy status Twojej płatności.", + "paid": "Twoja płatność została potwierdzona.", + "canceled": "Ta płatność nie została zakończona lub została anulowana.", + "needsReview": "Nie udało się automatycznie potwierdzić tej płatności." + } + }, + "paymentStatus": { + "confirming": "Płatność jest potwierdzana", + "paid": "Płatność potwierdzona", + "failed": "Płatność nie powiodła się", + "needsReview": "Płatność wymaga weryfikacji", + "refunded": "Płatność zwrócona", + "canceled": "Płatność anulowana", + "pending": "Płatność oczekująca", + "unknown": "Status płatności niedostępny" }, "error": { "paymentFailed": "Płatność nie powiodła się", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 3eb2b460..2a35ba6f 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -385,7 +385,19 @@ "placing": "Оформлення замовлення...", "message": "Ви будете перенаправлені на безпечну оплату або побачите підтвердження, якщо оплата не потрібна в цьому середовищі", "notRedirected": "Якщо ви не були автоматично перенаправлені, відкрийте ваше замовлення", - "goToOrder": "Перейти до замовлення" + "goToOrder": "Перейти до замовлення", + "errors": { + "unexpectedResponse": "Неочікувана відповідь оформлення замовлення.", + "startFailed": "Наразі неможливо розпочати оформлення замовлення." + }, + "paymentMethod": { + "label": "Спосіб оплати", + "stripe": "Stripe", + "monobank": "Monobank", + "monobankUahOnlyHint": "Monobank доступний лише для оплати в UAH.", + "monobankUnavailable": "Monobank недоступний у цьому середовищі.", + "noAvailable": "Наразі немає доступних способів оплати." + } }, "actions": { "removeItem": "Видалити {title} з кошика", @@ -574,7 +586,34 @@ "items": "Товари", "status": "Статус", "continueShopping": "Продовжити покупки", - "viewCart": "Переглянути кошик" + "viewCart": "Переглянути кошик", + "refreshStatus": "Оновити статус", + "refreshingStatus": "Оновлення...", + "statusAccessDenied": "Не вдалося перевірити статус оплати для цього замовлення.", + "needsReviewContact": "Будь ласка, зверніться в підтримку та вкажіть ID замовлення: {orderId}", + "statusHeadlines": { + "pending": "Підтверджуємо оплату", + "paid": "Оплату підтверджено", + "canceled": "Оплату не завершено", + "needsReview": "Оплата потребує ручної перевірки" + }, + "statusMessages": { + "pending": "Ми все ще підтверджуємо вашу оплату. Ця сторінка оновлюється автоматично.", + "pendingUnknown": "Ми все ще підтверджуємо статус вашої оплати.", + "paid": "Вашу оплату підтверджено.", + "canceled": "Цю оплату не було завершено або її скасовано.", + "needsReview": "Ми не змогли автоматично підтвердити цю оплату." + } + }, + "paymentStatus": { + "confirming": "Оплата підтверджується", + "paid": "Оплату підтверджено", + "failed": "Оплата не вдалася", + "needsReview": "Оплата потребує перевірки", + "refunded": "Оплату повернуто", + "canceled": "Оплату скасовано", + "pending": "Оплата в очікуванні", + "unknown": "Статус оплати недоступний" }, "error": { "paymentFailed": "Помилка оплати", @@ -672,27 +711,27 @@ } }, "productDescriptions": { - "t-shirt-curve": "Біла футболка Code-Heart\nЧиста біла футболка з фірмовим символом DevLovers code-heart у насиченому чорному кольорі.\nКонтрастний принт додає сучасного, чіткого вигляду й добре поєднується з джинсами, джогерами та багатошаровими образами.\nЛаконічна, мінімалістична і зручна для щоденного носіння.", - "duck": "Качечка-тімлід у режимі «я тільки перевірю один PR перед сном»: у худі, в гарнітурі, з ноутом, обклеєним стікерами так щільно, що там уже живе окремий DevOps.\nКава підписана «Team Lead», поруч сидить пінгвін-стажер, а сама качка з виглядом людини, яка щойно сказала:\n«Це не баг, це фіча… але давайте все ж закриємо це в цьому спринті».", - "hoodie-logo": "Худі DevLovers Minimal Logo (графіт)\nАкуратне худі на щодень із делікатним логотипом DevLovers на грудях в стилі вишивки. Створене для тих, хто любить стриманий брендинг і сучасний силует — графітовий колір легко вписується в повсякденний гардероб.\nМ’яке, практичне й зручне: класичний капюшон на шнурках і кишеня-кенгуру. Ідеально для роботи, прохолодних вечорів і шарових образів.", - "hoodie-graph": "Худі DevLovers Blueprint Graph (молочне)\nЧисте повсякденне худі з акцентним принтом на спині: фірмове «серце-креслення» DevLovers у кодових дужках. Мінімалістична молочна база легко поєднується з джинсами, джогерами та стрітвеар-шарами.\nСтворене для довгих робочих днів і спокійних вихідних: вільна посадка та комфорт без зайвого об’єму. Якщо хочеш стриманий фронт і виразний бек-принт — це воно.", - "hoodie-back": "Худі DevLovers Binary Heart (чорне)\nЧорне худі з виразним принтом на спині з бінарного коду та символом DevLovers «серце в дужках». Спереду стримано, ззаду акцентно — для тих, хто любить лаконічний стиль із характером.\nВільний унісекс-крій\nМ’який комфорт на щодень\nКапюшон на шнурках\nКишеня-кенгуру\nВеликий принт на спині (бінарний код + символ DevLovers)", - "hoodie-sketch": "Худі DevLovers Blueprint Sketch (графіт)\nЛаконічне технічне худі з фірмовим графічним «серцем-кресленням» DevLovers спереду. Графітова база та тонка біла лінійка дають сучасний мінімалістичний вигляд, який добре працює і соло, і в багатошарових луках.\nДля щоденного носіння: вільна унісекс-посадка, регульований капюшон на шнурках і передня кишеня-кенгуру.", - "t-shirt-pink": "Рожева футболка DevLovers — принт {♥}\nМ’яка рожева футболка з фірмовим фронтальним принтом {♥} у яскравому червоно-блакитному офсеті.\nЧиста, грайлива і легка для щоденного стилювання.\nЧудово підходить для casual-образів, мітепів і подарунка улюбленому розробнику.", - "hoodie-black": "Чорне худі DevLovers — принт {♥}\nЛаконічне чорне худі з фірмовим фронтальним принтом {♥} у виразному червоно-блакитному офсеті.\nЦе акцентна річ на щодень, що поєднує мінімалізм і dev-ідентичність.\nІдеальне для casual-луків, кодинг-сесій і прохолодної погоди.", - "stickers-set": "Набір стікерів DevLovers\nПрокачай свій сетап яскравим набором стікерів у dev-стилі.\nУ наборі 9 унікальних варіацій логотипа {❤️} — від чистого монохрому до glitch-дизайнів — на одному зручному аркуші. Класний варіант для ноутбуків, блокнотів, робочого столу та подарункових наборів.\n9 унікальних дизайнів.\nЗмішані стилі (класика, dark, glitch, texture).\nАкуратні вирізи на одному підкладному аркуші.\nДля розробників, студентів і tech-фанів.", - "tee-graph": "Футболка DevLovers Math Graph (графіт)\nМінімалістична графітова футболка для тих, хто любить код, математику та чистий дизайн. Принт поєднує графік кривої, параметричну геометрію серця та формульні позначення з делікатними кодовими дужками — технічно, nerdy і стильно без зайвого шуму.", - "t-shirt-stereo": "Темно-синя футболка Glitch Code-Heart\nГлибока navy-футболка з яскравим DevLovers-принтом code-heart у бірюзово-червоному glitch-стилі.\nЕфект зсуву кольорів додає динаміки й енергії, зберігаючи чисту центральну композицію.\nВдалий вибір, якщо хочеш сильніший візуальний акцент без втрати dev-естетики.", - "hoodie-blue": "Худі DevLovers Glitch Heart (небесно-блакитне)\nСвіже небесно-блакитне худі з виразним принтом DevLovers «серце в дужках» у glitch-офсеті. Чистий силует і акцентний фронтальний принт — для щоденного носіння з tech-street вайбом.\nВільний унісекс-крій.\nМ’яка, затишна тканина.\nРегульований капюшон на шнурках.\nПередня кишеня-кенгуру.\nАкцентний фронтальний принт (glitch-стиль DevLovers heart).", - "bottle-bl": "Стильна бежева пляшка з виразним чорним принтом «серце в дужках» і неоновими бірюзово-блакитними акцентами.\nЦя версія має вищий контраст і більш «tech» характер.\nІдеальний вибір для тих, хто хоче практичну гідратацію з виразною developer-естетикою.", - "t-shirt-flow": "Молочна футболка DevLovers з 3D-принтом {♥}\nЧиста молочна футболка з круглим вирізом і виразним чорним 3D-принтом {♥} DevLovers на грудях.\nМ’яка, мінімалістична і легка в поєднанні — створена для щоденного носіння в сучасному tech-стилі.", - "t-shirt-flow-inf": "Чорна футболка DevLovers з металевим 3D-принтом {♥}\nМінімалістична чорна футболка з круглим вирізом і металізованим 3D-емблемою {♥} DevLovers на грудях.\nЧистий сучасний вигляд із сильним контрастом — для щоденного casual-носіння та tech-івентів.", - "note": "Коричневий блокнот із гладкою обкладинкою під шкіру та тисненим символом {♥}.\nМає чистий преміальний вигляд і добре підходить для щоденних нотаток, планування та ідей.\nКласний аксесуар для столу або продуманий подарунок для розробника.", - "sticker-md": "Мінімалістичний білий стікер у code-стилі з написом markdown # <3 DevLovers {♥}.\nВисококонтрастний чорний друк, тонка рамка та чистий макет — ідеально для ноутбука, блокнота чи робочого простору.", - "t-shirt-volume-heart": "Футболка DevLovers Mesh Orb (фіолетова)\nЯскрава фіолетова футболка з техно-арт принтом спереду: плетена mesh-сфера в кодових дужках. Поєднує абстрактну 3D-текстуру та dev-естетику — лаконічно, але помітно.\nУнісекс regular fit.\nВеликий акцентний фронтальний принт.\nЛегка силуетна форма на щодень.\nЛегко стилізується з денімом, джогерами та багатошаровими луками.\nЧудовий вибір для фанів dev/design з любов’ю до мінімального брендингу та виразної графіки.", - "bootle-devl": "Стильна пляшка DevLovers із нержавної сталі зі сріблястим фінішем, делікатним code-принтом по всій поверхні та виразним вертикальним логотипом.\nМінімалістична кришка-гвинт робить її зручним щоденним супутником для роботи, навчання й гідратації в русі.", - "t-shirt-black-illusion": "Чорна Tonal футболка Code-Heart\nСтримана чорна футболка з делікатним чорним-на-чорному принтом DevLovers code-heart.\nЦя версія лаконічна й елегантна — для тих, хто любить мінімалізм із технічним характером.\nУніверсальна база для монохромних і мінімалістичних образів.", - "hoodie-bright": "Жовте худі DevLovers з 3D-принтом {♥}\nЯскраве жовте худі з виразним 3D-принтом {♥} у стилі DevLovers.\nКласичний крій: капюшон, довгі рукави, кишеня-кенгуру, манжети та низ у рубчик.\nКонтрастний чорний принт на яскраво-жовтій базі робить цю річ сильним акцентом на щодень." + "t-shirt-curve": "Біла футболка Code-Heart\nЧиста біла футболка з фірмовим символом DevLovers code-heart у насиченому чорному кольорі.\nКонтрастний принт додає сучасного, чіткого вигляду й добре поєднується з джинсами, джогерами та багатошаровими образами.\nЛаконічна, мінімалістична і зручна для щоденного носіння.", + "duck": "Качечка-тімлід у режимі «я тільки перевірю один PR перед сном»: у худі, в гарнітурі, з ноутом, обклеєним стікерами так щільно, що там уже живе окремий DevOps.\nКава підписана «Team Lead», поруч сидить пінгвін-стажер, а сама качка з виглядом людини, яка щойно сказала:\n«Це не баг, це фіча… але давайте все ж закриємо це в цьому спринті».", + "hoodie-logo": "Худі DevLovers Minimal Logo (графіт)\nАкуратне худі на щодень із делікатним логотипом DevLovers на грудях в стилі вишивки. Створене для тих, хто любить стриманий брендинг і сучасний силует — графітовий колір легко вписується в повсякденний гардероб.\nМ’яке, практичне й зручне: класичний капюшон на шнурках і кишеня-кенгуру. Ідеально для роботи, прохолодних вечорів і шарових образів.", + "hoodie-graph": "Худі DevLovers Blueprint Graph (молочне)\nЧисте повсякденне худі з акцентним принтом на спині: фірмове «серце-креслення» DevLovers у кодових дужках. Мінімалістична молочна база легко поєднується з джинсами, джогерами та стрітвеар-шарами.\nСтворене для довгих робочих днів і спокійних вихідних: вільна посадка та комфорт без зайвого об’єму. Якщо хочеш стриманий фронт і виразний бек-принт — це воно.", + "hoodie-back": "Худі DevLovers Binary Heart (чорне)\nЧорне худі з виразним принтом на спині з бінарного коду та символом DevLovers «серце в дужках». Спереду стримано, ззаду акцентно — для тих, хто любить лаконічний стиль із характером.\nВільний унісекс-крій\nМ’який комфорт на щодень\nКапюшон на шнурках\nКишеня-кенгуру\nВеликий принт на спині (бінарний код + символ DevLovers)", + "hoodie-sketch": "Худі DevLovers Blueprint Sketch (графіт)\nЛаконічне технічне худі з фірмовим графічним «серцем-кресленням» DevLovers спереду. Графітова база та тонка біла лінійка дають сучасний мінімалістичний вигляд, який добре працює і соло, і в багатошарових луках.\nДля щоденного носіння: вільна унісекс-посадка, регульований капюшон на шнурках і передня кишеня-кенгуру.", + "t-shirt-pink": "Рожева футболка DevLovers — принт {♥}\nМ’яка рожева футболка з фірмовим фронтальним принтом {♥} у яскравому червоно-блакитному офсеті.\nЧиста, грайлива і легка для щоденного стилювання.\nЧудово підходить для casual-образів, мітепів і подарунка улюбленому розробнику.", + "hoodie-black": "Чорне худі DevLovers — принт {♥}\nЛаконічне чорне худі з фірмовим фронтальним принтом {♥} у виразному червоно-блакитному офсеті.\nЦе акцентна річ на щодень, що поєднує мінімалізм і dev-ідентичність.\nІдеальне для casual-луків, кодинг-сесій і прохолодної погоди.", + "stickers-set": "Набір стікерів DevLovers\nПрокачай свій сетап яскравим набором стікерів у dev-стилі.\nУ наборі 9 унікальних варіацій логотипа {❤️} — від чистого монохрому до glitch-дизайнів — на одному зручному аркуші. Класний варіант для ноутбуків, блокнотів, робочого столу та подарункових наборів.\n9 унікальних дизайнів.\nЗмішані стилі (класика, dark, glitch, texture).\nАкуратні вирізи на одному підкладному аркуші.\nДля розробників, студентів і tech-фанів.", + "tee-graph": "Футболка DevLovers Math Graph (графіт)\nМінімалістична графітова футболка для тих, хто любить код, математику та чистий дизайн. Принт поєднує графік кривої, параметричну геометрію серця та формульні позначення з делікатними кодовими дужками — технічно, nerdy і стильно без зайвого шуму.", + "t-shirt-stereo": "Темно-синя футболка Glitch Code-Heart\nГлибока navy-футболка з яскравим DevLovers-принтом code-heart у бірюзово-червоному glitch-стилі.\nЕфект зсуву кольорів додає динаміки й енергії, зберігаючи чисту центральну композицію.\nВдалий вибір, якщо хочеш сильніший візуальний акцент без втрати dev-естетики.", + "hoodie-blue": "Худі DevLovers Glitch Heart (небесно-блакитне)\nСвіже небесно-блакитне худі з виразним принтом DevLovers «серце в дужках» у glitch-офсеті. Чистий силует і акцентний фронтальний принт — для щоденного носіння з tech-street вайбом.\nВільний унісекс-крій.\nМ’яка, затишна тканина.\nРегульований капюшон на шнурках.\nПередня кишеня-кенгуру.\nАкцентний фронтальний принт (glitch-стиль DevLovers heart).", + "bottle-bl": "Стильна бежева пляшка з виразним чорним принтом «серце в дужках» і неоновими бірюзово-блакитними акцентами.\nЦя версія має вищий контраст і більш «tech» характер.\nІдеальний вибір для тих, хто хоче практичну гідратацію з виразною developer-естетикою.", + "t-shirt-flow": "Молочна футболка DevLovers з 3D-принтом {♥}\nЧиста молочна футболка з круглим вирізом і виразним чорним 3D-принтом {♥} DevLovers на грудях.\nМ’яка, мінімалістична і легка в поєднанні — створена для щоденного носіння в сучасному tech-стилі.", + "t-shirt-flow-inf": "Чорна футболка DevLovers з металевим 3D-принтом {♥}\nМінімалістична чорна футболка з круглим вирізом і металізованим 3D-емблемою {♥} DevLovers на грудях.\nЧистий сучасний вигляд із сильним контрастом — для щоденного casual-носіння та tech-івентів.", + "note": "Коричневий блокнот із гладкою обкладинкою під шкіру та тисненим символом {♥}.\nМає чистий преміальний вигляд і добре підходить для щоденних нотаток, планування та ідей.\nКласний аксесуар для столу або продуманий подарунок для розробника.", + "sticker-md": "Мінімалістичний білий стікер у code-стилі з написом markdown # <3 DevLovers {♥}.\nВисококонтрастний чорний друк, тонка рамка та чистий макет — ідеально для ноутбука, блокнота чи робочого простору.", + "t-shirt-volume-heart": "Футболка DevLovers Mesh Orb (фіолетова)\nЯскрава фіолетова футболка з техно-арт принтом спереду: плетена mesh-сфера в кодових дужках. Поєднує абстрактну 3D-текстуру та dev-естетику — лаконічно, але помітно.\nУнісекс regular fit.\nВеликий акцентний фронтальний принт.\nЛегка силуетна форма на щодень.\nЛегко стилізується з денімом, джогерами та багатошаровими луками.\nЧудовий вибір для фанів dev/design з любов’ю до мінімального брендингу та виразної графіки.", + "bootle-devl": "Стильна пляшка DevLovers із нержавної сталі зі сріблястим фінішем, делікатним code-принтом по всій поверхні та виразним вертикальним логотипом.\nМінімалістична кришка-гвинт робить її зручним щоденним супутником для роботи, навчання й гідратації в русі.", + "t-shirt-black-illusion": "Чорна Tonal футболка Code-Heart\nСтримана чорна футболка з делікатним чорним-на-чорному принтом DevLovers code-heart.\nЦя версія лаконічна й елегантна — для тих, хто любить мінімалізм із технічним характером.\nУніверсальна база для монохромних і мінімалістичних образів.", + "hoodie-bright": "Жовте худі DevLovers з 3D-принтом {♥}\nЯскраве жовте худі з виразним 3D-принтом {♥} у стилі DevLovers.\nКласичний крій: капюшон, довгі рукави, кишеня-кенгуру, манжети та низ у рубчик.\nКонтрастний чорний принт на яскраво-жовтій базі робить цю річ сильним акцентом на щодень." } }, "about": {