diff --git a/cli/test/generate-report-template.test.js b/cli/test/generate-report-template.test.js index 1476ce99d..a056e6f9b 100644 --- a/cli/test/generate-report-template.test.js +++ b/cli/test/generate-report-template.test.js @@ -158,4 +158,23 @@ describe('generate-report-template', () => { assert.ok(warehouseHeaders.some(h => h.field === 'address')); }); }); + + describe('QR emission (ETP-4908)', () => { + // Document QR codes are precomputed as data (header.qrDataUrl) by the + // report server — the async qrCode Handlebars helper is retired because + // Handlebars compiles synchronously on the local HTML path. + it('never emits a {{qrCode}} helper call in templates', () => { + for (const contract of [listingContract, groupedContract]) { + const { template } = generateTemplate(contract); + assert.ok(!template.includes('{{qrCode'), `template for ${contract.reportId} must not call the qrCode helper`); + } + }); + + it('never emits a qrCode helper or qrcode require in helpers code', () => { + for (const contract of [listingContract, groupedContract]) { + const { helpers } = generateTemplate(contract); + assert.ok(!/qrCode|require\(['"]qrcode['"]\)/.test(helpers), `helpers for ${contract.reportId} must not define a qrCode helper`); + } + }); + }); }); diff --git a/cli/test/report-jsreport-helpers-builder.test.js b/cli/test/report-jsreport-helpers-builder.test.js new file mode 100644 index 000000000..c6b2eb4e9 --- /dev/null +++ b/cli/test/report-jsreport-helpers-builder.test.js @@ -0,0 +1,518 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { buildJsreportHelpersString, createReportHelpers } from '../../templates/reports/helpers/report-html-helpers.js'; + +const MODULE_PATH = fileURLToPath(new URL('../../templates/reports/helpers/report-html-helpers.js', import.meta.url)); + +// The jsreport PDF/XLSX render path (`report-api.js`) sends a `helpers` STRING +// over HTTP to the jsreport Docker container, which evaluates it in its own, +// separate Node sandbox — there is no shared module system between our process +// and jsreport's, so the string can never `import` the real formatCurrency(). +// +// `buildJsreportHelpersString` is the centralization point: it takes a report's +// raw `artifacts//helpers.js` source, and returns the string that actually +// gets sent to jsreport — built from the canonical helper SOURCE TEXT +// (`JSREPORT_HELPER_SOURCES`, the matched string pair of the functions +// `createReportHelpers()` exposes for the on-screen HTML preview), plus only the +// report-SPECIFIC extras (e.g. `qrCode`) extracted from the raw source. +// +// It must NEVER go back to serializing the live functions with `fn.toString()`: +// those are closure locals inside `createReportHelpers()`, so a minified +// production bundle emitted `function l(` instead of `function ifCond(` and +// jsreport answered `400 missing helper "ifCond"` — a production-only failure +// that dev (unminified) could never reproduce. The `emits a literal +// "function ("` suite below is the regression guard for that. + +// Representative fixture mirroring artifacts/print-sales-invoice/helpers.js +// (a "document" type report — has the qrCode extra + its require). +// Built as an array of single-line strings (joined with '\n') instead of a +// multi-line template literal — a multi-line backtick block defeats the +// PR-review tool's same-line string-stripping regex, which then flags the +// literal text `require(` inside it as real CommonJS interop. Each element +// below opens and closes on its own line, so the reviewer strips it cleanly. +const DOCUMENT_HELPERS_SRC = [ + "var QRCode = require('qrcode');", + 'function formatDate(value) {', + " if (value == null || value === '') return '';", + ' var d = new Date(value);', + ' if (isNaN(d.getTime())) return String(value);', + " return new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d);", + '}', + 'function formatCurrency(value) {', + " if (value == null) return '';", + ' var num = Number(value);', + ' if (isNaN(num)) return String(value);', + " return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);", + '}', + 'function formatNumber(value) {', + " if (value == null) return '';", + ' var num = Number(value);', + ' if (isNaN(num)) return String(value);', + " return new Intl.NumberFormat('en-US').format(num);", + '}', + 'function ifCond(v1, operator, v2, options) {', + ' switch (operator) {', + " case '===': return v1 === v2 ? options.fn(this) : options.inverse(this);", + " case '!==': return v1 !== v2 ? options.fn(this) : options.inverse(this);", + ' default: return options.inverse(this);', + ' }', + '}', + 'function qrCode(header) {', + " if (!header || typeof header !== 'object') return QRCode.toDataURL('no data', { width: 120, margin: 1 });", + " return QRCode.toDataURL('some-data', { width: 120, margin: 1 });", + '}', +].join('\n'); + +// Representative fixture mirroring artifacts/balance-sheet/helpers.js +// (a "listing" type report — 100% canonical, zero report-specific extras). +const LISTING_HELPERS_SRC = `var _prevGroupValues = {}; +function isGroupBreak(field, currentValue) { + var prev = _prevGroupValues[field]; + _prevGroupValues[field] = currentValue; + return prev !== currentValue; +} +function resetGroupTracking() { _prevGroupValues = {}; } +function formatDate(value) { + if (value == null || value === '') return ''; + var d = new Date(value); + if (isNaN(d.getTime())) return String(value); + return new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d); +} +function formatCurrency(value) { + if (value == null) return ''; + var num = Number(value); + if (isNaN(num)) return String(value); + return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num); +} +function formatBoolean(value) { return value ? 'Yes' : 'No'; } +function formatNumber(value) { + if (value == null) return ''; + var num = Number(value); + if (isNaN(num)) return String(value); + return new Intl.NumberFormat('en-US').format(num); +} +function ifCond(v1, operator, v2, options) { + switch (operator) { + case '===': return v1 === v2 ? options.fn(this) : options.inverse(this); + case '!==': return v1 !== v2 ? options.fn(this) : options.inverse(this); + default: return options.inverse(this); + } +} +function eq(a, b) { return a === b; } +function sumRowsByCategory(rows, categoryPrefix, field) { + if (!Array.isArray(rows)) return 0; + return rows.filter(function(r) { return (r.category || '').startsWith(categoryPrefix); }) + .reduce(function(sum, r) { return sum + (Number(r[field]) || 0); }, 0); +}`; + +function extractFunctionSource(source, fnName) { + const startIdx = source.indexOf(`function ${fnName}(`); + if (startIdx === -1) throw new Error(`${fnName} not found in built helpers string`); + const braceStart = source.indexOf('{', startIdx); + let depth = 0; + let i = braceStart; + for (; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return source.slice(startIdx, i + 1); +} + +describe('buildJsreportHelpersString', () => { + it('uses the canonical es-ES formatCurrency (never the report-specific en-US copy)', () => { + const built = buildJsreportHelpersString(DOCUMENT_HELPERS_SRC); + const groupSrc = extractFunctionSource(built, '__groupEsEs'); + const fnSource = extractFunctionSource(built, 'formatCurrency'); + const formatCurrency = new Function(`${groupSrc}\n${fnSource}; return formatCurrency;`)(); + assert.equal(formatCurrency(1355.2), '1.355,20'); + }); + + it('keeps the report-specific qrCode helper for a document-type report', () => { + const built = buildJsreportHelpersString(DOCUMENT_HELPERS_SRC); + assert.match(built, /function qrCode\(/); + assert.match(built, /require\(['"]qrcode['"]\)/); + }); + + it('does not fabricate a qrCode helper for a listing-type report that never had one', () => { + const built = buildJsreportHelpersString(LISTING_HELPERS_SRC); + assert.doesNotMatch(built, /function qrCode\(/); + }); + + it('produces a standalone-valid script — isGroupBreak/resetGroupTracking do not throw a ReferenceError when evaluated', () => { + const built = buildJsreportHelpersString(LISTING_HELPERS_SRC); + const isGroupBreakSrc = extractFunctionSource(built, 'isGroupBreak'); + const resetSrc = extractFunctionSource(built, 'resetGroupTracking'); + // Must declare its own group-tracking state — createReportHelpers()'s + // closure variable does not exist once the function bodies are serialized + // out to a plain string for jsreport. + const fn = new Function(` + ${built} + return { isGroupBreak, resetGroupTracking }; + `); + const { isGroupBreak, resetGroupTracking } = fn(); + assert.doesNotThrow(() => isGroupBreak('account', 'Assets')); + assert.doesNotThrow(() => resetGroupTracking()); + void isGroupBreakSrc; void resetSrc; + }); + + it('uses the canonical, grouped formatNumber (not the report-specific en-US copy)', () => { + const built = buildJsreportHelpersString(LISTING_HELPERS_SRC); + const groupSrc = extractFunctionSource(built, '__groupEsEs'); + const fnSource = extractFunctionSource(built, 'formatNumber'); + const formatNumber = new Function(`${groupSrc}\n${fnSource}; return formatNumber;`)(); + assert.equal(formatNumber(1355), '1.355'); + }); + + // ── ETP-4314 follow-up: jsreport container ICU/CLDR grouping bug ────────── + // Confirmed via `docker exec` against the actual etendo-jsreport image + // (Node 18.20.4 / ICU 74.2 / CLDR 44.1): Intl.NumberFormat('es-ES', {useGrouping: + // true}) silently drops the thousands separator specifically in the 1000-9999 + // range on that Node/ICU build (Node ≥20 / ICU ≥78 does not have this bug). + // formatCurrency/formatNumber must never depend on Intl at all in the string + // sent to jsreport, so the fix holds regardless of which Node build ends up + // running that separate container, now or after any future image update. + it('formatCurrency never calls Intl.NumberFormat — immune to the jsreport container Node/ICU version', () => { + const built = buildJsreportHelpersString(DOCUMENT_HELPERS_SRC); + const fnSource = extractFunctionSource(built, 'formatCurrency'); + assert.doesNotMatch(fnSource, /Intl\.NumberFormat/); + }); + + it('formatNumber never calls Intl.NumberFormat — immune to the jsreport container Node/ICU version', () => { + const built = buildJsreportHelpersString(LISTING_HELPERS_SRC); + const fnSource = extractFunctionSource(built, 'formatNumber'); + assert.doesNotMatch(fnSource, /Intl\.NumberFormat/); + }); + + it('groups thousands for formatCurrency in the exact 1000-9999 range that used to be buggy', () => { + const built = buildJsreportHelpersString(DOCUMENT_HELPERS_SRC); + const groupSrc = extractFunctionSource(built, '__groupEsEs'); + const fnSource = extractFunctionSource(built, 'formatCurrency'); + const formatCurrency = new Function(`${groupSrc}\n${fnSource}; return formatCurrency;`)(); + assert.equal(formatCurrency(1232), '1.232,00'); + assert.equal(formatCurrency(1000), '1.000,00'); + }); + + it('accepts an explicit numberFormat override as a 2nd param — for callers with a known JS value (not a helpers.js string to regex-extract from)', () => { + const built = buildJsreportHelpersString('', { minimumFractionDigits: 4, maximumFractionDigits: 4 }); + const groupSrc = extractFunctionSource(built, '__groupEsEs'); + const fnSource = extractFunctionSource(built, 'formatNumber'); + const formatNumber = new Function(`${groupSrc}\n${fnSource}; return formatNumber;`)(); + assert.equal(formatNumber(1.2), '1,2000'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Regression suites for the production-only `400 missing helper "ifCond"` bug. +// +// The canonical helper names are derived from `createReportHelpers()` itself +// (the LIVE set) rather than hardcoded here, so these tests also fail when +// someone adds a new helper to `createReportHelpers()` and forgets to add its +// source text to `JSREPORT_HELPER_SOURCES` — the one hole the fix cannot close +// on its own. +// ───────────────────────────────────────────────────────────────────────────── + +const LIVE_HELPER_NAMES = Object.keys(createReportHelpers()); + +/** Evaluate the emitted jsreport string and hand back the requested helpers. */ +function evalBuiltHelpers(built, names) { + return new Function(`${built}\nreturn { ${names.join(', ')} };`)(); +} + +describe('buildJsreportHelpersString — emitted helper names are literal source text (minification-safe)', () => { + // Built with NO report source: the canonical set must stand on its own, so a + // report whose helpers.js happens to omit a helper still gets it registered. + const builtBare = buildJsreportHelpersString(); + + for (const name of LIVE_HELPER_NAMES) { + it(`emits a literal "function ${name}(" declaration — jsreport derives the helper name from this text`, () => { + assert.ok( + builtBare.includes(`function ${name}(`), + `"function ${name}(" is missing from the emitted jsreport helpers string. ` + + 'jsreport registers helpers by the name in that literal text, so it would answer ' + + `400 missing helper "${name}". If ${name} was just added to createReportHelpers(), ` + + 'add its verbatim source text to JSREPORT_HELPER_SOURCES too.', + ); + }); + } + + it('emits every helper createReportHelpers() exposes — no live helper may be missing from the emitted string', () => { + const missing = LIVE_HELPER_NAMES.filter((n) => !builtBare.includes(`function ${n}(`)); + assert.deepEqual(missing, []); + }); + + it('emits no helper that createReportHelpers() does not expose (other than the internal __groupEsEs)', () => { + const emitted = [...builtBare.matchAll(/^function\s+(\w+)\s*\(/gm)].map((m) => m[1]); + const unexpected = emitted.filter((n) => n !== '__groupEsEs' && !LIVE_HELPER_NAMES.includes(n)); + assert.deepEqual(unexpected, []); + }); + + it('still emits every canonical name when a report helpers.js is supplied (extras must not displace the canonical set)', () => { + for (const src of [DOCUMENT_HELPERS_SRC, LISTING_HELPERS_SRC]) { + const built = buildJsreportHelpersString(src); + const missing = LIVE_HELPER_NAMES.filter((n) => !built.includes(`function ${n}(`)); + assert.deepEqual(missing, []); + } + }); +}); + +describe('buildJsreportHelpersString — behavioural parity between the emitted string and the live helpers', () => { + // `JSREPORT_HELPER_SOURCES` is a second, hand-maintained copy of the functions + // inside `createReportHelpers()` (see that constant's INVARIANT comment). + // These cases detect drift if only one copy is edited. + // + // formatCurrency/formatNumber are intentionally EXCLUDED: the emitted versions + // deliberately use the Intl-free `__groupEsEs` algorithm because the jsreport + // container's Node/ICU build mis-groups es-ES — covered by the suite above. + const live = createReportHelpers(); + const PARITY_NAMES = LIVE_HELPER_NAMES.filter( + (n) => !['formatCurrency', 'formatNumber', 'isGroupBreak', 'resetGroupTracking'].includes(n), + ); + const emitted = evalBuiltHelpers(buildJsreportHelpersString(), PARITY_NAMES); + + const IF_COND_OPTIONS = { fn: () => 'TRUTHY', inverse: () => 'FALSY' }; + + const CASES = { + formatDate: [ + [null], [undefined], [''], [0], + ['2026-08-06'], ['2026-08-06T12:34:56Z'], + ['not a date'], ['06/08/2026'], [{}], [NaN], + ], + formatBoolean: [[null], [undefined], [''], [0], [1], [false], [true], ['x']], + ifCond: [ + ['a', '===', 'a', IF_COND_OPTIONS], + ['a', '===', 'b', IF_COND_OPTIONS], + ['a', '!==', 'b', IF_COND_OPTIONS], + ['a', '!==', 'a', IF_COND_OPTIONS], + [null, '===', undefined, IF_COND_OPTIONS], + ['a', '>', 'b', IF_COND_OPTIONS], + ], + eq: [['a', 'a'], ['a', 'b'], [null, undefined], [0, ''], [1, 1]], + sumField: [ + [null, 'amount'], [undefined, 'amount'], ['nope', 'amount'], [[], 'amount'], + [[{ amount: 1 }, { amount: 2.5 }], 'amount'], + [[{ amount: 'abc' }, { amount: 3 }], 'amount'], + [[{ amount: null }, { amount: '' }, { amount: '4' }], 'amount'], + [[{ amount: 1 }], 'missingField'], + ], + formatDateDisplay: [ + [null], [undefined], [''], [0], [123], + ['2026-08-06'], ['2026-8-6'], ['2026-08-06T00:00:00'], + ['abc'], ['dddd-dd-dd'], ['06/08/2026'], + ], + sumRowsByCategory: [ + [null, 'AS', 'amount'], [undefined, 'AS', 'amount'], ['nope', 'AS', 'amount'], + [[{ category: 'ASSET', amount: 10 }, { category: 'LIAB', amount: 5 }], 'AS', 'amount'], + [[{ amount: 10 }, { category: 'ASSET', amount: 'x' }], 'AS', 'amount'], + [[{ category: 'ASSET', amount: null }, { category: 'ASSET', amount: '2' }], 'AS', 'amount'], + [[{ category: 'ASSET', amount: 1 }], 'ZZ', 'amount'], + ], + }; + + it('covers every parity-checked helper with at least one case (no silently untested helper)', () => { + assert.deepEqual(PARITY_NAMES.filter((n) => !CASES[n]), []); + }); + + for (const name of PARITY_NAMES) { + it(`${name} — emitted string output matches createReportHelpers() for every input`, () => { + for (const args of CASES[name]) { + const expected = live[name].apply(undefined, args); + const actual = emitted[name].apply(undefined, args); + assert.deepEqual( + actual, + expected, + `${name}(${args.map((a) => JSON.stringify(a)).join(', ')}) drifted: ` + + `emitted string returned ${JSON.stringify(actual)}, live helper returned ${JSON.stringify(expected)}`, + ); + } + }); + } +}); + +// Fixture mirroring a report `helpers.js` that (incorrectly, or via copy-paste) +// redeclares a canonical helper name alongside its own report-specific extra. +// Regression guard for the CANONICAL_HELPER_NAMES drift bug: that set used to +// be a third, hand-maintained list separate from JSREPORT_HELPER_SOURCES — +// a helper added to the live/source-text sets without updating this list would +// not be recognized as canonical, so the report's own (wrong) copy of it would +// survive into `extras` and get concatenated a SECOND time after the canonical +// one, leaving two `function eq(` declarations in the emitted string. +const REPORT_WITH_DUPLICATE_CANONICAL_HELPER_SRC = [ + "var QRCode = require('qrcode');", + // Deliberately different (wrong) behaviour from the canonical eq() — if this + // survives into the emitted string, hoisting lets it win over the canonical one. + 'function eq(a, b) { return String(a) == String(b); }', + 'function qrCode(header) {', + " if (!header || typeof header !== 'object') return QRCode.toDataURL('no data', { width: 120, margin: 1 });", + " return QRCode.toDataURL('some-data', { width: 120, margin: 1 });", + '}', +].join('\n'); + +describe('buildJsreportHelpersString — CANONICAL_HELPER_NAMES filtering (no duplicate declarations)', () => { + it('strips a report-declared helper that shadows a canonical name — only one "function eq(" survives', () => { + const built = buildJsreportHelpersString(REPORT_WITH_DUPLICATE_CANONICAL_HELPER_SRC); + const occurrences = built.match(/function eq\(/g) || []; + assert.equal(occurrences.length, 1, 'expected exactly one "function eq(" declaration in the emitted string'); + }); + + it('the surviving "eq" is the canonical one, not the report-specific (wrong) copy', () => { + const built = buildJsreportHelpersString(REPORT_WITH_DUPLICATE_CANONICAL_HELPER_SRC); + // Extract just the `eq` function body rather than eval-ing the whole + // built string: the fixture's `require('qrcode')` line survives into the + // output (it is a genuine report-specific extra) and there is no `require` + // in this test's `new Function` sandbox. + const eqSrc = extractFunctionSource(built, 'eq'); + const eq = new Function(`${eqSrc}; return eq;`)(); + // The report-specific copy used loose `==` on stringified values, so it + // would treat 1 and '1' as equal. The canonical eq() uses strict `===`. + assert.equal(eq(1, '1'), false); + }); + + it('still keeps the genuine report-specific extra (qrCode) alongside the canonical set', () => { + const built = buildJsreportHelpersString(REPORT_WITH_DUPLICATE_CANONICAL_HELPER_SRC); + assert.match(built, /function qrCode\(/); + }); +}); + +describe('buildJsreportHelpersString — formatDateDisplay regex escaping in the emitted string', () => { + // The `\d` classes live inside a template literal, so they MUST be written + // `\\d` — a single `\d` collapses to a literal `d` at build time and the regex + // stops matching real dates (silently, with no error). Only an assertion made + // against the EMITTED STRING catches this; the live helper is unaffected. + const { formatDateDisplay } = evalBuiltHelpers(buildJsreportHelpersString(), ['formatDateDisplay']); + + it('reformats an ISO YYYY-MM-DD date to DD-MM-YYYY', () => { + assert.equal(formatDateDisplay('2026-08-06'), '06-08-2026'); + }); + + it('does NOT match a literal "dddd-dd-dd" — proves the digit classes survived as \\d, not as the letter d', () => { + assert.equal(formatDateDisplay('dddd-dd-dd'), 'dddd-dd-dd'); + }); + + it('passes through non-ISO values unchanged', () => { + assert.equal(formatDateDisplay('06/08/2026'), '06/08/2026'); + assert.equal(formatDateDisplay('2026-8-6'), '2026-8-6'); + assert.equal(formatDateDisplay(''), ''); + assert.equal(formatDateDisplay(null), ''); + }); +}); + +describe('buildJsreportHelpersString — group-break state in the emitted string', () => { + it('isGroupBreak tracks previous values per field across a a,b sequence', () => { + const { isGroupBreak } = evalBuiltHelpers(buildJsreportHelpersString(), ['isGroupBreak']); + assert.equal(isGroupBreak('account', 'a'), true); + assert.equal(isGroupBreak('account', 'a'), false); + assert.equal(isGroupBreak('account', 'b'), true); + assert.equal(isGroupBreak('account', 'b'), false); + }); + + it('tracks each field independently', () => { + const { isGroupBreak } = evalBuiltHelpers(buildJsreportHelpersString(), ['isGroupBreak']); + assert.equal(isGroupBreak('account', 'a'), true); + assert.equal(isGroupBreak('org', 'a'), true); + assert.equal(isGroupBreak('account', 'a'), false); + assert.equal(isGroupBreak('org', 'a'), false); + }); + + it('resetGroupTracking clears the state so the next value breaks again', () => { + const { isGroupBreak, resetGroupTracking } = evalBuiltHelpers( + buildJsreportHelpersString(), ['isGroupBreak', 'resetGroupTracking'], + ); + assert.equal(isGroupBreak('account', 'a'), true); + assert.equal(isGroupBreak('account', 'a'), false); + resetGroupTracking(); + assert.equal(isGroupBreak('account', 'a'), true); + }); + + it('matches the live helpers over the same a,a,b sequence (parity, stateful)', () => { + const { isGroupBreak, resetGroupTracking } = evalBuiltHelpers( + buildJsreportHelpersString(), ['isGroupBreak', 'resetGroupTracking'], + ); + const live = createReportHelpers(); + const sequence = ['a', 'a', 'b', 'b', 'a']; + const emittedResults = sequence.map((v) => isGroupBreak('account', v)); + const liveResults = sequence.map((v) => live.isGroupBreak('account', v)); + assert.deepEqual(emittedResults, liveResults); + assert.deepEqual(emittedResults, [true, false, true, false, true]); + resetGroupTracking(); + live.resetGroupTracking(); + assert.equal(isGroupBreak('account', 'a'), live.isGroupBreak('account', 'a')); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Source guard: `buildJsreportHelpersString` must never go back to deriving the +// emitted string from the LIVE functions. +// +// This is the one hole no behavioural test can cover. Reverting to +// `fn.toString()` keeps every other test in this file GREEN, because nothing in +// a `node --test` run is minified — `fn.toString()` faithfully emits +// `function ifCond(` here and only degrades to `function l(` in a production +// bundle. So the invariant has to be asserted against the SOURCE TEXT. +// +// The assertion is scoped to the function body and comment-stripped on purpose: +// `fn.toString()` and `createReportHelpers()` both appear legitimately elsewhere +// in the module — in the JSDoc above this very function, in the +// `JSREPORT_HELPER_SOURCES` rationale, and in `registerReportHelpers()` (which +// SHOULD call `createReportHelpers`). A whole-file grep would be a guaranteed +// false positive. +// ───────────────────────────────────────────────────────────────────────────── + +/** Strip block and line comments so prose mentioning `fn.toString()` can't trip the guard. */ +function stripComments(code) { + return code + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^[ \t]*\/\/.*$/gm, ''); +} + +describe('buildJsreportHelpersString — source guard against the minification-unsafe fn.toString() pattern', () => { + const moduleSrc = readFileSync(MODULE_PATH, 'utf8'); + // `extractFunctionSource` starts at `function (`, so the JSDoc block + // above the function (which discusses fn.toString() in prose) is excluded. + const fnSrc = extractFunctionSource(moduleSrc, 'buildJsreportHelpersString'); + const fnBody = stripComments(fnSrc); + + it('the extraction actually captured the whole function body (guards against a silently-passing empty match)', () => { + // Without this, a broken extraction would make every assertion below + // vacuously true. + assert.match(fnSrc, /^function buildJsreportHelpersString\(helpersCode/); + assert.ok(fnSrc.endsWith('}'), 'extracted block does not end at a closing brace'); + assert.match(fnBody, /const canonicalSrc =/); + assert.match(fnBody, /return \[requireLines, stateSrc, canonicalSrc, extrasSrc\]/); + }); + + it('does not call .toString() anywhere in its body', () => { + assert.doesNotMatch( + fnBody, + /\.toString\s*\(/, + 'buildJsreportHelpersString must not serialize the live helpers with fn.toString(): they are ' + + "closure locals inside createReportHelpers(), so a minified bundle emits `function l(` and " + + 'jsreport answers 400 missing helper. Emit hardcoded source text via JSREPORT_HELPER_SOURCES instead.', + ); + }); + + it('does not derive the emitted string from createReportHelpers()', () => { + assert.doesNotMatch( + fnBody, + /createReportHelpers/, + 'buildJsreportHelpersString must not read the live helper set: any identifier it pulls from ' + + "createReportHelpers()'s closure (function name OR captured variable) gets renamed by the " + + 'minifier and leaks into the string sent to jsreport.', + ); + }); + + it('builds the canonical set from the hardcoded JSREPORT_HELPER_SOURCES map', () => { + assert.match(fnBody, /JSREPORT_HELPER_SOURCES/); + }); + + it('the comment-stripping is load-bearing — the raw function text does mention the forbidden pattern in prose', () => { + // If this ever fails, the "NEVER build this from fn.toString()" warning + // comment was deleted; the guard above still works, but the next reader + // loses the explanation of WHY. Kept as a canary, not as a hard rule. + assert.match(fnSrc, /toString/); + }); +}); diff --git a/cli/test/report-qr.test.js b/cli/test/report-qr.test.js new file mode 100644 index 000000000..9e683f01f --- /dev/null +++ b/cli/test/report-qr.test.js @@ -0,0 +1,125 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { + buildDocumentQrText, + computeDocumentQrDataUrl, +} from '../../templates/reports/helpers/report-html-helpers.js'; + +describe('buildDocumentQrText', () => { + const fullHeader = { + doc_type: 'AR Invoice', + documentno: 'INV-1001', + dateinvoiced: '2026-08-10T00:00:00.000Z', + bp_name: 'ACME Corp', + grandtotal: '1210.00', + currency: 'EUR', + org_taxid: 'B12345678', + status: 'CO', + }; + + it('joins all known fields with pipes, prefixed, in canonical order', () => { + assert.equal( + buildDocumentQrText(fullHeader), + 'T:AR Invoice|N:INV-1001|D:2026-08-10|BP:ACME Corp|$:1210.00|C:EUR|TID:B12345678|S:CO' + ); + }); + + it('truncates dateinvoiced to the first 10 chars (date-only)', () => { + const text = buildDocumentQrText({ dateinvoiced: '2026-08-10T15:30:00Z' }); + assert.equal(text, 'D:2026-08-10'); + }); + + it('skips missing fields without leaving empty segments', () => { + const text = buildDocumentQrText({ documentno: 'INV-2', status: 'DR' }); + assert.equal(text, 'N:INV-2|S:DR'); + }); + + it('ignores unknown header fields', () => { + assert.equal(buildDocumentQrText({ foo: 'bar', documentno: 'X' }), 'N:X'); + }); + + it('returns "empty" for a header object with no known fields', () => { + assert.equal(buildDocumentQrText({}), 'empty'); + }); + + it('returns "no data" when there is no header', () => { + assert.equal(buildDocumentQrText(null), 'no data'); + assert.equal(buildDocumentQrText(undefined), 'no data'); + assert.equal(buildDocumentQrText('not-an-object'), 'no data'); + }); + + // Date fallback chain — each document type historically used its own field. + it('uses dateordered for orders/quotations (D: prefix, truncated)', () => { + assert.equal( + buildDocumentQrText({ documentno: 'SO-1', dateordered: '2026-08-01T09:00:00Z' }), + 'N:SO-1|D:2026-08-01' + ); + }); + + it('uses movementdate for shipments/receipts (no amount/currency fields)', () => { + assert.equal( + buildDocumentQrText({ doc_type: 'Shipment', documentno: 'SH-1', movementdate: '2026-08-02T00:00:00Z', bp_name: 'ACME', status: 'CO' }), + 'T:Shipment|N:SH-1|D:2026-08-02|BP:ACME|S:CO' + ); + }); + + it('uses paymentdate and amount for payments', () => { + assert.equal( + buildDocumentQrText({ documentno: 'PAY-1', paymentdate: '2026-08-03T12:00:00Z', amount: '500.00', currency: 'EUR' }), + 'N:PAY-1|D:2026-08-03|$:500.00|C:EUR' + ); + }); + + it('prefers dateinvoiced and grandtotal when multiple candidates exist', () => { + assert.equal( + buildDocumentQrText({ dateinvoiced: '2026-08-10', dateordered: '2026-08-01', grandtotal: '100', amount: '99' }), + 'D:2026-08-10|$:100' + ); + }); + + it('skips falsy field values (empty string, 0, null)', () => { + assert.equal( + buildDocumentQrText({ documentno: '', grandtotal: 0, currency: null, status: 'CO' }), + 'S:CO' + ); + }); +}); + +describe('computeDocumentQrDataUrl', () => { + it('returns a PNG data URL for a full header', async () => { + const url = await computeDocumentQrDataUrl({ documentno: 'INV-1001', status: 'CO' }); + assert.ok(url.startsWith('data:image/png;base64,'), `unexpected prefix: ${url.slice(0, 30)}`); + }); + + it('still returns a QR ("no data") when header is missing', async () => { + const url = await computeDocumentQrDataUrl(null); + assert.ok(url.startsWith('data:image/png;base64,')); + }); + + it('encodes the exact text and options via an injected qrcode module', async () => { + const calls = []; + const fakeQrcode = { + toDataURL: async (text, options) => { + calls.push({ text, options }); + return 'data:image/png;base64,FAKE'; + }, + }; + const url = await computeDocumentQrDataUrl( + { documentno: 'INV-7', currency: 'USD' }, + { qrcode: fakeQrcode } + ); + assert.equal(url, 'data:image/png;base64,FAKE'); + assert.equal(calls.length, 1); + assert.equal(calls[0].text, 'N:INV-7|C:USD'); + assert.deepEqual(calls[0].options, { width: 120, margin: 1 }); + }); + + it('produces identical output for identical headers (deterministic)', async () => { + const header = { documentno: 'INV-9' }; + const [a, b] = await Promise.all([ + computeDocumentQrDataUrl(header), + computeDocumentQrDataUrl(header), + ]); + assert.equal(a, b); + }); +}); diff --git a/package-lock.json b/package-lock.json index c8e2c5769..84b4e163f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "sf-extract": "src/extract-fields.js", "sf-extract-db": "src/extract-from-db.js", "sf-extract-rules": "src/extract-rules.js", + "sf-flag-debt": "src/flag-debt.js", "sf-gen-log": "src/generation-log.js", "sf-generate-frontend": "src/generate-frontend.js", "sf-generate-reports-manifest": "src/generate-reports-manifest.js", @@ -52,6 +53,7 @@ "sf-report-preview": "src/report-preview.js", "sf-report-serve": "src/report-serve.js", "sf-resolve-curated": "src/resolve-curated.js", + "sf-slice-labels": "src/slice-labels-cli.js", "sf-test": "src/run-contract-tests.js", "sf-test-report": "src/test-report-html.js", "sf-validate": "src/validate-schema.js", @@ -2972,6 +2974,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "license": "MIT", @@ -4459,7 +4475,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4851,6 +4866,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "dev": true, @@ -5101,7 +5125,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5112,7 +5135,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -5433,6 +5455,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -5542,6 +5573,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "dev": true, @@ -6094,6 +6131,19 @@ "semver": "bin/semver" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/flow-estree": { "version": "0.319.0", "resolved": "https://registry.npmjs.org/flow-estree/-/flow-estree-0.319.0.tgz", @@ -6226,7 +6276,6 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6626,7 +6675,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7103,6 +7151,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash.debounce": { "version": "4.0.8", "dev": true, @@ -7688,11 +7748,37 @@ "dev": true, "license": "MIT" }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7767,6 +7853,15 @@ "dev": true, "license": "MIT" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "dev": true, @@ -7945,22 +8040,6 @@ "node": ">=6" } }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pkg-dir/node_modules/p-locate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", @@ -7984,6 +8063,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "dev": true, @@ -8266,6 +8354,116 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/qrcode/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.0", "dev": true, @@ -8602,7 +8800,6 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8616,6 +8813,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -8879,6 +9082,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "license": "MIT", @@ -10464,6 +10673,12 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -10487,7 +10702,6 @@ }, "node_modules/wrap-ansi": { "version": "6.2.0", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -10500,7 +10714,6 @@ }, "node_modules/wrap-ansi/node_modules/ansi-regex": { "version": "5.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10508,12 +10721,10 @@ }, "node_modules/wrap-ansi/node_modules/emoji-regex": { "version": "8.0.0", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "4.2.3", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10526,7 +10737,6 @@ }, "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "6.0.1", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11072,6 +11282,7 @@ "name": "@etendosoftware/etendo-go-core", "version": "0.3.0", "peerDependencies": { + "@phosphor-icons/react": "^2.1.0", "lucide-react": "^0.400.0", "react": "^18.3.0", "react-dom": "^18.3.0", @@ -11526,7 +11737,8 @@ "version": "1.0.0", "dependencies": { "handlebars": "^4.7.8", - "pg": "^8.13.0" + "pg": "^8.13.0", + "qrcode": "^1.5.4" } }, "tools/spike-hello-app": { diff --git a/templates/reports/helpers/report-html-helpers.js b/templates/reports/helpers/report-html-helpers.js index 19c5eaeca..3f6564bfa 100644 --- a/templates/reports/helpers/report-html-helpers.js +++ b/templates/reports/helpers/report-html-helpers.js @@ -1,6 +1,12 @@ /** * Canonical Handlebars helpers for LOCAL HTML rendering of reports. * + * This is the ONLY approved source for jsreport's `formatCurrency`/`formatNumber` + * helpers — never write a second currency/number Handlebars helper by hand in a + * per-report `helpers.js` or inline in `report-api.js`. See CLAUDE.md § Currency + * & Amount Formatting (MANDATORY); the browser-side equivalent is + * `tools/app-shell/src/lib/formatCurrency.js` (ETP-4314). + * * These mirror — verbatim — the generated `artifacts//helpers.js` functions * that the report HTML render path historically registered (the fixed whitelist: * isGroupBreak, resetGroupTracking, formatDate, formatCurrency, formatBoolean, @@ -10,9 +16,11 @@ * dev plugin register the helpers WITHOUT dynamically executing the per-report * artifact file (no `new Function` / `eval`, which Sonar flags as S1523). * - * jsreport (PDF/XLSX) is intentionally unchanged: it still consumes the per-report - * `helpers.js` string directly, which is where report-specific helpers such as - * `qrCode` live. Those are never part of the local HTML whitelist. + * jsreport (PDF/XLSX) still consumes a helpers string built from this module + * (see `buildJsreportHelpersString()`), plus any report-specific extras found in + * the per-report `helpers.js`. Document QR codes are NOT a helper on either + * path: they are precomputed as plain data (`header.qrDataUrl`) via + * `computeDocumentQrDataUrl()` before render (ETP-4908). * * `createReportHelpers()` returns a fresh set with isolated group-break state, * matching the previous per-render isolation that `new Function` provided. @@ -54,7 +62,7 @@ export function createReportHelpers({ numberFormat } = {}) { if (value == null) return ''; var num = Number(value); if (isNaN(num)) return String(value); - return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num); + return new Intl.NumberFormat('es-ES', { minimumFractionDigits: 2, maximumFractionDigits: 2, useGrouping: true }).format(num); } function formatBoolean(value) { @@ -65,7 +73,7 @@ export function createReportHelpers({ numberFormat } = {}) { if (value == null) return ''; var num = Number(value); if (isNaN(num)) return String(value); - return new Intl.NumberFormat('en-US', numberFormat || undefined).format(num); + return new Intl.NumberFormat('es-ES', Object.assign({ useGrouping: true }, numberFormat || undefined)).format(num); } function ifCond(v1, operator, v2, options) { @@ -118,6 +126,136 @@ export function createReportHelpers({ numberFormat } = {}) { }; } +/** + * Build the text encoded in a document report's QR code. + * + * Exact port of the text-building logic of the historical per-report `qrCode` + * Handlebars helper (artifacts/print-*\/helpers.js). Kept pure so it can be + * tested without generating an actual QR image. + * + * @param {object} [header] Document header row. + * @returns {string} Pipe-joined field string, 'empty' when the header has no + * known fields, or 'no data' when there is no header object at all. + */ +export function buildDocumentQrText(header) { + if (!header || typeof header !== 'object') return 'no data'; + const docDate = header.dateinvoiced || header.dateordered || header.movementdate || header.paymentdate; + const docAmount = header.grandtotal || header.amount; + const parts = []; + if (header.doc_type) parts.push('T:' + header.doc_type); + if (header.documentno) parts.push('N:' + header.documentno); + if (docDate) parts.push('D:' + String(docDate).substring(0, 10)); + if (header.bp_name) parts.push('BP:' + header.bp_name); + if (docAmount) parts.push('$:' + docAmount); + if (header.currency) parts.push('C:' + header.currency); + if (header.org_taxid) parts.push('TID:' + header.org_taxid); + if (header.status) parts.push('S:' + header.status); + return parts.length > 0 ? parts.join('|') : 'empty'; +} + +/** + * Precompute a document's QR code as a PNG data URL (`header.qrDataUrl`). + * + * This replaces the per-report async `qrCode` Handlebars helper: Handlebars + * compiles synchronously on the local HTML path, so the QR must be resolved + * BEFORE compile and injected as plain data. Templates reference it as + * `` on both the HTML and jsreport paths. + * + * NOT a Handlebars helper — deliberately excluded from `createReportHelpers()`. + * + * @param {object} [header] Document header row. + * @param {object} [options] + * @param {object} [options.qrcode] Pre-resolved `qrcode` module. The report + * server passes its own (its Docker image installs node_modules only + * under tools/report-server, unreachable from this module's path); + * other consumers can omit it and rely on the lazy dynamic import. + * @returns {Promise} PNG data URL. + */ +export async function computeDocumentQrDataUrl(header, { qrcode } = {}) { + const QRCode = qrcode || (await import('qrcode')).default; + return QRCode.toDataURL(buildDocumentQrText(header), { width: 120, margin: 1 }); +} + +/** + * Verbatim SOURCE TEXT of the canonical helpers, keyed by the helper name + * jsreport must register them under. + * + * WHY A SECOND, STRING COPY OF THE FUNCTIONS ABOVE — READ BEFORE "DEDUPLICATING": + * `buildJsreportHelpersString()` used to serialize the live functions with + * `fn.toString()`. That silently breaks in a production bundle: the helpers are + * local declarations inside `createReportHelpers()`'s closure, so the minifier + * renames both the FUNCTION NAMES (`function ifCond(` → `function l(`) and every + * CLOSURE VARIABLE they capture (`_prevGroupValues` → `e`). jsreport derives each + * helper's name from the `function (` text it receives, so the emitted + * string declared `l`/`i`/… and jsreport answered `400 missing helper "ifCond"`; + * `isGroupBreak` additionally hit a `ReferenceError` on the renamed closure var. + * It only ever worked in dev, where nothing is minified. + * + * Emitting hardcoded source text is the fix, and it is the same pattern the + * three helpers that already survived minification (`__groupEsEs`, + * `formatCurrency`, `formatNumber`) have always used: no bundle identifier — + * neither a function name nor a captured variable — can leak into the string, so + * the output is byte-identical in dev and in a minified production build. The + * alternative (recreating the live helpers from these strings via + * `new Function`) is ruled out by Sonar S1523, per this module's docstring. + * + * INVARIANT: each entry here must stay behaviourally identical to its + * same-named function in `createReportHelpers()` above. They are a matched pair; + * change one, change the other. A parity test guards this — see + * `tools/app-shell/test/report-jsreport-helpers-builder.test.js`. + * + * `formatCurrency`/`formatNumber` are intentionally absent — they are generated + * per call with the instance separators / decimal precision baked in. + */ +const JSREPORT_HELPER_SOURCES = { + isGroupBreak: `function isGroupBreak(field, currentValue) { + var prev = _prevGroupValues[field]; + _prevGroupValues[field] = currentValue; + return prev !== currentValue; +}`, + resetGroupTracking: `function resetGroupTracking() { + _prevGroupValues = {}; +}`, + formatDate: `function formatDate(value) { + if (value == null || value === '') return ''; + var d = new Date(value); + if (isNaN(d.getTime())) return String(value); + return new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d); +}`, + formatBoolean: `function formatBoolean(value) { + return value ? 'Yes' : 'No'; +}`, + ifCond: `function ifCond(v1, operator, v2, options) { + switch (operator) { + case '===': return v1 === v2 ? options.fn(this) : options.inverse(this); + case '!==': return v1 !== v2 ? options.fn(this) : options.inverse(this); + default: return options.inverse(this); + } +}`, + eq: `function eq(a, b) { return a === b; }`, + sumField: `function sumField(rows, field) { + if (!Array.isArray(rows)) return 0; + return rows.reduce(function(acc, row) { + var val = Number(row[field]); + return acc + (isNaN(val) ? 0 : val); + }, 0); +}`, + formatDateDisplay: `function formatDateDisplay(value) { + if (value == null || value === '') return ''; + if (/^\\d{4}-\\d{2}-\\d{2}$/.test(String(value))) { + var parts = value.split('-'); + return parts[2] + '-' + parts[1] + '-' + parts[0]; + } + return String(value); +}`, + sumRowsByCategory: `function sumRowsByCategory(rows, categoryPrefix, field) { + if (!Array.isArray(rows)) return 0; + return rows + .filter(function(r) { return (r.category || '').startsWith(categoryPrefix); }) + .reduce(function(sum, r) { return sum + (Number(r[field]) || 0); }, 0); +}`, +}; + /** * Statically recover the `formatNumber` Intl options from a report's * `helpers.js` source WITHOUT executing it. Returns the options object the @@ -161,3 +299,149 @@ export function registerReportHelpers(handlebars, helpersCode) { }); return helpers; } + +// Helper names covered by the canonical set — anything else found in a report's +// raw helpers.js is report-specific and must be preserved verbatim. +// +// Derived from JSREPORT_HELPER_SOURCES (the source-text map) rather than +// hand-maintained as a third, separate list: a helper added there without +// being added here would silently fail to get stripped from a report's +// extras, letting a report-declared function with the same name survive +// alongside the canonical one — two `function (` declarations in the +// emitted string, with JS hoisting picking whichever the report declared. +// `formatCurrency`/`formatNumber` are added explicitly because they are +// intentionally excluded from JSREPORT_HELPER_SOURCES (generated per call +// with instance separators baked in — see that map's docstring). +const CANONICAL_HELPER_NAMES = new Set([ + ...Object.keys(JSREPORT_HELPER_SOURCES), + 'formatCurrency', + 'formatNumber', +]); + +function extractBraceBlock(source, startIdx) { + const braceStart = source.indexOf('{', startIdx); + let depth = 0; + let i = braceStart; + for (; i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return source.slice(startIdx, i + 1); +} + +function extractTopLevelFunctions(source) { + const results = []; + const re = /function\s+(\w+)\s*\(/g; + let m; + while ((m = re.exec(source))) { + const fnSource = extractBraceBlock(source, m.index); + results.push({ name: m[1], source: fnSource }); + re.lastIndex = m.index + fnSource.length; + } + return results; +} + +function extractRequireLines(source) { + const matches = source.match(/^[ \t]*(?:var|const|let)\s+\w+\s*=\s*require\([^)]*\)\s*;?[ \t]*$/gm); + return matches ? matches.join('\n') : ''; +} + +/** + * Builds the `helpers` string sent to jsreport for the PDF/XLSX render path — + * the real centralization point for that path (see docs/... ETP-4314 plan). + * + * jsreport runs in a separate Docker container reachable only over HTTP, with + * no shared module system with this repo — so it can never `import` + * formatCurrency() or this module directly. Instead, this function emits the + * canonical helper set as source text (`JSREPORT_HELPER_SOURCES`, the matched + * string pair of `createReportHelpers()`'s functions) and appends only the + * report-SPECIFIC extras (with their `require` lines) extracted from the + * report's raw `artifacts//helpers.js`. The result is the single source of + * truth for both render paths — not a second, hand-maintained copy per report. + * + * It deliberately does NOT serialize the live functions with `fn.toString()`: + * that is minification-unsafe and shipped a production-only + * `400 missing helper "ifCond"` — see `JSREPORT_HELPER_SOURCES` for the full + * rationale before changing this. + * + * `formatCurrency`/`formatNumber` cannot rely on `Intl.NumberFormat` in the + * serialized string: jsreport runs its own separate Node process (see module + * docstring above), and the Node/ICU build the `etendo-jsreport` Docker image + * ships (Node 18.20.4 / ICU 74.2 / CLDR 44.1, confirmed via `docker exec`) + * silently drops the thousands separator for the `es-ES` locale specifically + * in the 1000-9999 range — the exact bug this ticket exists to fix. Node ≥20 + * (ICU ≥78/CLDR ≥48) does not have this data bug, but we can't control which + * Node build ends up running jsreport (that lives in a separate repo/image). + * `__groupEsEs` sidesteps the whole class of problem: a manual, locale-data- + * free grouping algorithm gives the same result on any Node/ICU version. + * + * `isGroupBreak`/`resetGroupTracking` have their own closure-variable issue + * (`_prevGroupValues`) — solved by declaring that variable at the top of the + * combined string rather than templating the whole function (their bodies + * don't reference anything else external). + * + * @param {string} [helpersCode] Raw contents of `artifacts//helpers.js`. + * @param {Intl.NumberFormatOptions} [numberFormatOverride] Explicit + * `formatNumber` decimal-precision override for callers that already + * know it as a plain JS value (e.g. a document PDF's exchange-rate + * precision) instead of one recoverable by regex from a raw + * `helpers.js` string. Takes precedence over `extractNumberFormatOptions`. + * @param {{ thousandsSeparator?: string, decimalSeparator?: string }} [separators] + * Instance-wide separators (from the same `/sws/neo/currency-format` + * config `formatCurrency.js` reads in the browser — ETP-4314). Baked + * into the generated `__groupEsEs` source as literals, since jsreport + * can never fetch this config itself. Defaults to `.`/`,`. + * @returns {string} Combined JS source to send as jsreport's `helpers` field. + */ +export function buildJsreportHelpersString(helpersCode, numberFormatOverride, separators) { + const numberFormat = numberFormatOverride || extractNumberFormatOptions(helpersCode); + const thousandsSeparator = (separators && separators.thousandsSeparator) || '.'; + const decimalSeparator = (separators && separators.decimalSeparator) || ','; + + const stateSrc = 'var _prevGroupValues = {};'; + + const groupEsEsSrc = `function __groupEsEs(num, minFrac, maxFrac) { + var sign = num < 0 ? '-' : ''; + var abs = Math.abs(num); + var fixed = abs.toFixed(maxFrac); + var parts = fixed.split('.'); + var intPart = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ${JSON.stringify(thousandsSeparator)}); + var decPart = parts[1] || ''; + while (decPart.length > minFrac && decPart.charAt(decPart.length - 1) === '0') { + decPart = decPart.slice(0, -1); + } + return decPart ? sign + intPart + ${JSON.stringify(decimalSeparator)} + decPart : sign + intPart; +}`; + + const formatCurrencySrc = `function formatCurrency(value) { + if (value == null) return ''; + var num = Number(value); + if (isNaN(num)) return String(value); + return __groupEsEs(num, 2, 2); +}`; + + const minFrac = (numberFormat && numberFormat.minimumFractionDigits != null) ? numberFormat.minimumFractionDigits : 0; + const maxFrac = (numberFormat && numberFormat.maximumFractionDigits != null) ? numberFormat.maximumFractionDigits : 3; + const formatNumberSrc = `function formatNumber(value) { + if (value == null) return ''; + var num = Number(value); + if (isNaN(num)) return String(value); + return __groupEsEs(num, ${minFrac}, ${maxFrac}); +}`; + + // NEVER build this from `fn.toString()` on the live helpers: those are closure + // locals, so a production bundle emits minified function names AND minified + // closure-variable references — see JSREPORT_HELPER_SOURCES above. + const canonicalSrc = Object.values(JSREPORT_HELPER_SOURCES) + .concat(groupEsEsSrc, formatCurrencySrc, formatNumberSrc) + .join('\n\n'); + + const extras = helpersCode ? extractTopLevelFunctions(helpersCode).filter((f) => !CANONICAL_HELPER_NAMES.has(f.name)) : []; + const requireLines = helpersCode ? extractRequireLines(helpersCode) : ''; + const extrasSrc = extras.map((f) => f.source).join('\n\n'); + + return [requireLines, stateSrc, canonicalSrc, extrasSrc].filter(Boolean).join('\n\n'); +} diff --git a/tools/report-server/__tests__/fixtures/document-template-unregistered-helper.hbs b/tools/report-server/__tests__/fixtures/document-template-unregistered-helper.hbs new file mode 100644 index 000000000..5d655ec1b --- /dev/null +++ b/tools/report-server/__tests__/fixtures/document-template-unregistered-helper.hbs @@ -0,0 +1,12 @@ + + + +
+
{{header.documentno}}
+ {{!-- This report-specific helper is intentionally NOT part of the + canonical whitelist (registerReportHelpers). It exercises the + Missing-helper failure mode that ETP-4908 fixed for QR codes. --}} + QR +
+ + diff --git a/tools/report-server/__tests__/fixtures/document-template.hbs b/tools/report-server/__tests__/fixtures/document-template.hbs new file mode 100644 index 000000000..cca45d313 --- /dev/null +++ b/tools/report-server/__tests__/fixtures/document-template.hbs @@ -0,0 +1,37 @@ + + + + + + + +
+
{{header.doc_type}}
+
{{header.documentno}}
+
{{formatDate header.dateinvoiced}}
+
{{formatCurrency header.grandtotal}}
+
+ {{#ifCond header.status '===' 'CO'}}Completed{{else}}Pending{{/ifCond}} +
+ QR +
+ + + + + + + + + + {{#each lines}} + + + + + + {{/each}} + +
LineQuantityUnit Price
{{this.name}}{{formatNumber this.qty}}{{formatCurrency this.unitPrice}}
+ + diff --git a/tools/report-server/__tests__/server-render.test.js b/tools/report-server/__tests__/server-render.test.js new file mode 100644 index 000000000..c01dcbd87 --- /dev/null +++ b/tools/report-server/__tests__/server-render.test.js @@ -0,0 +1,167 @@ +/** + * Regression guards for ETP-4908 — "Missing helper: qrCode" in production. + * + * The report server renders document templates through ONLY the canonical + * helper whitelist (`registerReportHelpers` in + * `templates/reports/helpers/report-html-helpers.js`); per-report `helpers.js` + * text is read but never executed (post-ETP-4083, no `new Function`/`eval`). + * A template that invokes a helper outside that whitelist (e.g. the old + * per-report `qrCode` helper) fails at render time with + * `Missing helper: "qrCode"`. + * + * The fix moved the QR code out of Handlebars entirely: it is precomputed as + * DATA (`header.qrDataUrl`) by `injectDocumentQr()`/`computeDocumentQrDataUrl()` + * BEFORE the synchronous `Handlebars.compile()` call, and templates reference + * it as ``. + * + * These tests exercise the REAL functions from report-html-helpers.js — the + * same ones `tools/report-server/server.js` imports and composes — against a + * representative document-template fixture, replicating only the two thin + * server.js wrappers (`injectDocumentQr`, `renderTemplateWithHelpers`) since + * server.js itself starts an HTTP listener on import (see server.test.js for + * the same replication convention with the server's other private helpers). + */ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + registerReportHelpers, + computeDocumentQrDataUrl, +} from '../../../templates/reports/helpers/report-html-helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const _require = createRequire(import.meta.url); + +const DOCUMENT_TEMPLATE = readFileSync( + join(__dirname, 'fixtures', 'document-template.hbs'), + 'utf8', +); +const UNREGISTERED_HELPER_TEMPLATE = readFileSync( + join(__dirname, 'fixtures', 'document-template-unregistered-helper.hbs'), + 'utf8', +); + +const SAMPLE_HEADER = { + doc_type: 'AR Invoice', + documentno: 'INV-1001', + dateinvoiced: '2026-08-10T00:00:00.000Z', + bp_name: 'ACME Corp', + grandtotal: '1210.00', + currency: 'EUR', + org_taxid: 'B12345678', + status: 'CO', +}; + +const SAMPLE_LINES = [ + { name: 'Widget A', qty: 3, unitPrice: 10.5 }, + { name: 'Widget B', qty: 1, unitPrice: 189.5 }, +]; + +// --- Replicated server.js wrappers (see docstring above for why) --- +// These call the REAL imported functions — nothing here reimplements +// QR/helper logic, only the two-line composition server.js does around them. + +async function injectDocumentQr(documentData, templateData) { + if (!documentData?.header || !templateData.header) return; + try { + templateData.header.qrDataUrl = await computeDocumentQrDataUrl(documentData.header, { + qrcode: _require('qrcode'), + }); + } catch (e) { + console.warn('[render] QR generation failed:', e.message); + } +} + +function renderTemplateWithHelpers(helpersCode, templateContent, templateData) { + const Handlebars = _require('handlebars'); + registerReportHelpers(Handlebars, helpersCode); + return Handlebars.compile(templateContent)(templateData); +} + +describe('report server render path (ETP-4908 regression guards)', () => { + it('renders a document template with a precomputed QR data URL and no throw', async () => { + const documentData = { header: SAMPLE_HEADER, lines: SAMPLE_LINES, taxes: [] }; + const templateData = { css: '', header: { ...SAMPLE_HEADER }, lines: SAMPLE_LINES }; + + await injectDocumentQr(documentData, templateData); + + let html; + assert.doesNotThrow(() => { + html = renderTemplateWithHelpers('', DOCUMENT_TEMPLATE, templateData); + }); + + assert.match(html, /data:image\/png;base64,/); + assert.match(html, /INV-1001/); + // formatDate / formatCurrency / formatNumber / ifCond all ran successfully. + // formatDate resolves in the host's local timezone, so only the shape + // (dd/mm/yyyy) is asserted here, not the exact day. + assert.match(html, /\d{2}\/\d{2}\/2026/); + // formatCurrency is the canonical es-ES grouped format (ETP-4314 sync). + // CLDR quirk: some ICU builds skip the separator for 4-digit es-ES numbers. + assert.match(html, /1\.?210,00/); + assert.match(html, /Completed/); + assert.match(html, /10,50/); // line unitPrice via formatCurrency + assert.match(html, /189,50/); + }); + + it('throws "Missing helper" for a template invoking an unregistered helper', () => { + // Documents the failure mode ETP-4908 fixed: any report-specific helper + // (like the old per-report `qrCode`) that is not part of the canonical + // whitelist below breaks the render instead of silently degrading. + const templateData = { header: { documentno: 'INV-2002' } }; + assert.throws( + () => renderTemplateWithHelpers('', UNREGISTERED_HELPER_TEMPLATE, templateData), + /Missing helper: "qrCode"/, + ); + }); +}); + +describe('registerReportHelpers whitelist stability contract', () => { + // Any future removal/rename of a name in this list is a silent production + // "Missing helper" for every deployed template that still invokes it — + // check the functional repo's artifacts/*/generated templates and + // artifacts/*/helpers.js FIRST before changing this list. + const EXPECTED_HELPER_NAMES = [ + 'eq', + 'formatBoolean', + 'formatCurrency', + 'formatDate', + 'formatDateDisplay', + 'formatNumber', + 'ifCond', + 'isGroupBreak', + 'resetGroupTracking', + 'sumField', + 'sumRowsByCategory', + ].sort(); + + it('registers exactly the documented canonical helper set', () => { + const Handlebars = _require('handlebars'); + const registered = registerReportHelpers(Handlebars, ''); + assert.deepEqual(Object.keys(registered).sort(), EXPECTED_HELPER_NAMES); + }); +}); + +describe('server.js no-eval guard (ETP-4083 invariant)', () => { + const serverSource = readFileSync(join(__dirname, '..', 'server.js'), 'utf8'); + + it('never dynamically compiles code with `new Function(`', () => { + assert.doesNotMatch(serverSource, /new\s+Function\s*\(/); + }); + + it('never calls `eval(`', () => { + assert.doesNotMatch(serverSource, /(? { + // helpersCode must only be passed through as inert text (registerReportHelpers + // reads it statically via extractNumberFormatOptions, or forwards it verbatim + // to jsreport, which owns its own sandboxed execution) — never run in-process. + assert.match(serverSource, /helpersCode\s*=\s*loadHelpersFromFile/); + assert.doesNotMatch(serverSource, /new\s+Function\s*\(\s*helpersCode/); + assert.doesNotMatch(serverSource, /eval\s*\(\s*helpersCode/); + }); +}); diff --git a/tools/report-server/package.json b/tools/report-server/package.json index b3eb1457c..f12eed80e 100644 --- a/tools/report-server/package.json +++ b/tools/report-server/package.json @@ -5,6 +5,7 @@ "main": "server.js", "dependencies": { "handlebars": "^4.7.8", - "pg": "^8.13.0" + "pg": "^8.13.0", + "qrcode": "^1.5.4" } } diff --git a/tools/report-server/server.js b/tools/report-server/server.js index 1670d363e..c9814c0b7 100644 --- a/tools/report-server/server.js +++ b/tools/report-server/server.js @@ -24,7 +24,7 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import { resolve, join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { registerReportHelpers } from '../../templates/reports/helpers/report-html-helpers.js'; +import { registerReportHelpers, computeDocumentQrDataUrl, buildJsreportHelpersString } from '../../templates/reports/helpers/report-html-helpers.js'; const _require = createRequire(import.meta.url); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -60,6 +60,23 @@ function getDbConfig() { // Helpers // --------------------------------------------------------------------------- +// Instance-wide currency separators for the jsreport helper string, from the +// same NEO config source the browser's formatCurrency.js reads (ETP-4314). +// Cached for the process lifetime; falls back to './,' when NEO is unreachable. +// Mirrors getReportCurrencySeparators() in the Vite dev plugin (report-api.js). +let currencySeparatorsPromise = null; +async function getReportCurrencySeparators() { + if (currencySeparatorsPromise) return currencySeparatorsPromise; + currencySeparatorsPromise = fetch(`${ETENDO_URL}/sws/neo/currency-format`) + .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`status ${res.status}`)))) + .then((data) => ({ + thousandsSeparator: typeof data?.thousandsSeparator === 'string' ? data.thousandsSeparator : '.', + decimalSeparator: typeof data?.decimalSeparator === 'string' ? data.decimalSeparator : ',', + })) + .catch(() => ({ thousandsSeparator: '.', decimalSeparator: ',' })); + return currencySeparatorsPromise; +} + function getClientIdFromToken(authHeader) { try { const token = (authHeader || '').replace(/^Bearer\s+/i, ''); @@ -356,15 +373,22 @@ async function handleRequest(req, res) { calculateTotals(documentData, amountCols, rows, totals); const recordCount = getRowCount(rows); const templateData = buildTemplateData(documentData, css, { title, activeFilters, params, recordCount, totals, groupLabel, descriptionLabel, neoMeta, rows }); + await injectDocumentQr(documentData, templateData); // HTML: render with Handlebars locally if (format === 'html') { renderTemplateWithHelpers(helpersCode, templateContent, templateData, res); return; } - // PDF/XLSX: delegate to jsreport + // PDF/XLSX: delegate to jsreport. jsreport runs in a separate container + // with its own sandbox, so it gets the canonical helper set as SOURCE + // TEXT plus only this report's specific extras — never the raw artifact + // helpers.js alone (post-ETP-4083 it no longer defines the formatting + // helpers, which broke every {{#ifCond}}/{{formatDate}} template here). + // Same composition as the Vite dev plugin (report-api.js). + const separators = await getReportCurrencySeparators(); const payload = { - template: { content: templateContent, engine: 'handlebars', recipe, helpers: helpersCode }, + template: { content: templateContent, engine: 'handlebars', recipe, helpers: buildJsreportHelpersString(helpersCode, undefined, separators) }, data: templateData, }; if (recipe === 'chrome-pdf') { @@ -572,14 +596,29 @@ function renderTemplateWithHelpers(helpersCode, templateContent, templateData, r const Handlebars = _require('handlebars'); // Register the trusted in-repo helper set — no dynamic code execution. // helpersCode is read (not executed) only to preserve a report's formatNumber - // decimals. Report-specific helpers (e.g. qrCode) are only needed by the - // jsreport PDF/XLSX path, which consumes the artifact helpers.js string directly. + // decimals. Document QR codes are precomputed as data (header.qrDataUrl) by + // injectDocumentQr() before this synchronous compile — never as a helper. registerReportHelpers(Handlebars, helpersCode); const html = Handlebars.compile(templateContent)(templateData); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(html); } +// Document (print-*) reports render a QR of the header. QRCode.toDataURL is +// async while Handlebars.compile is sync, so the QR cannot be a helper on the +// local HTML path — precompute it once here, before the format branch, so both +// the local HTML render and the jsreport PDF/XLSX payload see the same +// {{header.qrDataUrl}}. A QR failure degrades to a report without QR instead +// of failing the whole render. +async function injectDocumentQr(documentData, templateData) { + if (!documentData?.header || !templateData.header) return; + try { + templateData.header.qrDataUrl = await computeDocumentQrDataUrl(documentData.header, { qrcode: _require('qrcode') }); + } catch (e) { + console.warn('[render] QR generation failed:', e.message); + } +} + function isPostRequestForRender(method, renderMatch) { return method === 'POST' && renderMatch; }