diff --git a/frontend/app/[locale]/shop/cart/capabilities.ts b/frontend/app/[locale]/shop/cart/capabilities.ts index 8cd79e33..cf9409b4 100644 --- a/frontend/app/[locale]/shop/cart/capabilities.ts +++ b/frontend/app/[locale]/shop/cart/capabilities.ts @@ -1,14 +1,40 @@ -import { resolveStandardStorefrontProviderCapabilities } from '@/lib/shop/commercial-policy.server'; +import { isMonobankEnabled } from '@/lib/env/monobank'; +import { readServerEnv } from '@/lib/env/server-env'; +import { isPaymentsEnabled as isStripePaymentsEnabled } from '@/lib/env/stripe'; + +function isFlagEnabled(value: string | undefined): boolean { + const normalized = (value ?? '').trim().toLowerCase(); + return ( + normalized === 'true' || + normalized === '1' || + normalized === 'yes' || + normalized === 'on' + ); +} export function resolveStripeCheckoutEnabled(): boolean { - return resolveStandardStorefrontProviderCapabilities().stripeCheckoutEnabled; + try { + return isStripePaymentsEnabled({ + requirePublishableKey: true, + }); + } catch { + return false; + } } export function resolveMonobankCheckoutEnabled(): boolean { - return resolveStandardStorefrontProviderCapabilities().monobankCheckoutEnabled; + const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED')); + if (!paymentsEnabled) return false; + + try { + return isMonobankEnabled(); + } catch { + return false; + } } export function resolveMonobankGooglePayEnabled(): boolean { - return resolveStandardStorefrontProviderCapabilities() - .monobankGooglePayEnabled; + if (!resolveMonobankCheckoutEnabled()) return false; + + return isFlagEnabled(readServerEnv('SHOP_MONOBANK_GPAY_ENABLED')); } diff --git a/frontend/lib/env/monobank.ts b/frontend/lib/env/monobank.ts index 8ce8207f..d0476ad2 100644 --- a/frontend/lib/env/monobank.ts +++ b/frontend/lib/env/monobank.ts @@ -1,10 +1,10 @@ import 'server-only'; -import { getRuntimeEnv } from '@/lib/env'; import { assertProductionLikeProviderString, assertProductionLikeProviderUrl, } from '@/lib/env/provider-runtime'; +import { readServerEnv } from '@/lib/env/server-env'; export type MonobankEnv = { token: string | null; @@ -23,17 +23,17 @@ function parseWebhookMode(raw: string | undefined): MonobankWebhookMode { } export function getMonobankConfig(): MonobankConfig { - const rawMode = process.env.MONO_WEBHOOK_MODE; + const rawMode = readServerEnv('MONO_WEBHOOK_MODE'); return { webhookMode: parseWebhookMode(rawMode), - refundEnabled: process.env.MONO_REFUND_ENABLED === 'true', + refundEnabled: readServerEnv('MONO_REFUND_ENABLED') === 'true', invoiceValiditySeconds: parsePositiveInt( - process.env.MONO_INVOICE_VALIDITY_SECONDS, + readServerEnv('MONO_INVOICE_VALIDITY_SECONDS'), 86400 ), timeSkewToleranceSec: parsePositiveInt( - process.env.MONO_TIME_SKEW_TOLERANCE_SEC, + readServerEnv('MONO_TIME_SKEW_TOLERANCE_SEC'), 300 ), baseUrlSource: resolveBaseUrlSource(), @@ -71,7 +71,7 @@ function parsePositiveInt(raw: string | undefined, fallback: number): number { } function resolveMonobankToken(): string | null { - return nonEmpty(process.env.MONO_MERCHANT_TOKEN); + return nonEmpty(readServerEnv('MONO_MERCHANT_TOKEN')); } function assertMonobankRuntimeConfig(args: { @@ -102,9 +102,10 @@ function assertMonobankRuntimeConfig(args: { } function resolveBaseUrlSource(): MonobankConfig['baseUrlSource'] { - if (nonEmpty(process.env.SHOP_BASE_URL)) return 'shop_base_url'; - if (nonEmpty(process.env.APP_ORIGIN)) return 'app_origin'; - if (nonEmpty(process.env.NEXT_PUBLIC_SITE_URL)) return 'next_public_site_url'; + if (nonEmpty(readServerEnv('SHOP_BASE_URL'))) return 'shop_base_url'; + if (nonEmpty(readServerEnv('APP_ORIGIN'))) return 'app_origin'; + if (nonEmpty(readServerEnv('NEXT_PUBLIC_SITE_URL'))) + return 'next_public_site_url'; return 'unknown'; } @@ -116,28 +117,28 @@ export function requireMonobankToken(): string { assertMonobankRuntimeConfig({ token, apiBaseUrl: - nonEmpty(process.env.MONO_API_BASE) ?? 'https://api.monobank.ua', - publicKey: nonEmpty(process.env.MONO_PUBLIC_KEY), + nonEmpty(readServerEnv('MONO_API_BASE')) ?? 'https://api.monobank.ua', + publicKey: nonEmpty(readServerEnv('MONO_PUBLIC_KEY')), }); return token; } export function getMonobankEnv(): MonobankEnv { - const runtimeEnv = getRuntimeEnv(); + const nodeEnv = readServerEnv('NODE_ENV') ?? process.env.NODE_ENV; const token = resolveMonobankToken(); - const publicKey = nonEmpty(process.env.MONO_PUBLIC_KEY); + const publicKey = nonEmpty(readServerEnv('MONO_PUBLIC_KEY')); const apiBaseUrl = - nonEmpty(process.env.MONO_API_BASE) ?? 'https://api.monobank.ua'; + nonEmpty(readServerEnv('MONO_API_BASE')) ?? 'https://api.monobank.ua'; - const paymentsFlag = process.env.PAYMENTS_ENABLED ?? 'false'; + const paymentsFlag = readServerEnv('PAYMENTS_ENABLED') ?? 'false'; const configured = !!token; const paymentsEnabled = String(paymentsFlag).trim() === 'true' && configured; const invoiceTimeoutMs = parseTimeoutMs( - process.env.MONO_INVOICE_TIMEOUT_MS, - runtimeEnv.NODE_ENV === 'production' ? 8000 : 12000 + readServerEnv('MONO_INVOICE_TIMEOUT_MS'), + String(nodeEnv).trim().toLowerCase() === 'production' ? 8000 : 12000 ); if (!paymentsEnabled) { @@ -172,8 +173,8 @@ export function isMonobankEnabled(): boolean { assertMonobankRuntimeConfig({ token, apiBaseUrl: - nonEmpty(process.env.MONO_API_BASE) ?? 'https://api.monobank.ua', - publicKey: nonEmpty(process.env.MONO_PUBLIC_KEY), + nonEmpty(readServerEnv('MONO_API_BASE')) ?? 'https://api.monobank.ua', + publicKey: nonEmpty(readServerEnv('MONO_PUBLIC_KEY')), }); return true; diff --git a/frontend/lib/env/provider-runtime.ts b/frontend/lib/env/provider-runtime.ts index ebe5f798..4d906851 100644 --- a/frontend/lib/env/provider-runtime.ts +++ b/frontend/lib/env/provider-runtime.ts @@ -1,5 +1,7 @@ import 'server-only'; +import { readServerEnv } from './server-env'; + const PLACEHOLDER_SEGMENTS = new Set([ 'test', 'testing', @@ -59,10 +61,10 @@ export class ShopProviderConfigError extends Error { } export function isProductionLikeRuntime(): boolean { - const appEnv = String(process.env.APP_ENV ?? '') + const appEnv = String(readServerEnv('APP_ENV') ?? '') .trim() .toLowerCase(); - const nodeEnv = String(process.env.NODE_ENV ?? '') + const nodeEnv = String(readServerEnv('NODE_ENV') ?? process.env.NODE_ENV ?? '') .trim() .toLowerCase(); return appEnv === 'production' || nodeEnv === 'production'; diff --git a/frontend/lib/env/stripe.ts b/frontend/lib/env/stripe.ts index eb7c772a..f49885fc 100644 --- a/frontend/lib/env/stripe.ts +++ b/frontend/lib/env/stripe.ts @@ -1,9 +1,9 @@ -import { getClientEnv, getRuntimeEnv } from '@/lib/env'; import { assertProductionLikeProviderString, isProductionLikeRuntime, ShopProviderConfigError, } from '@/lib/env/provider-runtime'; +import { readServerEnv } from '@/lib/env/server-env'; export type StripeEnv = { secretKey: string | null; @@ -25,19 +25,21 @@ function nonEmpty(v: string | undefined): string | null { } export function getStripeEnv(): StripeEnv { - const runtimeEnv = getRuntimeEnv(); - const clientEnv = getClientEnv(); - - const paymentsFlag = process.env.PAYMENTS_ENABLED ?? 'false'; - const secretKey = nonEmpty(process.env.STRIPE_SECRET_KEY); - const webhookSecret = nonEmpty(process.env.STRIPE_WEBHOOK_SECRET); + const nodeEnv = readServerEnv('NODE_ENV') ?? process.env.NODE_ENV; + const paymentsFlag = readServerEnv('PAYMENTS_ENABLED') ?? 'false'; + const secretKey = nonEmpty(readServerEnv('STRIPE_SECRET_KEY')); + const webhookSecret = nonEmpty(readServerEnv('STRIPE_WEBHOOK_SECRET')); const publishableKey = nonEmpty( - clientEnv.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? undefined + readServerEnv('NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY') ); + const rawMode = nonEmpty(readServerEnv('STRIPE_MODE'))?.toLowerCase(); const mode = - (nonEmpty(process.env.STRIPE_MODE) as 'test' | 'live' | null) ?? - (runtimeEnv.NODE_ENV === 'production' ? 'live' : 'test'); + rawMode === 'test' || rawMode === 'live' + ? rawMode + : String(nodeEnv).trim().toLowerCase() === 'production' + ? 'live' + : 'test'; const paymentsEnabled = paymentsFlag === 'true' && !!secretKey && !!webhookSecret; @@ -100,10 +102,10 @@ function isFlagEnabled(value: string | undefined): boolean { } function isStripeRailEnabledByFlags(): boolean { - const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED); + const paymentsEnabled = isFlagEnabled(readServerEnv('PAYMENTS_ENABLED')); if (!paymentsEnabled) return false; - const stripeFlag = (process.env.STRIPE_PAYMENTS_ENABLED ?? '').trim(); + const stripeFlag = (readServerEnv('STRIPE_PAYMENTS_ENABLED') ?? '').trim(); return stripeFlag.length > 0 ? stripeFlag === 'true' : true; } diff --git a/frontend/lib/tests/shop/public-cart-env-contract.test.ts b/frontend/lib/tests/shop/public-cart-env-contract.test.ts index 4f97f56c..2bfd4951 100644 --- a/frontend/lib/tests/shop/public-cart-env-contract.test.ts +++ b/frontend/lib/tests/shop/public-cart-env-contract.test.ts @@ -1,30 +1,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const readServerEnvMock = vi.hoisted(() => vi.fn()); -const isMonobankEnabledMock = vi.hoisted(() => vi.fn()); -const getMonobankEnvMock = vi.hoisted(() => vi.fn()); -const isStripePaymentsEnabledMock = vi.hoisted(() => vi.fn()); -const getStripeEnvMock = vi.hoisted(() => vi.fn()); -const ENV_KEYS = ['SHOP_BASE_URL'] as const; +const ENV_KEYS = [ + 'APP_ENV', + 'PAYMENTS_ENABLED', + 'STRIPE_PAYMENTS_ENABLED', + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY', + 'STRIPE_MODE', + 'MONO_MERCHANT_TOKEN', + 'MONO_PUBLIC_KEY', + 'MONO_API_BASE', + 'MONO_INVOICE_TIMEOUT_MS', + 'MONO_REFUND_ENABLED', + 'MONO_INVOICE_VALIDITY_SECONDS', + 'MONO_TIME_SKEW_TOLERANCE_SEC', + 'SHOP_MONOBANK_GPAY_ENABLED', + 'SHOP_BASE_URL', + 'APP_ORIGIN', + 'NEXT_PUBLIC_SITE_URL', + 'AUTH_SECRET', + 'SHOP_STATUS_TOKEN_SECRET', + 'DATABASE_URL', + 'DATABASE_URL_LOCAL', +] as const; const previousEnv: Record<(typeof ENV_KEYS)[number], string | undefined> = Object.create(null); -function baselineCriticalEnv(key: string): string | undefined { +function baselineCartEnv(key: string): string | undefined { switch (key) { - case 'APP_ENV': - return 'local'; - case 'DATABASE_URL_LOCAL': - return 'postgresql://devlovers_local:test@localhost:5432/devlovers_shop_local_clean?sslmode=disable'; - case 'AUTH_SECRET': - return 'test_auth_secret_test_auth_secret_test_auth_secret'; - case 'SHOP_STATUS_TOKEN_SECRET': - return 'test_status_token_secret_test_status_token_secret'; - case 'STRIPE_SECRET_KEY': - return 'sk_test_checkout_enabled'; - case 'STRIPE_WEBHOOK_SECRET': - return 'whsec_test_checkout_enabled'; - case 'MONO_MERCHANT_TOKEN': - return 'mono_test_checkout_enabled'; + case 'PAYMENTS_ENABLED': + return 'false'; default: return undefined; } @@ -34,41 +41,17 @@ vi.mock('@/lib/env/server-env', () => ({ readServerEnv: (key: string) => readServerEnvMock(key), })); -vi.mock('@/lib/env/monobank', () => ({ - isMonobankEnabled: () => isMonobankEnabledMock(), - getMonobankEnv: () => getMonobankEnvMock(), -})); - -vi.mock('@/lib/env/stripe', () => ({ - isPaymentsEnabled: (args?: unknown) => isStripePaymentsEnabledMock(args), - getStripeEnv: () => getStripeEnvMock(), -})); - describe('public cart env contract', () => { beforeEach(() => { for (const key of ENV_KEYS) { previousEnv[key] = process.env[key]; + delete process.env[key]; } - process.env.SHOP_BASE_URL = 'http://localhost:3000'; vi.clearAllMocks(); vi.resetModules(); readServerEnvMock.mockImplementation((key: string) => - baselineCriticalEnv(key) + baselineCartEnv(key) ); - getStripeEnvMock.mockReturnValue({ - paymentsEnabled: true, - secretKey: 'sk_test_checkout_enabled', - webhookSecret: 'whsec_test_checkout_enabled', - publishableKey: null, - mode: 'test', - }); - getMonobankEnvMock.mockReturnValue({ - token: 'mono_test_checkout_enabled', - apiBaseUrl: 'https://api.monobank.ua', - paymentsEnabled: true, - invoiceTimeoutMs: 12000, - publicKey: null, - }); }); afterEach(() => { @@ -84,21 +67,20 @@ describe('public cart env contract', () => { it('resolves monobank checkout from readServerEnv PAYMENTS_ENABLED before checking provider capability', async () => { readServerEnvMock.mockImplementation((key: string) => - key === 'PAYMENTS_ENABLED' ? 'true' : baselineCriticalEnv(key) + key === 'PAYMENTS_ENABLED' ? 'true' : baselineCartEnv(key) ); - isMonobankEnabledMock.mockReturnValue(true); const mod = await import('@/app/[locale]/shop/cart/capabilities'); const enabled = mod.resolveMonobankCheckoutEnabled(); - expect(enabled).toBe(true); + expect(enabled).toBe(false); expect(readServerEnvMock).toHaveBeenCalledWith('PAYMENTS_ENABLED'); - expect(isMonobankEnabledMock).toHaveBeenCalledTimes(1); + expect(readServerEnvMock).toHaveBeenCalledWith('MONO_MERCHANT_TOKEN'); }); it('does not check monobank provider capability when readServerEnv PAYMENTS_ENABLED is disabled', async () => { readServerEnvMock.mockImplementation((key: string) => - key === 'PAYMENTS_ENABLED' ? 'false' : baselineCriticalEnv(key) + key === 'PAYMENTS_ENABLED' ? 'false' : baselineCartEnv(key) ); const mod = await import('@/app/[locale]/shop/cart/capabilities'); @@ -106,16 +88,18 @@ describe('public cart env contract', () => { expect(enabled).toBe(false); expect(readServerEnvMock).toHaveBeenCalledWith('PAYMENTS_ENABLED'); - expect(isMonobankEnabledMock).not.toHaveBeenCalled(); + expect(readServerEnvMock).not.toHaveBeenCalledWith('MONO_MERCHANT_TOKEN'); }); it('treats normalized truthy env values as enabled for capability resolution', async () => { readServerEnvMock.mockImplementation((key: string) => { if (key === 'PAYMENTS_ENABLED') return ' YES '; if (key === 'SHOP_MONOBANK_GPAY_ENABLED') return ' On '; - return baselineCriticalEnv(key); + if (key === 'MONO_MERCHANT_TOKEN') return 'mono_live_12345678'; + if (key === 'MONO_PUBLIC_KEY') return 'mono_live_public_12345678'; + if (key === 'MONO_API_BASE') return 'https://api.monobank.ua'; + return baselineCartEnv(key); }); - isMonobankEnabledMock.mockReturnValue(true); const mod = await import('@/app/[locale]/shop/cart/capabilities'); @@ -131,9 +115,11 @@ describe('public cart env contract', () => { readServerEnvMock.mockImplementation((key: string) => { if (key === 'PAYMENTS_ENABLED') return 'true'; if (key === 'SHOP_MONOBANK_GPAY_ENABLED') return 'on'; - return baselineCriticalEnv(key); + if (key === 'MONO_MERCHANT_TOKEN') return 'mono_runtime_only_12345678'; + if (key === 'MONO_PUBLIC_KEY') return 'mono_public_runtime_12345678'; + if (key === 'MONO_API_BASE') return 'https://api.monobank.ua'; + return baselineCartEnv(key); }); - isMonobankEnabledMock.mockReturnValue(true); const mod = await import('@/app/[locale]/shop/cart/capabilities'); const enabled = mod.resolveMonobankGooglePayEnabled(); @@ -149,7 +135,7 @@ describe('public cart env contract', () => { readServerEnvMock.mockImplementation((key: string) => { if (key === 'SHOP_TERMS_VERSION') return 'terms-v7'; if (key === 'SHOP_PRIVACY_VERSION') return undefined; - return baselineCriticalEnv(key); + return baselineCartEnv(key); }); const mod = await import('@/lib/env/shop-legal'); @@ -162,4 +148,124 @@ describe('public cart env contract', () => { expect(readServerEnvMock).toHaveBeenCalledWith('SHOP_TERMS_VERSION'); expect(readServerEnvMock).toHaveBeenCalledWith('SHOP_PRIVACY_VERSION'); }); + + it('does not throw when AUTH_SECRET, SHOP_STATUS_TOKEN_SECRET, and database env are absent', async () => { + readServerEnvMock.mockImplementation((key: string) => { + if ( + key === 'AUTH_SECRET' || + key === 'SHOP_STATUS_TOKEN_SECRET' || + key === 'APP_ENV' || + key === 'DATABASE_URL' || + key === 'DATABASE_URL_LOCAL' + ) { + return undefined; + } + return baselineCartEnv(key); + }); + + const mod = await import('@/app/[locale]/shop/cart/capabilities'); + + expect(() => mod.resolveStripeCheckoutEnabled()).not.toThrow(); + expect(() => mod.resolveMonobankCheckoutEnabled()).not.toThrow(); + expect(() => mod.resolveMonobankGooglePayEnabled()).not.toThrow(); + }); + + it('enables stripe capability from runtime-only env when config is valid', async () => { + readServerEnvMock.mockImplementation((key: string) => { + switch (key) { + case 'PAYMENTS_ENABLED': + return 'true'; + case 'STRIPE_SECRET_KEY': + return 'sk_test_runtime_only_1234567890'; + case 'STRIPE_WEBHOOK_SECRET': + return 'whsec_runtime_only_1234567890'; + case 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY': + return 'pk_test_runtime_only_1234567890'; + default: + return baselineCartEnv(key); + } + }); + + const mod = await import('@/app/[locale]/shop/cart/capabilities'); + + expect(mod.resolveStripeCheckoutEnabled()).toBe(true); + }); + + it('enables monobank capability from runtime-only env when config is valid', async () => { + readServerEnvMock.mockImplementation((key: string) => { + switch (key) { + case 'PAYMENTS_ENABLED': + return 'true'; + case 'MONO_MERCHANT_TOKEN': + return 'mono_runtime_only_12345678'; + case 'MONO_PUBLIC_KEY': + return 'mono_public_runtime_12345678'; + case 'MONO_API_BASE': + return 'https://api.monobank.ua'; + case 'SHOP_MONOBANK_GPAY_ENABLED': + return 'on'; + default: + return baselineCartEnv(key); + } + }); + + const mod = await import('@/app/[locale]/shop/cart/capabilities'); + + expect(mod.resolveMonobankCheckoutEnabled()).toBe(true); + expect(mod.resolveMonobankGooglePayEnabled()).toBe(true); + }); + + it('fails closed for stripe capability when runtime-only config is partial or invalid', async () => { + readServerEnvMock.mockImplementation((key: string) => { + switch (key) { + case 'APP_ENV': + return 'production'; + case 'PAYMENTS_ENABLED': + return 'true'; + case 'STRIPE_SECRET_KEY': + return 'sk_test_placeholder'; + case 'STRIPE_WEBHOOK_SECRET': + return 'whsec_placeholder_value'; + case 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY': + return 'pk_test_placeholder'; + case 'STRIPE_MODE': + return 'test'; + default: + return baselineCartEnv(key); + } + }); + + const mod = await import('@/app/[locale]/shop/cart/capabilities'); + + expect(() => mod.resolveStripeCheckoutEnabled()).not.toThrow(); + expect(mod.resolveStripeCheckoutEnabled()).toBe(false); + }); + + it('fails closed for monobank capability when runtime-only config is partial or invalid', async () => { + readServerEnvMock.mockImplementation((key: string) => { + switch (key) { + case 'APP_ENV': + return 'production'; + case 'PAYMENTS_ENABLED': + return 'true'; + case 'MONO_MERCHANT_TOKEN': + return 'mono_test_placeholder'; + case 'MONO_PUBLIC_KEY': + return 'mono_test_public'; + case 'MONO_API_BASE': + return 'https://api.example.test'; + case 'SHOP_MONOBANK_GPAY_ENABLED': + return 'on'; + default: + return baselineCartEnv(key); + } + }); + + const mod = await import('@/app/[locale]/shop/cart/capabilities'); + + expect(() => mod.resolveMonobankCheckoutEnabled()).not.toThrow(); + expect(mod.resolveMonobankCheckoutEnabled()).toBe(false); + expect(() => mod.resolveMonobankGooglePayEnabled()).not.toThrow(); + expect(mod.resolveMonobankGooglePayEnabled()).toBe(false); + }); });