diff --git a/frontend/e2e/diagnostics-checkout-recovery.spec.ts b/frontend/e2e/diagnostics-checkout-recovery.spec.ts index d7c90f578..640f8e338 100644 --- a/frontend/e2e/diagnostics-checkout-recovery.spec.ts +++ b/frontend/e2e/diagnostics-checkout-recovery.spec.ts @@ -161,12 +161,85 @@ test('a structured quantity rejection keeps its specific message without a dupli operation_id: originalOperationId, outcome: 'not_applied', }); + expect( + supportSubmissions[0].message, + 'support ticket contains the informative rejection reason', + ).toBe('Invalid order quantity'); expect(supportSubmissions[1]).toEqual(supportSubmissions[0]); } finally { await restoreBusiness(page, originalBusiness); } }); +test('a structured stock rejection displays the specific item name to the cashier and in the support ticket', async ({ + page, +}) => { + const originalBusiness = await setBillingType(page, 'postpaid'); + try { + await loginAndOpenPos(page); + const supportSubmissions: Array> = []; + await page.route('**/api/support-ticket', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + const payload = route.request().postDataJSON() as Record; + supportSubmissions.push(payload); + await route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ client_ticket_id: payload.client_ticket_id }), + }); + }); + let originalOperationId = ''; + await page.route('**/api/orders', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + const operationId = route.request().headers()['x-operation-id']; + originalOperationId = operationId || ''; + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + error: 'Insufficient stock for "Double Cheeseburger"', + reason_code: 'order.stock_insufficient', + operation: 'order.create', + stage: 'validation', + classification: 'expected_rejection', + outcome: 'not_applied', + correlation_id: operationId, + }), + }); + }); + + await addOneProduct(page); + await page.getByRole('button', { name: 'Place Order' }).click(); + + await expect( + page.getByRole('main').getByText('Insufficient stock for "Double Cheeseburger"'), + ).toBeVisible(); + await expect(page.getByText('Correct the details, then submit the order again.')).toBeVisible(); + + const getHelp = page.getByRole('button', { name: 'Get help', exact: true }); + await getHelp.click(); + await expect.poll(() => supportSubmissions.length).toBe(1); + expect( + supportSubmissions[0].message, + 'support ticket receives the item-specific stock rejection message', + ).toBe('Insufficient stock for "Double Cheeseburger"'); + expect(supportSubmissions[0].correlation_id).toBe(originalOperationId); + expect(supportSubmissions[0].operation_failure).toEqual({ + capture_point: 'fc.backend.order.create.stock_validation', + operation_id: originalOperationId, + outcome: 'not_applied', + }); + } finally { + await restoreBusiness(page, originalBusiness); + } +}); + test('a lost prepaid payment response replays the same saved request after bill recovery', async ({ page, }) => { diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index 269774c2a..590b467d8 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -726,6 +726,7 @@ export default function POSPage() { const isAddonOrGeneralValidation = failure.reasonCode === 'order.addon_selection_required' || failure.reasonCode === 'order.addon_unavailable' || + failure.reasonCode === 'order.stock_insufficient' || failure.reasonCode === 'validation.rejected'; const rejectionMessage = isAddonOrGeneralValidation && failure.serverError diff --git a/frontend/src/lib/order-attempt.ts b/frontend/src/lib/order-attempt.ts index 219824b0e..9389c4b95 100644 --- a/frontend/src/lib/order-attempt.ts +++ b/frontend/src/lib/order-attempt.ts @@ -461,6 +461,50 @@ function plainRecord(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } +export function sanitizeServerError(rawError: unknown): string | null { + if (typeof rawError !== 'string') return null; + const trimmed = rawError.trim(); + if (!trimmed) return null; + const bounded = trimmed.length > 255 ? trimmed.slice(0, 255) : trimmed; + if ( + /[\r\n]/.test(bounded) || + /\bat\s+\S+\s+\(/.test(bounded) || + /\bat\s+.*:\d+:\d+/.test(bounded) + ) { + return null; + } + if ( + /(?:sqlite|syntax error|database (?:locked|disk image)|no such (?:table|column)|foreign key constraint|unique constraint|primary key constraint|check constraint|table\s+\w+|column\s+\w+|select\s+.+from|insert\s+into|update\s+.+set|delete\s+from|drop\s+table|alter\s+table)/i.test( + bounded, + ) + ) { + return null; + } + if ( + /^(?:[A-Z][a-zA-Z0-9_]*(?:Error|Exception)|TypeError|RangeError|SyntaxError|ReferenceError|SystemError):/i.test( + bounded, + ) + ) { + return null; + } + if ( + /\b(?:ENOENT|EACCES|EPERM|ECONNREFUSED|ECONNRESET|EADDRINUSE|ETIMEDOUT|EPIPE)\b/i.test(bounded) + ) { + return null; + } + if (/(?:^|[\s"'`])(?:(?:\/|[a-zA-Z]:[\\/])\w+|(?:\.\.?[\\/]\w+))/i.test(bounded)) { + return null; + } + if ( + /\b(?:bearer\s+[a-zA-Z0-9._-]+|jwt|api[_-]?key|client[_-]?secret|secret[_-]?key|private[_-]?key|password)\b/i.test( + bounded, + ) + ) { + return null; + } + return bounded; +} + /** Trusts additive server metadata only when the closed tuple and operation reference match. */ export function classifyOrderRequestFailure( error: unknown, @@ -553,6 +597,9 @@ export function classifyOrderRequestFailure( ) return fallback; + const serverError = sanitizeServerError(payload.error); + const detail = serverError ?? fallback.detail; + return { ...fallback, code: payload.reason_code, @@ -562,7 +609,8 @@ export function classifyOrderRequestFailure( classification: definition.classification, correlationId: expected.correlationId, displayCode: typeof displayCode === 'string' ? displayCode : null, - serverError: typeof payload.error === 'string' ? payload.error : null, + serverError, + detail, trusted: true, }; } diff --git a/main/routes/orders.ts b/main/routes/orders.ts index db4d9cb93..8be52f317 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -425,11 +425,9 @@ function moveOrderItemAddonStock( const pool = db.prepare('SELECT stock_quantity FROM addons WHERE id = ?').get(addon.id) as | { stock_quantity: number } | undefined; - throw Object.assign( - new Error( - `Add-on "${addon.name}" is out of stock (requested ${quantity}, available ${Number(pool?.stock_quantity ?? 0)})`, - ), - { statusCode: 400 }, + throw new InventoryServiceError( + 400, + `Add-on "${addon.name}" is out of stock (requested ${quantity}, available ${Number(pool?.stock_quantity ?? 0)})`, ); } } diff --git a/main/services/cloud-sync.ts b/main/services/cloud-sync.ts index 92baaa52b..2083006db 100644 --- a/main/services/cloud-sync.ts +++ b/main/services/cloud-sync.ts @@ -2496,6 +2496,9 @@ export class CloudSyncService { }>; if (rows.length === 0) return; + const { isTelemetrySuppressed } = require('./telemetry') as typeof import('./telemetry'); + if (isTelemetrySuppressed()) return; + for (const row of rows) { if (this.cloudDeletionInProgress || this.shutdownRequested) return; if (!isDiagnosticsConsentEnabled() || !isDiagnosticsTransmissionEnabled()) return; diff --git a/main/services/inventory.ts b/main/services/inventory.ts index eb42b513c..a806555eb 100644 --- a/main/services/inventory.ts +++ b/main/services/inventory.ts @@ -192,8 +192,26 @@ export function adjustProductStock( stockLabel = 'Variant'; stockIdArgs = [variantId, options.productId as string | number]; } - const readStock = db.prepare(`SELECT stock_quantity FROM ${stockTable} WHERE ${stockKey}`); - const current = readStock.get(...stockIdArgs) as { stock_quantity: number | null } | undefined; + const readStock = db.prepare(`SELECT stock_quantity, name FROM ${stockTable} WHERE ${stockKey}`); + const current = readStock.get(...stockIdArgs) as + | { stock_quantity: number | null; name?: string } + | undefined; + let itemName: string | null = null; + if (current?.name) { + const safeName = (name: string) => (name.length > 60 ? `${name.slice(0, 57)}...` : name); + if (variantId && options.productId) { + const productRow = db + .prepare('SELECT name FROM products WHERE id = ?') + .get(options.productId as string | number) as { name?: string } | undefined; + itemName = productRow?.name + ? `"${safeName(productRow.name)}" (${safeName(current.name)})` + : `variant "${safeName(current.name)}"`; + } else if (addonId) { + itemName = `add-on "${safeName(current.name)}"`; + } else { + itemName = `"${safeName(current.name)}"`; + } + } const stockBefore = Number(current?.stock_quantity ?? 0); const update = db.prepare(` UPDATE ${stockTable} @@ -226,7 +244,10 @@ export function adjustProductStock( if (!current) { throw new InventoryServiceError(404, `${stockLabel} not found`); } - throw new InventoryServiceError(400, 'Insufficient stock'); + throw new InventoryServiceError( + 400, + itemName ? `Insufficient stock for ${itemName}` : 'Insufficient stock', + ); } const updatedStock = readStock.get(...stockIdArgs) as { stock_quantity: number }; diff --git a/main/services/posthog-config.ts b/main/services/posthog-config.ts index 310cc301e..bc28c35b7 100644 --- a/main/services/posthog-config.ts +++ b/main/services/posthog-config.ts @@ -39,6 +39,8 @@ export function postHogEnvironment(): PostHogEnvironment | null { if (process.env.CI) return null; if (process.env.FLO_MATRIX_OFFLINE === '1') return null; if (process.env.E2E_TASK_LOCAL_PORTS) return null; + if (process.env.NODE_ENV === 'test') return null; + if (process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK === '1') return null; if ((process as NodeJS.Process & { type?: string }).type !== 'browser') return null; if (process.env.FLO_POSTHOG_DEV === '1') return 'development'; return isOfficialReleaseBuild() ? 'production' : null; diff --git a/main/services/telemetry.ts b/main/services/telemetry.ts index 2e6254994..f5066339b 100644 --- a/main/services/telemetry.ts +++ b/main/services/telemetry.ts @@ -15,6 +15,7 @@ import { } from '../db'; import { normalizeSupportedCurrencyCode } from '../../shared/currencies'; import { isOperationReference, serializeOperationFailure } from '../lib/operation-failure'; +import { getBuildIdentity } from './build-identity'; export const TELEMETRY_URL = 'https://telemetry.flopos.com/collect'; @@ -125,8 +126,36 @@ export function getInstallSource(): InstallSource { return linuxPackageFormat(); } +export function isTelemetrySuppressed(): boolean { + if ( + process.env.FLO_MATRIX_OFFLINE === '1' || + Boolean(process.env.E2E_TASK_LOCAL_PORTS) || + process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK === '1' + ) { + return true; + } + if (process.env.FLO_TELEMETRY_DEV === '1') { + return false; + } + if (process.env.NODE_ENV === 'test' || Boolean(process.env.CI)) { + return true; + } + const version = typeof app?.getVersion === 'function' ? app.getVersion() : ''; + if ( + version === '1.0.0-test' || + version === 'test' || + version === 'e2e' || + /(?:^|[-.])(?:test|e2e)(?:[-.]|$)/i.test(version) + ) { + return true; + } + const isDev = !app?.isPackaged || getBuildIdentity() === 'dev'; + if (isDev) return true; + return false; +} + function matrixOffline(): boolean { - return process.env.FLO_MATRIX_OFFLINE === '1'; + return isTelemetrySuppressed(); } function trackTelemetry(operation: Promise): Promise { @@ -326,7 +355,7 @@ export async function sendAnonymousDiagnosticReport( try { const anonId = ensureTelemetryAnonId(); - const build = require('./build-identity').getBuildIdentity() as string; + const build = getBuildIdentity(); const response = await fetch(TELEMETRY_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/package.json b/package.json index 2a0bc2591..382f8eb3b 100644 --- a/package.json +++ b/package.json @@ -286,7 +286,8 @@ "test:diagnostics-outbox-bounds": "node tests/run-electron-node-test.cjs tests/diagnostics-outbox-bounds.test.ts", "test:diagnostic-wire-producer": "node tests/run-electron-node-test.cjs tests/diagnostic-wire-producer.test.ts", "test:diagnostics-screen": "node tests/run-electron-node-test.cjs tests/diagnostics-screen.test.ts", - "test:diagnostics": "bash tests/run-test.sh npm run test:diagnostics-event && bash tests/run-test.sh npm run test:diagnostic-signature && bash tests/run-test.sh npm run test:diagnostics-outbox-bounds && bash tests/run-test.sh npm run test:diagnostic-wire-producer && bash tests/run-test.sh npm run test:diagnostics-screen && bash tests/run-test.sh npm run test:diagnostics-feature-coverage", + "test:telemetry-leakage-prevention": "node tests/run-electron-node-test.cjs tests/telemetry-leakage-prevention.test.ts", + "test:diagnostics": "bash tests/run-test.sh npm run test:diagnostics-event && bash tests/run-test.sh npm run test:diagnostic-signature && bash tests/run-test.sh npm run test:diagnostics-outbox-bounds && bash tests/run-test.sh npm run test:diagnostic-wire-producer && bash tests/run-test.sh npm run test:diagnostics-screen && bash tests/run-test.sh npm run test:diagnostics-feature-coverage && bash tests/run-test.sh npm run test:telemetry-leakage-prevention", "test:diagnostics-feature-coverage": "node tests/run-electron-node-test.cjs tests/diagnostics-feature-coverage.test.ts", "test:customer-display-config": "ts-node --transpile-only -P tests/tsconfig.json tests/customer-display-config.test.ts", "test:customer-display-settings-route": "node tests/run-electron-node-test.cjs tests/customer-display-settings-route.test.ts", diff --git a/tests/addon-inventory-db.test.ts b/tests/addon-inventory-db.test.ts index d8ab42062..940c77e58 100644 --- a/tests/addon-inventory-db.test.ts +++ b/tests/addon-inventory-db.test.ts @@ -7,33 +7,54 @@ const path = require('path'); let activeTestDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-addon-inventory-db-')); Module._load = function (request: string) { - if (request === 'electron') return { app: { isPackaged: true, getPath: () => activeTestDir, getVersion: () => 'test' } }; + if (request === 'electron') + return { app: { isPackaged: true, getPath: () => activeTestDir, getVersion: () => 'test' } }; return originalLoad.apply(this, arguments as any); }; const { - initDatabase, getDatabase, closeDatabase, now, MIGRATIONS, getCurrentSchemaVersion, - getInventoryMovementRows, validateInventoryLedgerDatabase, validateInventoryLedgerRows, - validateInventoryLedgerReplacement, resetDatabaseForCurrencyChange, createBackup, restoreBackup, + initDatabase, + getDatabase, + closeDatabase, + now, + MIGRATIONS, + getCurrentSchemaVersion, + getInventoryMovementRows, + validateInventoryLedgerDatabase, + validateInventoryLedgerRows, + validateInventoryLedgerReplacement, + resetDatabaseForCurrencyChange, + createBackup, + restoreBackup, } = require('../main/db'); const { adjustProductStock, listInventoryMovements } = require('../main/services/inventory'); const { - assertOrThrow, assertEqualOrThrow, getResults, resetCounters, + assertOrThrow, + assertEqualOrThrow, + getResults, + resetCounters, } = require('./helpers/test-setup'); const ADDON_INVENTORY_VERSION = 104; function columnInfo(db: any, table: string, column: string): any { - return (db.prepare(`PRAGMA table_info(${table})`).all() as any[]).find((info) => info.name === column); + return (db.prepare(`PRAGMA table_info(${table})`).all() as any[]).find( + (info) => info.name === column, + ); } function columnsOf(db: any, table: string): string[] { - return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((column) => column.name); + return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map( + (column) => column.name, + ); } function indexNames(db: any, table: string): string[] { - return (db.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?").all(table) as { name: string }[]) - .map((row) => row.name); + return ( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ?") + .all(table) as { name: string }[] + ).map((row) => row.name); } function seedAddonPool(db: any, addonId: string, stock: number, actorId = 'owner'): void { @@ -43,8 +64,14 @@ function seedAddonPool(db: any, addonId: string, stock: number, actorId = 'owner VALUES (?, 'milk', ?, 40, 1, ?, 2, 1, ?, ?)`).run(addonId, addonId, stock, stamp, stamp); db.prepare(`INSERT INTO inventory_movements (product_id, variant_id, addon_id, quantity_delta, movement_type, reference_type, reference_id, reason, actor_user_id, stock_after, created_at) - VALUES (NULL, NULL, ?, ?, 'adjustment', 'opening_balance', ?, 'Opening count', ?, ?, ?)`) - .run(addonId, stock, addonId, actorId, stock, stamp); + VALUES (NULL, NULL, ?, ?, 'adjustment', 'opening_balance', ?, 'Opening count', ?, ?, ?)`).run( + addonId, + stock, + addonId, + actorId, + stock, + stamp, + ); } function seedMenu(db: any): void { @@ -61,8 +88,9 @@ function seedMenu(db: any): void { VALUES ('milk', 'Milk', 1, ?, ?)`).run(stamp, stamp); db.prepare(`INSERT INTO inventory_movements (product_id, variant_id, quantity_delta, movement_type, reference_type, reference_id, reason, actor_user_id, stock_after, created_at) - VALUES ('latte', NULL, 12, 'adjustment', 'opening_balance', 'latte', 'Opening count', 'owner', 12, ?)`) - .run(stamp); + VALUES ('latte', NULL, 12, 'adjustment', 'opening_balance', 'latte', 'Opening count', 'owner', 12, ?)`).run( + stamp, + ); } async function closeStore(): Promise { @@ -75,7 +103,11 @@ function freshInstallAssertions(): void { const db = getDatabase(); const migration = MIGRATIONS.find((entry: any) => entry.version === ADDON_INVENTORY_VERSION); assertOrThrow(!!migration, 'the add-on inventory migration is registered'); - assertEqualOrThrow(migration.name, 'add_addon_inventory', 'the migration is named add_addon_inventory'); + assertEqualOrThrow( + migration.name, + 'add_addon_inventory', + 'the migration is named add_addon_inventory', + ); assertEqualOrThrow( getCurrentSchemaVersion(), MIGRATIONS[MIGRATIONS.length - 1].version, @@ -100,10 +132,18 @@ function freshInstallAssertions(): void { // An add-on pool has no owning product, so product_id must be nullable and // must keep its product foreign key for product and variant pools. const productId = columnInfo(db, 'inventory_movements', 'product_id'); - assertEqualOrThrow(productId.notnull, 0, 'inventory_movements.product_id accepts a pool with no product'); - const movementDdl = (db.prepare( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'inventory_movements'", - ).get() as { sql: string }).sql; + assertEqualOrThrow( + productId.notnull, + 0, + 'inventory_movements.product_id accepts a pool with no product', + ); + const movementDdl = ( + db + .prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'inventory_movements'", + ) + .get() as { sql: string } + ).sql; assertOrThrow( /product_id TEXT DEFAULT NULL REFERENCES products\(id\)/.test(movementDdl), 'the rebuilt ledger still references products(id) from product_id', @@ -113,17 +153,23 @@ function freshInstallAssertions(): void { 'idx_inventory_movements_reference', 'idx_inventory_movements_created', ]) { - assertOrThrow(indexNames(db, 'inventory_movements').includes(index), `the rebuilt ledger keeps ${index}`); + assertOrThrow( + indexNames(db, 'inventory_movements').includes(index), + `the rebuilt ledger keeps ${index}`, + ); } seedMenu(db); const stamp = now(); db.prepare(`INSERT INTO addons (id, addon_group_id, name, price, is_active, created_at, updated_at) VALUES ('untracked', 'milk', 'Untracked add-on', 10, 1, ?, ?)`).run(stamp, stamp); - const untracked = db.prepare('SELECT track_inventory, stock_quantity, low_stock_threshold FROM addons WHERE id = ?') + const untracked = db + .prepare('SELECT track_inventory, stock_quantity, low_stock_threshold FROM addons WHERE id = ?') .get('untracked'); assertOrThrow( - Number(untracked.track_inventory) === 0 && Number(untracked.stock_quantity) === 0 && Number(untracked.low_stock_threshold) === 0, + Number(untracked.track_inventory) === 0 && + Number(untracked.stock_quantity) === 0 && + Number(untracked.low_stock_threshold) === 0, 'an add-on defaults to an untracked, zero-stock pool', ); @@ -134,7 +180,9 @@ function freshInstallAssertions(): void { db.prepare(`INSERT INTO order_item_addons (order_item_id, addon_id, addon_name, price, quantity, created_at) VALUES (1, 'untracked', 'Untracked add-on', 10, 1, ?)`).run(stamp); assertEqualOrThrow( - db.prepare('SELECT inventory_deducted_quantity FROM order_item_addons WHERE addon_id = ?').get('untracked').inventory_deducted_quantity, + db + .prepare('SELECT inventory_deducted_quantity FROM order_item_addons WHERE addon_id = ?') + .get('untracked').inventory_deducted_quantity, 0, 'an add-on line defaults to no deducted quantity', ); @@ -159,11 +207,21 @@ function inventoryServiceAssertions(): void { actorUserId: 'owner', }); assertEqualOrThrow(sold.stockAfter, 3, 'an add-on sale reduces the add-on pool'); - const movement = db.prepare('SELECT * FROM inventory_movements WHERE addon_id = ? ORDER BY id DESC LIMIT 1').get('oat'); + const movement = db + .prepare('SELECT * FROM inventory_movements WHERE addon_id = ? ORDER BY id DESC LIMIT 1') + .get('oat'); assertEqualOrThrow(movement.product_id, null, 'an add-on movement carries no product id'); assertEqualOrThrow(movement.variant_id, null, 'an add-on movement carries no variant id'); - assertEqualOrThrow(movement.actor_user_id, 'owner', 'an add-on movement is attributed to the authenticated actor'); - assertEqualOrThrow(movement.stock_after, 3, 'an add-on movement records the resulting add-on stock'); + assertEqualOrThrow( + movement.actor_user_id, + 'owner', + 'an add-on movement is attributed to the authenticated actor', + ); + assertEqualOrThrow( + movement.stock_after, + 3, + 'an add-on movement records the resulting add-on stock', + ); assertEqualOrThrow( db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('latte').stock_quantity, 12, @@ -202,7 +260,11 @@ function inventoryServiceAssertions(): void { } catch (error: any) { insufficient = error.message; } - assertEqualOrThrow(insufficient, 'Insufficient stock', 'an add-on pool cannot be driven negative'); + assertEqualOrThrow( + insufficient, + 'Insufficient stock for add-on "oat"', + 'an add-on pool cannot be driven negative', + ); assertEqualOrThrow( db.prepare('SELECT stock_quantity FROM addons WHERE id = ?').get('oat').stock_quantity, 5, @@ -239,10 +301,17 @@ function inventoryServiceAssertions(): void { } catch (error: any) { unknownAddon = error.message; } - assertEqualOrThrow(unknownAddon, 'Add-on not found', 'an unknown add-on pool is a 404, not a silent write'); + assertEqualOrThrow( + unknownAddon, + 'Add-on not found', + 'an unknown add-on pool is a 404, not a silent write', + ); const addonPage = listInventoryMovements(db, { addonId: 'oat' }); - assertOrThrow(addonPage.movements.length >= 2, 'listing movements with addonId filter returns add-on movements'); + assertOrThrow( + addonPage.movements.length >= 2, + 'listing movements with addonId filter returns add-on movements', + ); assertOrThrow( addonPage.movements.every((m: any) => m.addon_id === 'oat' && m.addon_name === 'oat'), 'movements filtered by addonId carry addon_id and addon_name', @@ -253,19 +322,46 @@ function ledgerValidatorAssertions(): void { const addons = [{ id: 'oat', stock_quantity: 5 }]; const movements = [ { - id: 1, product_id: null, variant_id: null, addon_id: 'oat', quantity_delta: 5, - movement_type: 'adjustment', reference_type: 'opening_balance', reference_id: 'oat', - reason: 'Opening count', actor_user_id: 'owner', stock_after: 5, created_at: '2026-01-01 00:00:00', + id: 1, + product_id: null, + variant_id: null, + addon_id: 'oat', + quantity_delta: 5, + movement_type: 'adjustment', + reference_type: 'opening_balance', + reference_id: 'oat', + reason: 'Opening count', + actor_user_id: 'owner', + stock_after: 5, + created_at: '2026-01-01 00:00:00', }, { - id: 2, product_id: null, variant_id: null, addon_id: 'oat', quantity_delta: -2, - movement_type: 'sale', reference_type: 'order_item', reference_id: 'oi-1', - reason: null, actor_user_id: 'owner', stock_after: 3, created_at: '2026-01-02 00:00:00', + id: 2, + product_id: null, + variant_id: null, + addon_id: 'oat', + quantity_delta: -2, + movement_type: 'sale', + reference_type: 'order_item', + reference_id: 'oi-1', + reason: null, + actor_user_id: 'owner', + stock_after: 3, + created_at: '2026-01-02 00:00:00', }, { - id: 3, product_id: null, variant_id: null, addon_id: 'oat', quantity_delta: 2, - movement_type: 'cancel_restore', reference_type: 'order_item', reference_id: 'oi-1', - reason: null, actor_user_id: 'owner', stock_after: 5, created_at: '2026-01-03 00:00:00', + id: 3, + product_id: null, + variant_id: null, + addon_id: 'oat', + quantity_delta: 2, + movement_type: 'cancel_restore', + reference_type: 'order_item', + reference_id: 'oi-1', + reason: null, + actor_user_id: 'owner', + stock_after: 5, + created_at: '2026-01-03 00:00:00', }, ]; @@ -332,13 +428,20 @@ function ledgerValidatorAssertions(): void { getProducts(), currentMovements, getProducts(), - currentMovements.map((row: any) => (row.addon_id === 'oat' ? { ...row, addon_id: null, product_id: 'latte' } : row)), + currentMovements.map((row: any) => + row.addon_id === 'oat' ? { ...row, addon_id: null, product_id: 'latte' } : row, + ), ), 'Inventory movement history cannot be erased by a replacement', 'a replacement cannot re-pool add-on history onto the product pool', ); assertEqualOrThrow( - validateInventoryLedgerReplacement(getProducts(), currentMovements, getProducts(), currentMovements), + validateInventoryLedgerReplacement( + getProducts(), + currentMovements, + getProducts(), + currentMovements, + ), null, 'replacing a store with identical add-on history is allowed', ); @@ -377,7 +480,8 @@ function replaySafetyAssertions(): void { 'replaying v104 does not duplicate ledger indexes', ); assertOrThrow( - JSON.stringify(db.prepare('SELECT id, stock_quantity FROM addons ORDER BY id').all()) === JSON.stringify(beforeAddons), + JSON.stringify(db.prepare('SELECT id, stock_quantity FROM addons ORDER BY id').all()) === + JSON.stringify(beforeAddons), 'replaying v104 keeps add-on stock untouched', ); assertEqualOrThrow( @@ -405,8 +509,12 @@ async function upgradeFromPreV104Assertions(): Promise { VALUES ('oat', 'milk', 'Oat milk', 40, 1, ?, ?)`).run(stamp, stamp); db.prepare(`INSERT INTO inventory_movements (product_id, quantity_delta, movement_type, reference_type, reference_id, reason, actor_user_id, stock_after, created_at) - VALUES ('latte', 12, 'adjustment', 'opening_balance', 'latte', 'Opening count', 'owner', 12, ?)`).run(stamp); - const legacyMovementId = (db.prepare('SELECT MAX(id) AS id FROM inventory_movements').get() as { id: number }).id; + VALUES ('latte', 12, 'adjustment', 'opening_balance', 'latte', 'Opening count', 'owner', 12, ?)`).run( + stamp, + ); + const legacyMovementId = ( + db.prepare('SELECT MAX(id) AS id FROM inventory_movements').get() as { id: number } + ).id; assertOrThrow( !columnsOf(db, 'addons').includes('stock_quantity'), @@ -451,8 +559,10 @@ async function upgradeFromPreV104Assertions(): Promise { 'the upgrade adds the add-on pool column to the ledger', ); assertOrThrow( - JSON.stringify(db.prepare('SELECT id, name, price, stock_quantity FROM products ORDER BY id').all()) - === JSON.stringify([ + JSON.stringify( + db.prepare('SELECT id, name, price, stock_quantity FROM products ORDER BY id').all(), + ) === + JSON.stringify([ { id: 'latte', name: 'Latte', price: 250, stock_quantity: 12 }, { id: 'tea', name: 'Tea', price: 100, stock_quantity: 0 }, ]), @@ -478,7 +588,8 @@ async function upgradeFromPreV104Assertions(): Promise { 'the upgraded store still validates against the ledger pre-check', ); - const nextMovementId = db.prepare(`INSERT INTO inventory_movements (product_id, variant_id, addon_id, + const nextMovementId = db + .prepare(`INSERT INTO inventory_movements (product_id, variant_id, addon_id, quantity_delta, movement_type, reference_type, reference_id, reason, actor_user_id, stock_after, created_at) VALUES (NULL, NULL, 'oat', 5, 'adjustment', 'opening_balance', 'oat', 'Opening count', 'owner', 5, ?)`) .run(stamp).lastInsertRowid; @@ -513,7 +624,9 @@ async function currencyResetAssertions(): Promise { initDatabase(); const db = getDatabase(); const stamp = now(); - const setting = db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)'); + const setting = db.prepare( + 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)', + ); setting.run('country', 'IN', stamp); setting.run('currency', 'INR', stamp); setting.run('timezone', 'Asia/Kolkata', stamp); @@ -564,7 +677,11 @@ function initAtVersion(version: number): any { MIGRATIONS.length = 0; MIGRATIONS.push(...FULL_REGISTRY.filter((entry: any) => entry.version <= version)); initDatabase(); - assertEqualOrThrow(getCurrentSchemaVersion(), version, `the upgrade fixture starts at schema v${version}`); + assertEqualOrThrow( + getCurrentSchemaVersion(), + version, + `the upgrade fixture starts at schema v${version}`, + ); return getDatabase(); } @@ -584,7 +701,9 @@ async function main() { console.log('─── Ledger validation ───'); ledgerValidatorAssertions(); - console.log(' ✓ the ledger accepts an add-on chain and refuses broken, colliding, and erased ones'); + console.log( + ' ✓ the ledger accepts an add-on chain and refuses broken, colliding, and erased ones', + ); console.log('─── Replay safety ───'); replaySafetyAssertions(); @@ -592,7 +711,9 @@ async function main() { console.log('─── Upgrade from a pre-v104 store ───'); await upgradeFromPreV104Assertions(); - console.log(' ✓ a populated pre-v104 store upgrades without losing data or reusing movement ids'); + console.log( + ' ✓ a populated pre-v104 store upgrades without losing data or reusing movement ids', + ); console.log('─── Currency reset ───'); await currencyResetAssertions(); @@ -609,6 +730,10 @@ main() process.exitCode = 1; }) .finally(() => { - try { closeDatabase(); } catch { /* already closed */ } + try { + closeDatabase(); + } catch { + /* already closed */ + } fs.rmSync(activeTestDir, { recursive: true, force: true }); - }); \ No newline at end of file + }); diff --git a/tests/anonymous-diagnostics-delivery.test.ts b/tests/anonymous-diagnostics-delivery.test.ts index a6ba4d6d6..d39450f2f 100644 --- a/tests/anonymous-diagnostics-delivery.test.ts +++ b/tests/anonymous-diagnostics-delivery.test.ts @@ -44,6 +44,7 @@ globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { const { sendAnonymousDiagnosticReport } = require('../main/services/telemetry'); async function main() { + process.env.FLO_TELEMETRY_DEV = '1'; const window = new Date(); window.setUTCMinutes(0, 0, 0); const utc = (date: Date) => date.toISOString().replace(/\.\d{3}Z$/, 'Z'); @@ -212,6 +213,7 @@ async function main() { 'invalid_ack', ); } finally { + delete process.env.FLO_TELEMETRY_DEV; globalThis.fetch = originalFetch; Module._load = originalLoad; } diff --git a/tests/e2e-server.cjs b/tests/e2e-server.cjs index 1f3e4d43f..98c93ec67 100644 --- a/tests/e2e-server.cjs +++ b/tests/e2e-server.cjs @@ -32,6 +32,7 @@ process.env.FLO_AUTH_RATE_LIMIT_MAX = '1000'; process.env.FLO_SETTINGS_READ_RATE_LIMIT_MAX = '100000'; process.env.FLO_SETTINGS_WRITE_RATE_LIMIT_MAX = '1000'; process.env.FLO_WHATSAPP_SOCKET_DISABLED = '1'; +process.env.FLO_MATRIX_OFFLINE = '1'; process.env.NODE_ENV = 'test'; const originalLoad = Module._load; diff --git a/tests/helpers/test-setup.ts b/tests/helpers/test-setup.ts index be32ab410..752818d70 100644 --- a/tests/helpers/test-setup.ts +++ b/tests/helpers/test-setup.ts @@ -34,6 +34,8 @@ * failures through getResults(). */ +process.env.NODE_ENV = 'test'; + const express = require('express'); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); diff --git a/tests/issue-263-phone-normalization.test.ts b/tests/issue-263-phone-normalization.test.ts index 82a1e318b..cecee0562 100644 --- a/tests/issue-263-phone-normalization.test.ts +++ b/tests/issue-263-phone-normalization.test.ts @@ -77,10 +77,26 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { }); test('normalizeOptionalPhone helper contract', () => { - assert.deepEqual(normalizeOptionalPhone(undefined, 'IN'), { valid: true, e164: null, countryCode: null }); - assert.deepEqual(normalizeOptionalPhone(null, 'IN'), { valid: true, e164: null, countryCode: null }); - assert.deepEqual(normalizeOptionalPhone('', 'IN'), { valid: true, e164: null, countryCode: null }); - assert.deepEqual(normalizeOptionalPhone(' ', 'IN'), { valid: true, e164: null, countryCode: null }); + assert.deepEqual(normalizeOptionalPhone(undefined, 'IN'), { + valid: true, + e164: null, + countryCode: null, + }); + assert.deepEqual(normalizeOptionalPhone(null, 'IN'), { + valid: true, + e164: null, + countryCode: null, + }); + assert.deepEqual(normalizeOptionalPhone('', 'IN'), { + valid: true, + e164: null, + countryCode: null, + }); + assert.deepEqual(normalizeOptionalPhone(' ', 'IN'), { + valid: true, + e164: null, + countryCode: null, + }); const inRes = normalizeOptionalPhone('9876543210', 'IN'); assert.equal(inRes.valid, true); @@ -109,21 +125,31 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { const db = getDatabase(); seedSetupProfile(db, 'demo', 'finedine', 'en', 'IN'); - const customers = db.prepare('SELECT id, name, phone, phone_digits, country_code FROM customers WHERE is_active = 1').all(); + const customers = db + .prepare( + 'SELECT id, name, phone, phone_digits, country_code FROM customers WHERE is_active = 1', + ) + .all(); assert.ok(customers.length >= 3, 'Demo customers should be seeded'); for (const c of customers) { assert.ok(c.phone.startsWith('+'), `Customer ${c.name} phone ${c.phone} must start with +`); - assert.equal(c.phone, '+' + c.phone_digits, `Customer ${c.name} phone ${c.phone} must match +phone_digits`); + assert.equal( + c.phone, + '+' + c.phone_digits, + `Customer ${c.name} phone ${c.phone} must match +phone_digits`, + ); } - const alertResult = db.prepare(` + const alertResult = db + .prepare(` SELECT COUNT(*) as count FROM customers WHERE is_active = 1 AND phone IS NOT NULL AND phone != '' AND phone != '+' || phone_digits - `).get(); + `) + .get(); assert.equal(alertResult.count, 0, 'Fresh demo install must have 0 invalid phone alerts'); }); @@ -132,11 +158,16 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { const db = getDatabase(); seedSetupProfile(db, 'demo', 'qsr', 'es', 'AR'); - const customers = db.prepare('SELECT id, name, phone, phone_digits FROM customers WHERE is_active = 1').all(); + const customers = db + .prepare('SELECT id, name, phone, phone_digits FROM customers WHERE is_active = 1') + .all(); assert.ok(customers.length >= 3); for (const c of customers) { - assert.ok(c.phone.startsWith('+54'), `Argentine customer ${c.name} phone ${c.phone} must start with +54`); + assert.ok( + c.phone.startsWith('+54'), + `Argentine customer ${c.name} phone ${c.phone} must start with +54`, + ); assert.equal(c.phone, '+' + c.phone_digits); } }); @@ -145,11 +176,22 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { const db = getDatabase(); seedSetupProfile(db, 'demo', 'finedine', 'fr', 'FR'); - assert.equal(db.prepare("SELECT name FROM categories WHERE id = 'cat-demo-starters'").get().name, 'Entrées'); - assert.equal(db.prepare("SELECT name FROM products WHERE id = 'prod-demo-burger'").get().name, 'Burger Classique'); - assert.equal(db.prepare("SELECT name FROM users WHERE id = 'user-demo-manager'").get().name, 'Gérant Démo'); - - const customer = db.prepare("SELECT phone, country_code FROM customers WHERE id = 'cust-demo-1'").get(); + assert.equal( + db.prepare("SELECT name FROM categories WHERE id = 'cat-demo-starters'").get().name, + 'Entrées', + ); + assert.equal( + db.prepare("SELECT name FROM products WHERE id = 'prod-demo-burger'").get().name, + 'Burger Classique', + ); + assert.equal( + db.prepare("SELECT name FROM users WHERE id = 'user-demo-manager'").get().name, + 'Gérant Démo', + ); + + const customer = db + .prepare("SELECT phone, country_code FROM customers WHERE id = 'cust-demo-1'") + .get(); assert.equal(customer.phone, '+33145678901'); assert.equal(customer.country_code, '+33'); }); @@ -158,11 +200,22 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { const db = getDatabase(); seedSetupProfile(db, 'demo', 'finedine', 'de', 'DE'); - assert.equal(db.prepare("SELECT name FROM categories WHERE id = 'cat-demo-beverages'").get().name, 'Getränke'); - assert.equal(db.prepare("SELECT name FROM products WHERE id = 'prod-demo-burger'").get().name, 'Klassischer Burger'); - assert.equal(db.prepare("SELECT name FROM users WHERE id = 'user-demo-manager'").get().name, 'Demo-Manager'); - - const customer = db.prepare("SELECT phone, country_code FROM customers WHERE id = 'cust-demo-1'").get(); + assert.equal( + db.prepare("SELECT name FROM categories WHERE id = 'cat-demo-beverages'").get().name, + 'Getränke', + ); + assert.equal( + db.prepare("SELECT name FROM products WHERE id = 'prod-demo-burger'").get().name, + 'Klassischer Burger', + ); + assert.equal( + db.prepare("SELECT name FROM users WHERE id = 'user-demo-manager'").get().name, + 'Demo-Manager', + ); + + const customer = db + .prepare("SELECT phone, country_code FROM customers WHERE id = 'cust-demo-1'") + .get(); assert.equal(customer.phone, '+4915123456789'); assert.equal(customer.country_code, '+49'); }); @@ -212,13 +265,21 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { }); assert.equal(res.status, 200); - const storedHandle = getDatabase().prepare("SELECT value FROM settings WHERE key = 'instagram_handle'").get(); - assert.equal(storedHandle.value, '@'.repeat(100), 'business settings truncate Instagram handles to 100 characters'); + const storedHandle = getDatabase() + .prepare("SELECT value FROM settings WHERE key = 'instagram_handle'") + .get(); + assert.equal( + storedHandle.value, + '@'.repeat(100), + 'business settings truncate Instagram handles to 100 characters', + ); }); test('PUT /api/settings/:key validates and normalizes business_phone', async () => { const db = getDatabase(); - db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', datetime('now'))").run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', datetime('now'))", + ).run(); const resValid = await api(baseUrl, '/api/settings/business_phone', { method: 'PUT', @@ -295,7 +356,9 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { test('POST /api/customers/admin/repair-phones repairs legacy phone records', async () => { const db = getDatabase(); - db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', datetime('now'))").run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', datetime('now'))", + ).run(); db.prepare(` INSERT INTO customers (id, name, phone, country_code, is_active, created_at, updated_at) VALUES ('cust-legacy-1', 'Legacy User', '9876543210', '+91', 1, datetime('now'), datetime('now')) @@ -403,11 +466,21 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { assert.equal(TELEMETRY_URL, 'https://telemetry.flopos.com/collect'); const db = getDatabase(); - db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('telemetry_enabled', 'true', datetime('now'))").run(); - db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', datetime('now'))").run(); - db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country_confirmed_at', datetime('now'), datetime('now'))").run(); - db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_phone', '+919876543210', datetime('now'))").run(); - db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('phone', '+919876543210', datetime('now'))").run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('telemetry_enabled', 'true', datetime('now'))", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country', 'IN', datetime('now'))", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('country_confirmed_at', datetime('now'), datetime('now'))", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('business_phone', '+919876543210', datetime('now'))", + ).run(); + db.prepare( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('phone', '+919876543210', datetime('now'))", + ).run(); const originalFetch = globalThis.fetch; let sentUrl = ''; @@ -419,6 +492,7 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { }; try { + process.env.FLO_TELEMETRY_DEV = '1'; const delivered = await sendEvent('app_launch'); assert.equal(delivered, true); assert.equal(sentUrl, TELEMETRY_URL); @@ -436,6 +510,7 @@ describe('Issue #263: Phone Normalization, Validation, and Privacy', () => { assert.equal('business_phone' in payload, false); assert.equal('contact_phone' in payload, false); } finally { + delete process.env.FLO_TELEMETRY_DEV; globalThis.fetch = originalFetch; } }); diff --git a/tests/issue-788-order-attempt-storage.test.ts b/tests/issue-788-order-attempt-storage.test.ts index 4e4c6fda0..698c3c93a 100644 --- a/tests/issue-788-order-attempt-storage.test.ts +++ b/tests/issue-788-order-attempt-storage.test.ts @@ -28,6 +28,7 @@ const { getPrepaidOrderAttemptStorageKey, readOrderAttempt, persistOrderAttempt, + sanitizeServerError, } = require('../frontend/src/lib/order-attempt'); class MemoryStorage { @@ -372,27 +373,34 @@ function main() { assert.equal(stockRejected.reasonCode, 'order.stock_insufficient'); assert.equal(quantityRejected.outcome, 'not_applied'); assert.equal( - quantityRejected.detail.includes('secret'), - false, - 'trusted structured detail never includes server text', + quantityRejected.detail, + 'Invalid quantity for secret product', + 'trusted structured detail preserves server rejection error message', + ); + assert.equal( + stockRejected.detail, + 'Insufficient stock for secret product', + 'trusted structured detail preserves stock error message', ); assert.notEqual( quantityRejected.reasonCode, stockRejected.reasonCode, 'different validation reasons stay distinct', ); + const mismatchedOp = classifyOrder({ + error: 'secret', + reason_code: 'order.stock_insufficient', + operation: 'payment.create', + stage: 'validation', + outcome: 'not_applied', + classification: 'expected_rejection', + correlation_id: operationReference, + }); + assert.equal(mismatchedOp.reasonCode, null, 'a reason for another operation is not trusted'); assert.equal( - classifyOrder({ - error: 'secret', - reason_code: 'order.stock_insufficient', - operation: 'payment.create', - stage: 'validation', - outcome: 'not_applied', - classification: 'expected_rejection', - correlation_id: operationReference, - }).reasonCode, - null, - 'a reason for another operation is not trusted', + mismatchedOp.detail, + 'The local server response did not confirm the result (HTTP 400)', + 'untrusted failure retains safe fallback detail without server text', ); assert.equal( classifyOrder({ @@ -783,6 +791,39 @@ function main() { ); assert.notEqual(appendStorage.getItem(getAppendAttemptStorageKey(USER_ID)), null); + // 11. Server error messages are sanitized and bounded to protect against + // internal exceptions, SQL disclosure, or unbounded storage. + assert.equal(sanitizeServerError('Item "Latte" is out of stock'), 'Item "Latte" is out of stock'); + assert.equal(sanitizeServerError('SQLITE_ERROR: no such table: orders'), null); + assert.equal(sanitizeServerError('Error: crash\n at query (db.ts:1)'), null); + assert.equal(sanitizeServerError('TypeError: cannot read properties of undefined'), null); + assert.equal(sanitizeServerError('SELECT * FROM orders WHERE id = 1'), null); + assert.equal(sanitizeServerError('a'.repeat(300))?.length, 255); + + const sqlLeakageAttempt = classifyOrderRequestFailure( + { + response: { + status: 400, + data: { + reason_code: 'validation.rejected', + operation: 'order.create', + stage: 'validation', + outcome: 'not_applied', + classification: 'expected_rejection', + correlation_id: operationReference, + error: 'SQLITE_ERROR: syntax error in INSERT INTO orders', + }, + }, + }, + { operation: 'order.create', correlationId: operationReference }, + ); + assert.equal(sqlLeakageAttempt.serverError, null, 'SQL leakage is discarded from serverError'); + assert.equal( + sqlLeakageAttempt.detail, + 'The local server response did not confirm the result (HTTP 400)', + 'SQL leakage falls back to safe generic detail', + ); + console.log('Issue #788 order-attempt storage tests passed'); } diff --git a/tests/telemetry-delivery.test.ts b/tests/telemetry-delivery.test.ts index 0bfb12105..f8b44ad16 100644 --- a/tests/telemetry-delivery.test.ts +++ b/tests/telemetry-delivery.test.ts @@ -24,6 +24,7 @@ const { initDatabase, getDatabase, closeDatabase, now } = require('../main/db'); const { telemetry, sendEvent, TELEMETRY_URL } = require('../main/services/telemetry'); async function main() { + process.env.FLO_TELEMETRY_DEV = '1'; initDatabase(); const db = getDatabase(); const set = db.prepare(` @@ -50,7 +51,11 @@ async function main() { assert.equal(await sendEvent('app_launch'), true, '2xx telemetry delivery succeeds'); assert.equal(requestBody?.country, 'AR', 'telemetry sends the configured ISO country'); - assert.equal(requestBody?.currency, 'USD', 'telemetry sends the normalized configured currency'); + assert.equal( + requestBody?.currency, + 'USD', + 'telemetry sends the normalized configured currency', + ); assert.equal(requestBody?.app, 'flocafe'); assert.equal(requestBody?.app_version, '2.7.2-test'); @@ -63,7 +68,7 @@ async function main() { assert.equal( Object.prototype.hasOwnProperty.call(requestBody ?? {}, 'country'), false, - 'an unconfirmed country is omitted so the server can geolocate instead' + 'an unconfirmed country is omitted so the server can geolocate instead', ); set.run('country_confirmed_at', new Date().toISOString(), now()); @@ -87,32 +92,59 @@ async function main() { set.run('currency', 'USD', now()); let bodyCancelled = false; - globalThis.fetch = (async () => ({ - ok: true, - status: 204, - body: { cancel: async () => { bodyCancelled = true; } }, - }) as unknown as Response) as typeof fetch; - assert.equal(await sendEvent('app_launch'), true, 'telemetry delivery succeeds with a response body'); + globalThis.fetch = (async () => + ({ + ok: true, + status: 204, + body: { + cancel: async () => { + bodyCancelled = true; + }, + }, + }) as unknown as Response) as typeof fetch; + assert.equal( + await sendEvent('app_launch'), + true, + 'telemetry delivery succeeds with a response body', + ); assert.equal(bodyCancelled, true, 'telemetry cancels the response body before settling'); let non2xxBodyCancelled = false; - globalThis.fetch = (async () => ({ - ok: false, - status: 503, - body: { cancel: async () => { non2xxBodyCancelled = true; } }, - }) as unknown as Response) as typeof fetch; - assert.equal(await sendEvent('daily_ping'), false, 'non-2xx telemetry is reported as undelivered'); - assert.equal(non2xxBodyCancelled, true, 'telemetry cancels the response body on non-2xx responses'); + globalThis.fetch = (async () => + ({ + ok: false, + status: 503, + body: { + cancel: async () => { + non2xxBodyCancelled = true; + }, + }, + }) as unknown as Response) as typeof fetch; + assert.equal( + await sendEvent('daily_ping'), + false, + 'non-2xx telemetry is reported as undelivered', + ); + assert.equal( + non2xxBodyCancelled, + true, + 'telemetry cancels the response body on non-2xx responses', + ); // Verify telemetry.stop() drains and cancels non-2xx in-flight work before settling let stopBodyCancelled = false; let finishFetch: () => void = () => {}; const fetchDeferred = new Promise((resolve) => { - finishFetch = () => resolve({ - ok: false, - status: 500, - body: { cancel: async () => { stopBodyCancelled = true; } }, - } as unknown as Response); + finishFetch = () => + resolve({ + ok: false, + status: 500, + body: { + cancel: async () => { + stopBodyCancelled = true; + }, + }, + } as unknown as Response); }); globalThis.fetch = (() => fetchDeferred) as unknown as typeof fetch; @@ -121,7 +153,11 @@ async function main() { finishFetch(); assert.equal(await inFlightOp, false); await stopPromise; - assert.equal(stopBodyCancelled, true, 'telemetry.stop() drains in-flight non-2xx requests and cancels response body'); + assert.equal( + stopBodyCancelled, + true, + 'telemetry.stop() drains in-flight non-2xx requests and cancels response body', + ); set.run('telemetry_enabled', 'false', now()); let calledWhileDisabled = false; @@ -147,6 +183,7 @@ async function main() { console.log('✅ Telemetry delivery contract checks passed'); } finally { + delete process.env.FLO_TELEMETRY_DEV; globalThis.fetch = originalFetch; closeDatabase(); fs.rmSync(testDir, { recursive: true, force: true }); diff --git a/tests/telemetry-install-provenance.test.ts b/tests/telemetry-install-provenance.test.ts index 9e92ed39f..c7b7dd2ec 100644 --- a/tests/telemetry-install-provenance.test.ts +++ b/tests/telemetry-install-provenance.test.ts @@ -284,6 +284,7 @@ async function main() { const originalFetch = globalThis.fetch; let body: Record | null = null; try { + process.env.FLO_TELEMETRY_DEV = '1'; globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { body = JSON.parse(String(init?.body || '{}')); return new Response(null, { status: 204 }); @@ -308,6 +309,7 @@ async function main() { 'a dev run reports dev/dev over the wire', ); } finally { + delete process.env.FLO_TELEMETRY_DEV; globalThis.fetch = originalFetch; } diff --git a/tests/telemetry-leakage-prevention.test.ts b/tests/telemetry-leakage-prevention.test.ts new file mode 100644 index 000000000..e598e18f0 --- /dev/null +++ b/tests/telemetry-leakage-prevention.test.ts @@ -0,0 +1,282 @@ +/** + * Telemetry leakage prevention tests. + * + * Proves that: + * 1. Automated tests, CI runs, and local dev runs cannot transmit anonymous + * diagnostics, telemetry pings, or PostHog events to production by default. + * 2. Packaged official releases, beta builds, and custom builds preserve delivery + * support when user consent and transmission settings allow it. + * 3. An explicit developer flag (FLO_TELEMETRY_DEV=1) permits local testing without + * enabling default leakage. + * + * Usage: node tests/run-electron-node-test.cjs tests/telemetry-leakage-prevention.test.ts + */ + +const Module = require('module'); +const originalLoad = Module._load; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-telemetry-leak-')); + +let mockPackaged = true; +let mockVersion = '3.13.0'; + +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { + app: { + isPackaged: mockPackaged, + getPath: () => testDir, + getVersion: () => mockVersion, + }, + }; + } + return originalLoad.apply(this, arguments as any); +}; + +const { + isTelemetrySuppressed, + sendAnonymousDiagnosticReport, + TELEMETRY_URL, +} = require('../main/services/telemetry'); +const { postHogEnvironment } = require('../main/services/posthog-config'); +const { assertOrThrow, assertEqualOrThrow } = require('./helpers/test-setup'); + +async function run() { + console.log('Testing telemetry leakage prevention gates...'); + + // Save current environment variables + const envSnapshot = { ...process.env }; + + try { + // 1. In a standard test environment, test-setup initializes FLO_MATRIX_OFFLINE=1. + process.env.NODE_ENV = 'test'; + process.env.FLO_MATRIX_OFFLINE = '1'; + delete process.env.FLO_TELEMETRY_DEV; + mockVersion = '3.13.0'; + mockPackaged = true; + + assertOrThrow(isTelemetrySuppressed(), 'Standard test setup suppresses telemetry transmission'); + assertEqualOrThrow( + postHogEnvironment(), + null, + 'Standard test setup suppresses PostHog connection', + ); + + // 2. Offline matrix environment suppresses transmission regardless of NODE_ENV. + process.env.NODE_ENV = 'production'; + process.env.FLO_MATRIX_OFFLINE = '1'; + assertOrThrow(isTelemetrySuppressed(), 'FLO_MATRIX_OFFLINE=1 suppresses telemetry'); + assertEqualOrThrow(postHogEnvironment(), null, 'FLO_MATRIX_OFFLINE=1 suppresses PostHog'); + + // 3. Test runtime versions suppress transmission even when packaged. + delete process.env.FLO_MATRIX_OFFLINE; + mockVersion = 'test'; + assertOrThrow(isTelemetrySuppressed(), 'Version "test" suppresses telemetry'); + assertEqualOrThrow(postHogEnvironment(), null, 'Version "test" suppresses PostHog'); + + // 4. E2E ports environment suppresses transmission. + delete process.env.CI; + process.env.E2E_TASK_LOCAL_PORTS = '1'; + assertOrThrow(isTelemetrySuppressed(), 'E2E_TASK_LOCAL_PORTS suppresses telemetry'); + assertEqualOrThrow(postHogEnvironment(), null, 'E2E_TASK_LOCAL_PORTS suppresses PostHog'); + + // 5. Test version strings suppress transmission even in production mode. + delete process.env.E2E_TASK_LOCAL_PORTS; + for (const testVer of ['test', 'e2e', '1.0.0-test', '3.13.0-test.1']) { + mockVersion = testVer; + assertOrThrow(isTelemetrySuppressed(), `Version "${testVer}" suppresses telemetry`); + } + + // 6. Development run (unpackaged / dev build) suppresses transmission by default. + mockVersion = '3.13.0'; + mockPackaged = false; + delete process.env.FLO_TELEMETRY_DEV; + assertOrThrow(isTelemetrySuppressed(), 'Unpackaged dev run suppresses telemetry by default'); + + // 7. Development run with FLO_TELEMETRY_DEV=1 allows telemetry. + process.env.FLO_TELEMETRY_DEV = '1'; + assertOrThrow(!isTelemetrySuppressed(), 'FLO_TELEMETRY_DEV=1 unsuppresses dev telemetry'); + + // 8. Packaged release with no test flags allows telemetry. + delete process.env.FLO_TELEMETRY_DEV; + mockPackaged = true; + mockVersion = '3.13.0'; + assertOrThrow(!isTelemetrySuppressed(), 'Clean packaged build allows permitted telemetry'); + + // 9. Beta releases allow telemetry when packaged. + mockVersion = '3.14.0-beta.1'; + assertOrThrow(!isTelemetrySuppressed(), 'Packaged beta release allows permitted telemetry'); + + // 10. Fixture version 2.7.2-test suppresses telemetry by default, but allows telemetry when FLO_TELEMETRY_DEV=1. + mockVersion = '2.7.2-test'; + delete process.env.FLO_TELEMETRY_DEV; + assertOrThrow(isTelemetrySuppressed(), 'Version "2.7.2-test" suppresses telemetry by default'); + process.env.FLO_TELEMETRY_DEV = '1'; + assertOrThrow( + !isTelemetrySuppressed(), + 'Version "2.7.2-test" is permitted when FLO_TELEMETRY_DEV=1', + ); + + // 11. Conflicting environment variables: offline/e2e switches take precedence over FLO_TELEMETRY_DEV=1. + process.env.FLO_MATRIX_OFFLINE = '1'; + assertOrThrow( + isTelemetrySuppressed(), + 'FLO_MATRIX_OFFLINE=1 takes precedence over FLO_TELEMETRY_DEV=1', + ); + delete process.env.FLO_MATRIX_OFFLINE; + + process.env.E2E_TASK_LOCAL_PORTS = '1'; + assertOrThrow( + isTelemetrySuppressed(), + 'E2E_TASK_LOCAL_PORTS takes precedence over FLO_TELEMETRY_DEV=1', + ); + delete process.env.E2E_TASK_LOCAL_PORTS; + + process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK = '1'; + assertOrThrow( + isTelemetrySuppressed(), + 'FLO_E2E_SKIP_OPTIONAL_NETWORK takes precedence over FLO_TELEMETRY_DEV=1', + ); + delete process.env.FLO_E2E_SKIP_OPTIONAL_NETWORK; + + // 12. CI environment suppresses telemetry by default. + process.env.CI = 'true'; + delete process.env.FLO_TELEMETRY_DEV; + assertOrThrow(isTelemetrySuppressed(), 'CI=true suppresses telemetry'); + assertEqualOrThrow(postHogEnvironment(), null, 'CI=true suppresses PostHog'); + delete process.env.CI; + + // 13. NODE_ENV=test suppresses telemetry in packaged mode when FLO_MATRIX_OFFLINE is unset. + process.env.NODE_ENV = 'test'; + delete process.env.FLO_MATRIX_OFFLINE; + mockPackaged = true; + mockVersion = '3.13.0'; + assertOrThrow(isTelemetrySuppressed(), 'NODE_ENV=test suppresses telemetry in packaged mode'); + assertEqualOrThrow( + postHogEnvironment(), + null, + 'NODE_ENV=test suppresses PostHog in packaged mode', + ); + + // 14. Server error sanitization bounds and safety checks. + const { sanitizeServerError } = require('../frontend/src/lib/order-attempt'); + assertEqualOrThrow( + sanitizeServerError('Insufficient stock for "Double Cheeseburger"'), + 'Insufficient stock for "Double Cheeseburger"', + 'valid stock error is preserved', + ); + assertEqualOrThrow( + sanitizeServerError('Add-on "Extra Bacon" is out of stock (requested 1, available 0)'), + 'Add-on "Extra Bacon" is out of stock (requested 1, available 0)', + 'valid add-on error is preserved', + ); + assertEqualOrThrow( + sanitizeServerError('Error: SQLITE_BUSY: database is locked'), + null, + 'SQLite database locked error is sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('SELECT * FROM users WHERE id = 1'), + null, + 'SQL query statements are sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('at Object. (/app/routes/orders.ts:42:15)'), + null, + 'stack trace frame is sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('Failed to open file /etc/shadow'), + null, + 'UNIX file paths are sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('Failed to load C:\\Users\\Admin\\keys.db'), + null, + 'Windows file paths are sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('Could not read ../secrets/.env'), + null, + 'relative file paths are sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('ENOENT: no such file or directory'), + null, + 'system error codes are sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('Invalid bearer token xyz123'), + null, + 'bearer tokens and secrets are sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('Incorrect password supplied for user'), + null, + 'password mentions are sanitized', + ); + assertEqualOrThrow( + sanitizeServerError('A'.repeat(300))?.length, + 255, + 'oversized errors are bounded to 255 chars', + ); + assertEqualOrThrow( + sanitizeServerError('Line one\nLine two'), + null, + 'multiline error is sanitized', + ); + + // 15. Proving fetch is never called when telemetry is suppressed. + process.env.NODE_ENV = 'test'; + process.env.FLO_MATRIX_OFFLINE = '1'; + let fetchCalled = false; + const originalFetch = global.fetch; + (global as any).fetch = async (url: string) => { + fetchCalled = true; + throw new Error(`Unexpected fetch to ${url}`); + }; + + try { + const sampleReport = { + schema_version: 2, + report_id: 'e9825623-22d4-4221-a75f-5dcf578b42d1', + reason_code: 'order.quantity_invalid', + classification: 'expected_rejection', + operation: 'order.create', + stage: 'validation', + outcome: 'not_applied', + capture_point: 'fc.backend.order.create.quantity_validation', + count: 1, + window_start: '2026-10-18T04:00:00Z', + first_occurred_at: '2026-10-18T04:55:06Z', + last_occurred_at: '2026-10-18T04:55:06Z', + app_version: '3.13.0', + app_build: 'unknown', + platform: 'darwin', + facts: {}, + }; + + const result = await sendAnonymousDiagnosticReport(sampleReport); + assertEqualOrThrow( + result.disposition, + 'network_error', + 'suppressed send returns network_error', + ); + assertEqualOrThrow(fetchCalled, false, 'fetch was never invoked while suppressed'); + } finally { + global.fetch = originalFetch; + } + + console.log('✓ All telemetry leakage prevention checks passed!'); + } finally { + process.env = envSnapshot; + fs.rmSync(testDir, { recursive: true, force: true }); + } +} + +void run().catch((err) => { + console.error('Telemetry leakage test failed:', err); + process.exit(1); +});