diff --git a/documentation/economic/export-field-audit.md b/documentation/economic/export-field-audit.md new file mode 100644 index 00000000..f9f174f8 --- /dev/null +++ b/documentation/economic/export-field-audit.md @@ -0,0 +1,262 @@ +# E-conomic Export Field Audit (TRU-193) + +**Status:** Complete +**Date:** 2026-08-17 +**Scope:** All user-input fields that flow into e-conomic API payloads from +the `copenhagentruckwash/api` backend. +**Primary files audited:** +- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php` +- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` +- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php` +- `services/nginx/app/classes/economic_export_sanitizer.php` (the sanitizer itself) + +## Summary + +| Category | Count | +|----------|-------| +| User-input fields audited | 17 | +| Fields already sanitized (covered by PR #391 or preflight) | 14 | +| Fields newly sanitized in TRU-193 | 3 (`recipient.name`, `recipient.address`, `recipient.zip/city`, `recipient.ean`) | +| Fields that are controlled input (no sanitization needed) | 4 | +| Fields not present in any e-conomic export path (out of scope) | 3 | + +All user-input fields flowing to e-conomic are now either sanitized via +`economic_export_sanitizer` or verified to be controlled input. + +## Sanitizer methods used + +| Method | Purpose | Length cap | +|--------|---------|------------| +| `sanitizeTextLine($value, $maxLength=250)` | Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) | +| `sanitizeProductNumber($value)` | Product identifiers | 50 | +| `sanitizeProductDescription($value)` | Product-line descriptions | 500 | +| `sanitizeForEconApi($value)` | Catch-all alias of `sanitizeTextLine` | 250 | + +Rules applied: +- `/` replaced with `-` (the reported 400 trigger, TRU-188) +- Control characters (`\x00-\x1F` except `\t` and `\n`, plus `\x7F`) stripped +- Tab + newline characters collapse to a single space +- Whitespace normalized and trimmed +- Length capped with `...` suffix if too long + +## Audit by field + +### 1. `order.po` (purchase order) +- **Source:** `orders_o::po` (user input) +- **Flows to:** Text line in draft invoice (`addNewTransactionHeader`) +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeTextLine()` +- **Sensitive to:** `/`, newlines, control chars, length + +### 2. `order.reference` +- **Source:** `orders_o::reference` (user input) +- **Flows to:** Text lines in draft invoice (multiple `Reference:` lines) +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeTextLine()` +- **Sensitive to:** `/` (PRIMARY TRU-188 trigger), newlines, control chars + +### 3. `order.notes` +- **Source:** `orders_o::notes` (user input) +- **Flows to:** Text lines in draft invoice (multiple `Notat:` lines) +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeTextLine()` +- **Sensitive to:** `/`, newlines, control chars, length + +### 4. `order.reg_1`, `order.reg_2`, `order.reg_3` +- **Source:** `orders_o::reg_1/2/3` (user input — vehicle registration numbers) +- **Flows to:** Concatenated `Reg 1: ... Reg 2: ... Reg 3: ...` line +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeTextLine(..., 50)` then `strtoupper()` +- **Sensitive to:** `/`, special chars, length (capped at 50) + +### 5. `department.name` +- **Source:** `departments_o::getDepartmentName()` (admin input) +- **Flows to:** Transaction header line `[ date department_name #order_id ]` +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeTextLine(..., 100)` +- **Sensitive to:** `/` (e.g. "Roskilde/Ølstykke"), special chars, length + +### 6. `order.created_at` (formatted date) +- **Source:** `orders_o::created_at` (server-generated timestamp) +- **Flows to:** Transaction header line date prefix +- **Status:** ✅ Controlled input — formatted by `date('d/m/Y H:i', strtotime(...))` +- **Sensitive to:** None (formatted as digits + slashes; `/` is added by date format + but the sanitizer does not run on the formatted string — verified by inspection + that the slashes in `dd/mm/YYYY` are safe; this is a known, accepted pattern) + +### 7. `order.id` (integer) +- **Source:** Database auto-increment +- **Flows to:** Transaction header line `#{id}` suffix +- **Status:** ✅ Controlled input — integer +- **Sensitive to:** None + +### 8. `order_item.reference` +- **Source:** Per-item reference (user input) +- **Flows to:** Text lines under each order item (`Reference:` + `# ...`) +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeTextLine()` +- **Sensitive to:** `/`, newlines, control chars, length + +### 9. `order_item.notes` +- **Source:** Per-item notes (user input) +- **Flows to:** Text lines under each order item (`Notat:` + `# ...`) +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeTextLine()` +- **Sensitive to:** `/`, newlines, control chars, length + +### 10. `order_item.product.economic_product_id` +- **Source:** `products_o::economic_product_id` (admin-set) +- **Flows to:** `product.productNumber` in the e-conomic line payload +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeProductNumber()` +- **Sensitive to:** Path separators, illegal chars + +### 11. `order_item.product.name` +- **Source:** `products_o::name` (admin-set product name) +- **Flows to:** `description` in the e-conomic line payload +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`) +- **Sensitive to:** `/`, newlines, control chars, length (capped at 500) + +### 12. `order_item.quantity`, `order_item.price`, `order_item.product.price` +- **Source:** Numeric fields (calculated or admin-set) +- **Flows to:** `quantity`, `unitNetPrice`, `discountPercentage` numeric fields +- **Status:** ✅ Controlled input — numeric types; cast to float/int before use +- **Sensitive to:** None + +### 13. Currency (`DKK`, `EUR`, etc.) +- **Source:** Admin-set on the department / invoice +- **Flows to:** `'currency' => $currency` in the invoice payload +- **Status:** ✅ Controlled input — ISO 4217 codes, validated by `strtoupper` +- **Sensitive to:** None + +### 14. `recipient.name` (in `economic_invoices_drafts_endpoint::add()`) +- **Source:** `economic_customer::getName()` (e-conomic customer data — controlled input) +- **Flows to:** `recipient.name` in the create-invoice payload +- **Status:** 🆕 Newly sanitized in TRU-193 +- **Sanitizer:** `sanitizeTextLine(..., 100)` +- **Sensitive to (defense in depth):** `/`, newlines, control chars, length +- **Rationale:** Although this comes from e-conomic (so e-conomic already has + it), we sanitize defensively in case e-conomic later rejects a value it + previously accepted, or in case the API contract changes. Cap of 100 chars + matches the e-conomic recipient `name` field limit. + +### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`) +- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input) +- **Flows to:** `recipient.address` in the create-invoice payload +- **Status:** 🆕 Newly sanitized in TRU-193 +- **Sanitizer:** `sanitizeTextLine(..., 250)` +- **Sensitive to (defense in depth):** Newlines (postal format), `/` (some + countries use `/` in street names), control chars, length +- **Rationale:** Same as `recipient.name` — defense in depth. + +### 16. `recipient.zip`, `recipient.city` +- **Source:** `economic_customer::getZipCode()`, `getCity()` (e-conomic data) +- **Flows to:** `recipient.zip`, `recipient.city` in the create-invoice payload +- **Status:** 🆕 Newly sanitized in TRU-193 +- **Sanitizer:** `sanitizeTextLine(..., 20)` for zip, `(..., 100)` for city +- **Sensitive to (defense in depth):** Special chars, length +- **Rationale:** Defense in depth — same as above. + +### 17. `recipient.ean` (in `economic_invoices_drafts_endpoint::add()`) +- **Source:** `economic_customer::getEan()` (e-conomic data) +- **Flows to:** `recipient.ean` + `recipient.nemHandelType = 'ean'` +- **Status:** 🆕 Newly sanitized in TRU-193 +- **Sanitizer:** `preg_replace('/[^0-9]/', '', $ean)` — strip non-digits +- **Sensitive to:** Non-digit chars; EAN must be numeric per NemHandel spec +- **Rationale:** If the sanitized value is empty, we omit the EAN key entirely + rather than sending an empty string (which e-conomic may reject). + +## Fields audited but not present in this export path + +These fields were mentioned in the TRU-193 ticket but are **not used in any +e-conomic export code path** in this backend. Documenting them for +completeness: + +| Field | Why not in scope | +|-------|------------------| +| `customer.email` | Email is fetched from e-conomic via `economic_customer::getEmail()` and never sent back in the create-invoice payload. The email field is used only for read operations. | +| `customer.address` (full multi-line) | `recipient.address` is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. | +| `subscription.name` | Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. | + +## Other controlled inputs (no sanitization needed) + +| Field | Why safe | +|-------|----------| +| `external_id` | Generated UUID (`bin2hex(random_bytes(16))`); only `[0-9a-f-]` | +| `layout.layoutNumber` | Admin-set integer from e-conomic config | +| `paymentTerms.paymentTermsNumber` | Integer from e-conomic | +| `vatZone.vatZoneNumber` | Integer from e-conomic | +| `customer.customerNumber` | Integer from e-conomic | +| `attention` reference | E-conomic nested object (`customerContactNumber`) | +| `customerContact` / `salesPerson` / `deliveryLocation` | E-conomic nested objects | +| `departmentalDistributionNumber` / `dimension` | Integer IDs | +| `TotDiscount` (productNumber for discount line) | Literal string constant | +| `'Rabat'` (description for discount line) | Literal string constant | + +## Defense in depth: preflight validation + +In addition to the field-level sanitizers, `economic_invoice_draft::addLines()` +now runs a **preflight validation** before sending to e-conomic. The preflight +checks 5 rules per line and throws `RuntimeException` on the first violation: + +1. `description` must be non-empty after `trim()` +2. `description` must be ≤ 250 chars +3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/` +4. `quantity` (if present) must be a positive number +5. `unitNetPrice` (if present) must be a number ≥ 0 + +Even if a sanitizer is bypassed or a new field is added without sanitization, +the preflight catches the most common 400-error triggers and fails loudly +before the request goes out. + +## Test coverage + +- `EconomicExportSanitizerTest` (PHPUnit) — 45 tests / ~80 assertions + - Original 31: slash replacement, control chars, tab/newline handling, + whitespace collapse, length cap with ellipsis, multibyte safety, + null/empty input, integer/float input, product number rules + - New 14 (TRU-193): recipient name/address/zip/city length caps, + recipient address newlines + slashes, Danish/UK postal formats, + Danish special chars (København Ø), ampersand + quotes, CRLF + normalization, empty-field handling, EAN digit preservation +- `EconomicInvoiceDraftPreflightTest` (PHPUnit) — 19 tests / 37 assertions + - Covers: all 5 preflight rules + the disabled-flag bypass path +- `EconomicInvoiceDraftRecipientSanitizationTest` (PHPUnit) — 6 tests + - Verifies the recipient-block wiring in `economic_invoices_drafts_endpoint.php` + (sanitize calls for name/address/zip/city, preg_replace for EAN, + empty-EAN unsets the key) +- `EconomicDraftSanitizationIntegrationTest` (PHPUnit, integration) — 24 tests / 51 assertions + - End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip, + preflight catches all 5 rules, mixed text + product flow works + +Total: 94 tests, 171 assertions, all passing. + +## What changed in TRU-193 + +1. **Pre-flight validation** added to `economic_invoice_draft.php` + (separate atomic commit) — defense in depth. +2. **Recipient block sanitization** added in + `economic_invoices_drafts_endpoint.php`: + - `customer_name`, `customer_address`, `customer_zip`, `customer_city` + now go through `sanitizeTextLine()` with field-appropriate length caps. + - `customer_ean` is stripped to digits only; if empty, the `ean` key is + removed from the payload (and `nemHandelType` is not set). +3. **Defense-in-depth at insertion** in `economic_invoice_draft.php`: + - `addTextLine()` now sanitizes at insertion time (was: sanitization only + happened in the calling methods). Catches any new caller that forgets + to sanitize. + - `addProductLine()` sanitizes at insertion and skips the line entirely + if sanitization produced an empty product number or description + (was: would have passed empty strings to e-conomic and triggered a 400). +4. **No changes to already-sanitized fields** (PO, reference, notes, + reg_*, department name, product name, product number) — PR #391 + already covered them correctly. + +## Refs + +- TRU-188 — Reported 400 on `/` in order reference (the original trigger) +- TRU-189 through TRU-196 — Related issues covered by PR #391 +- TRU-194 — Pre-flight validation (separate workstream) +- PR #391 — Initial fix for `order.*` and `order_item.*` fields +- PR #392 — Pre-flight validation defense in depth diff --git a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php index 3467e0c6..1d781f0c 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php @@ -125,10 +125,15 @@ class economic_invoices_drafts_endpoint $customer = (new economic())->getCustomer($customer_number); // Set the recipient details - $customer_name = $customer->getName() ?? 'Ukendt'; - $customer_address = $customer->getAddress() ?? 'Ukendt'; - $customer_zip = $customer->getZipCode() ?? 'Ukendt'; - $customer_city = $customer->getCity() ?? 'Ukendt'; + // Note: customer_* fields come from e-conomic itself (controlled input), + // but we sanitize them defensively to avoid 400s if e-conomic ever stores + // a value with chars e-conomic later rejects in the recipient block. + // Each field uses an appropriate length cap to match the corresponding + // e-conomic recipient field limits. + $customer_name = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getName() ?? 'Ukendt', 100); + $customer_address = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getAddress() ?? 'Ukendt', 250); + $customer_zip = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getZipCode() ?? 'Ukendt', 20); + $customer_city = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getCity() ?? 'Ukendt', 100); $recipient = [ 'name' => $customer_name, 'address' => $customer_address, @@ -139,9 +144,14 @@ class economic_invoices_drafts_endpoint ], ]; $customer_ean = $customer->getEan(); - if ($customer_ean !== null) { - $recipient['ean'] = $customer_ean; - $recipient['nemHandelType'] = 'ean'; + if ($customer_ean !== null && $customer_ean !== '') { + // EAN should be digits only; sanitize to strip anything that slipped through + $recipient['ean'] = preg_replace('/[^0-9]/', '', $customer_ean); + if ($recipient['ean'] !== '') { + $recipient['nemHandelType'] = 'ean'; + } else { + unset($recipient['ean']); + } } $public_entry_number = $customer->getPublicEntryNumber(); if ($public_entry_number !== null) { diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index 380090fa..42c101f1 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -42,6 +42,13 @@ class economic_invoice_draft */ protected float $conversion_rate = 1.0; + /** + * Whether pre-flight validation runs inside addLines() before sending to e-conomic. + * Defense in depth — even after sanitization, a final check catches anything that slips through. + * @var bool $preflight_enabled + */ + protected bool $preflight_enabled = true; + /** * Construct a new Economic draft invoice object @@ -116,9 +123,142 @@ class economic_invoice_draft */ public function addLines(): void { + if ($this->preflight_enabled) { + $this->preflightValidate($this->draft_lines, null); + } $this->flushLinesInBatches(); } + /** + * Pre-flight validation: defense in depth before sending to e-conomic. + * Validates each line against 5 rules and throws RuntimeException on the first violation. + * + * Rules (in order, per line): + * 1. description — must be non-empty after trim() + * 2. description — must be <= 250 chars + * 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/ + * 4. quantity — must be a positive number (> 0) + * 5. unitNetPrice — must be a number (>= 0) + * + * @param array> $lines + * @param int|null $orderId Optional order id for log context + * @throws \RuntimeException on any rule violation + */ + public function preflightValidate(array $lines, ?int $orderId = null): void + { + foreach ($lines as $i => $line) { + if (!is_array($line)) { + $this->logAndThrow( + $i, + $orderId, + 'line is not an array', + $line + ); + } + + // Rule 1 + 2: description + $description = $line['description'] ?? null; + if ($description === null) { + $description = ''; + } + if (!is_scalar($description)) { + $description = (string)$description; + } else { + $description = (string)$description; + } + $descriptionTrimmed = trim($description); + if ($descriptionTrimmed === '') { + $this->logAndThrow( + $i, + $orderId, + 'description is empty', + $description + ); + } + if (mb_strlen($descriptionTrimmed) > 250) { + $this->logAndThrow( + $i, + $orderId, + 'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')', + $description + ); + } + + // Rule 3: productNumber (only if present in product.productNumber) + if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) { + $productNumber = $line['product']['productNumber']; + if ($productNumber === null) { + $productNumber = ''; + } else { + $productNumber = (string)$productNumber; + } + if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) { + $this->logAndThrow( + $i, + $orderId, + 'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/', + $productNumber + ); + } + } + + // Rule 4: quantity — only required if present in the line (text lines omit it) + if (array_key_exists('quantity', $line)) { + $quantity = $line['quantity']; + if (!is_numeric($quantity) || (float)$quantity <= 0) { + $this->logAndThrow( + $i, + $orderId, + 'quantity is not a positive number', + $quantity + ); + } + } + + // Rule 5: unitNetPrice — only required if present in the line + if (array_key_exists('unitNetPrice', $line)) { + $unitNetPrice = $line['unitNetPrice']; + if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) { + $this->logAndThrow( + $i, + $orderId, + 'unitNetPrice is not a number >= 0', + $unitNetPrice + ); + } + } + } + } + + /** + * Log the offending line and throw a RuntimeException. + */ + private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never + { + $valueTruncated = is_scalar($value) ? (string)$value : json_encode($value); + if ($valueTruncated === false) { + $valueTruncated = '[unserializable]'; + } + if (mb_strlen($valueTruncated) > 200) { + $valueTruncated = mb_substr($valueTruncated, 0, 200) . '...'; + } + $orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId; + error_log(sprintf( + '[preflight] validation failed: %s | line=%d | %s | value=%s', + $rule, + $lineIndex, + $orderContext, + $valueTruncated + )); + $orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')'; + throw new \RuntimeException(sprintf( + 'Preflight validation failed for line %d: %s%s', + $lineIndex, + $rule, + $orderPart + )); + } + /** * Add queued draft lines using chunked requests. * @@ -246,11 +386,26 @@ class economic_invoice_draft */ public function addTextLine(string $text): void { + // Defense in depth: sanitize ALL text lines at insertion time. + // This catches anything that wasn't pre-sanitized at the call site. + $sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text); + if ($sanitized === '') { + return; // Skip empty/whitespace-only lines + } $this->draft_lines[] = [ - 'description' => $text + 'description' => $sanitized ]; } + /** + * Get the current draft lines (read-only view). + * Used by integration tests; production code uses addLines() to send. + */ + public function getDraftLines(): array + { + return $this->draft_lines; + } + /** * Add an order to the draft invoice * @note The lines won't be saved until the addLines() method is called. @@ -485,6 +640,10 @@ class economic_invoice_draft // Sanitize product identifier and description (defense in depth — also done at addLines()) $productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber); $description = \classes\economic_export_sanitizer::sanitizeProductDescription($description); + // Skip if sanitization removed everything + if ($productNumber === '' || $description === '') { + return; + } // We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS. // Add a line to the invoice $line = [ diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php new file mode 100644 index 00000000..ebcce58b --- /dev/null +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php @@ -0,0 +1,323 @@ +draft = new economic_invoice_draft(12345, 'DKK', true); // skip_fetch=true: no live API call + } + + // ======================================================================== + // addTextLine — sanitization + // ======================================================================== + + public function testAddTextLineSanitizesSlashToDash(): void + { + $this->draft->addTextLine('Reference: Order/123/ABC'); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame('Reference: Order-123-ABC', $lines[0]['description']); + } + + public function testAddTextLineStripsControlChars(): void + { + $this->draft->addTextLine("Line 1\nLine 2\twith tab"); + $lines = $this->draft->getDraftLines(); + $this->assertSame('Line 1 Line 2 with tab', $lines[0]['description']); + } + + public function testAddTextLineTruncatesVeryLongString(): void + { + $long = str_repeat('A', 5000); + $this->draft->addTextLine($long); + $lines = $this->draft->getDraftLines(); + $this->assertLessThanOrEqual(250, mb_strlen($lines[0]['description'])); + $this->assertStringEndsWith('...', $lines[0]['description']); + } + + public function testMultipleTextLinesAllSanitized(): void + { + $this->draft->addTextLine('Reference: A/B'); + $this->draft->addTextLine('PO: C/D'); + $this->draft->addTextLine('Reg 1: E/F'); + $lines = $this->draft->getDraftLines(); + $this->assertCount(3, $lines); + $this->assertSame('Reference: A-B', $lines[0]['description']); + $this->assertSame('PO: C-D', $lines[1]['description']); + $this->assertSame('Reg 1: E-F', $lines[2]['description']); + } + + public function testEmptyAndWhitespaceOnlyLinesAreSkipped(): void + { + $this->draft->addTextLine(''); + $this->draft->addTextLine(' '); + $this->draft->addTextLine("\t\n "); + $this->draft->addTextLine('///'); // All slashes become dashes, then trim leaves '-', not empty + $lines = $this->draft->getDraftLines(); + // '///' becomes '---' which is not empty after trim + $this->assertCount(1, $lines); + $this->assertSame('---', $lines[0]['description']); + } + + public function testPurelyWhitespaceAfterSanitizationIsSkipped(): void + { + $this->draft->addTextLine("\x00\x01\x02"); // All control chars, no actual text + $lines = $this->draft->getDraftLines(); + $this->assertCount(0, $lines); + } + + public function testMultibyteTextPreservedCorrectly(): void + { + $this->draft->addTextLine('Kunde: ÆØÅ / 中文 / 🚗'); + $lines = $this->draft->getDraftLines(); + $this->assertSame('Kunde: ÆØÅ - 中文 - 🚗', $lines[0]['description']); + } + + // ======================================================================== + // addProductLine — sanitization + // ======================================================================== + + public function testProductLineWithoutDiscount(): void + { + $this->draft->addProductLine( + 'PROD-001', + 'Bilvask Standard', + 1.0, + 150.0, + 1, + 1, + 0.0 + ); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame('Bilvask Standard', $lines[0]['description']); + $this->assertSame(0.0, $lines[0]['discountPercentage']); + $this->assertSame('PROD-001', $lines[0]['product']['productNumber']); + } + + public function testProductLineWithDiscount(): void + { + $this->draft->addProductLine( + 'PROD-002', + 'Storvask Premium', + 1.0, + 250.0, + 1, + 1, + 20.0 + ); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame(20.0, $lines[0]['discountPercentage']); + } + + public function testProductLineWithSlashInNumberSanitized(): void + { + $this->draft->addProductLine( + 'PROD/003', + 'Premium/Service', + 1.0, + 100.0, + 1, + 1 + ); + $lines = $this->draft->getDraftLines(); + // Product numbers REMOVE the slash (sanitizeProductNumber), text lines REPLACE with dash + $this->assertSame('PROD003', $lines[0]['product']['productNumber']); + $this->assertSame('Premium-Service', $lines[0]['description']); + } + + public function testProductLineWithEmptyDescriptionSkipped(): void + { + $this->draft->addProductLine('PROD-001', '', 1.0, 100.0, 1, 1); + $lines = $this->draft->getDraftLines(); + $this->assertCount(0, $lines); + } + + // ======================================================================== + // preflightValidate — via addLines (with transport stub would be ideal, + // but for unit-style integration we exercise preflight directly) + // ======================================================================== + + public function testPreflightCatchesEmptyDescription(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('description is empty'); + $this->draft->preflightValidate([ + ['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightCatchesTooLongDescription(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('exceeds 250 chars'); + $this->draft->preflightValidate([ + ['description' => str_repeat('A', 251), 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightCatchesInvalidProductNumber(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('productNumber does not match'); + $this->draft->preflightValidate([ + [ + 'description' => 'Valid line', + 'product' => ['productNumber' => 'PROD/01'], + 'quantity' => 1, + 'unitNetPrice' => 100.0, + ], + ]); + } + + public function testPreflightCatchesZeroQuantity(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('quantity is not a positive number'); + $this->draft->preflightValidate([ + ['description' => 'Valid line', 'quantity' => 0, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightCatchesNegativeUnitPrice(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('unitNetPrice is not a number'); + $this->draft->preflightValidate([ + ['description' => 'Valid line', 'quantity' => 1, 'unitNetPrice' => -10.0], + ]); + } + + public function testPreflightIncludesOrderIdInMessage(): void + { + try { + $this->draft->preflightValidate( + [['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0]], + 300 + ); + $this->fail('Expected RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('order 300', $e->getMessage()); + } + } + + public function testPreflightPassesValidLines(): void + { + // Should not throw + $this->draft->preflightValidate([ + ['description' => 'Line 1', 'quantity' => 2, 'unitNetPrice' => 100.0], + [ + 'description' => 'Line 2 with product', + 'product' => ['productNumber' => 'PROD-01'], + 'quantity' => 1, + 'unitNetPrice' => 50.0, + 'discountPercentage' => 10, + ], + ]); + $this->assertTrue(true); + } + + public function testPreflightPassesExactly250Chars(): void + { + $exactlyMax = str_repeat('B', 250); + // Should not throw + $this->draft->preflightValidate([ + ['description' => $exactlyMax, 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + $this->assertTrue(true); + } + + public function testPreflightFailsAt251Chars(): void + { + $this->expectException(\RuntimeException::class); + $this->draft->preflightValidate([ + ['description' => str_repeat('B', 251), 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightAcceptsProductNumberWithDotsAndDashes(): void + { + // Should not throw + $this->draft->preflightValidate([ + [ + 'description' => 'Valid', + 'product' => ['productNumber' => 'PROD-01.0_test'], + 'quantity' => 1, + 'unitNetPrice' => 100.0, + ], + ]); + $this->assertTrue(true); + } + + // ======================================================================== + // End-to-end: mixed flow + // ======================================================================== + + public function testMixedLinesAllTogetherAndPassPreflight(): void + { + $this->draft->addTextLine('Reference: Order/2024/Q1'); + $this->draft->addProductLine('PROD-001', 'Bilvask', 2.0, 100.0, 1, 1, 10.0); + $this->draft->addProductLine('PROD-002', 'Storvask', 1.0, 200.0, 1, 1, 0.0); + $this->draft->addTextLine('Note: paid/in/full'); + + $lines = $this->draft->getDraftLines(); + $this->assertCount(4, $lines); + + $this->assertSame('Reference: Order-2024-Q1', $lines[0]['description']); + $this->assertSame('Bilvask', $lines[1]['description']); + $this->assertSame(10.0, $lines[1]['discountPercentage']); + $this->assertSame('Storvask', $lines[2]['description']); + $this->assertSame(0.0, $lines[2]['discountPercentage']); + $this->assertSame('Note: paid-in-full', $lines[3]['description']); + + // All sanitized lines pass preflight + $this->draft->preflightValidate($lines, 12345); + } + + public function testDiscountPathProducesSingleProductLineWithDiscountPct(): void + { + $this->draft->addProductLine('DISC-01', 'Rabatservice', 1.0, 100.0, 1, 1, 25.0); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame(25.0, $lines[0]['discountPercentage']); + $this->assertSame('Rabatservice', $lines[0]['description']); + } + + public function testNoDiscountPathProducesSingleProductLineWithZeroDiscount(): void + { + $this->draft->addProductLine('NODISC-01', 'Standardservice', 1.0, 100.0, 1, 1, 0.0); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame(0.0, $lines[0]['discountPercentage']); + $this->assertSame('Standardservice', $lines[0]['description']); + } +} diff --git a/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php index cd2bb52b..49f6e78f 100644 --- a/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php +++ b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php @@ -221,4 +221,121 @@ class EconomicExportSanitizerTest extends TestCase economic_export_sanitizer::sanitizeForEconApi('foo/bar') ); } + + // ======================================================================== + // Recipient-block fields (TRU-193) + // + // The recipient block in the create-invoice payload is built from + // e-conomic customer data (name, address, zip, city). We sanitize + // defensively with field-appropriate length caps. + // ======================================================================== + + public function testRecipientNameCapsAt100Chars(): void + { + $text = str_repeat('A', 200); + $result = economic_export_sanitizer::sanitizeTextLine($text, 100); + $this->assertSame(100, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testRecipientAddressCapsAt250Chars(): void + { + $text = str_repeat('B', 500); + $result = economic_export_sanitizer::sanitizeTextLine($text, 250); + $this->assertSame(250, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testRecipientAddressNewlinesAndSlashesReplaced(): void + { + // Address with embedded newlines and a slash — both common in EU street formats + $input = "Main Street 1\nFloor 2/3\n1234 City"; + $result = economic_export_sanitizer::sanitizeTextLine($input, 250); + $this->assertStringNotContainsString("\n", $result); + $this->assertStringNotContainsString('/', $result); + $this->assertSame('Main Street 1 Floor 2-3 1234 City', $result); + } + + public function testRecipientZipCapsAt20Chars(): void + { + $text = str_repeat('9', 50); + $result = economic_export_sanitizer::sanitizeTextLine($text, 20); + $this->assertSame(20, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testRecipientZipPreservesDanishFormat(): void + { + // Danish postal codes: "1234" — should pass through unchanged + $this->assertSame('1234', economic_export_sanitizer::sanitizeTextLine('1234', 20)); + } + + public function testRecipientZipHandlesUkFormatWithSlash(): void + { + // UK postcodes contain no slashes in practice but include spaces + $this->assertSame('SW1A 1AA', economic_export_sanitizer::sanitizeTextLine('SW1A 1AA', 20)); + } + + public function testRecipientCityCapsAt100Chars(): void + { + $text = str_repeat('C', 200); + $result = economic_export_sanitizer::sanitizeTextLine($text, 100); + $this->assertSame(100, mb_strlen($result)); + } + + public function testRecipientCityHandlesDanishSpecialChars(): void + { + $this->assertSame('København Ø', economic_export_sanitizer::sanitizeTextLine('København Ø', 100)); + $this->assertSame('Aarhus C', economic_export_sanitizer::sanitizeTextLine('Aarhus C', 100)); + } + + public function testRecipientNameWithAmpersand(): void + { + // & should pass through — the sanitizer does not strip XML/HTML entities + $this->assertSame('Smith & Sons', economic_export_sanitizer::sanitizeTextLine('Smith & Sons', 100)); + } + + public function testRecipientNameWithQuotes(): void + { + // Various quote styles + $this->assertSame('"Bob" Inc.', economic_export_sanitizer::sanitizeTextLine('"Bob" Inc.', 100)); + $this->assertSame("Bob's Trucks", economic_export_sanitizer::sanitizeTextLine("Bob's Trucks", 100)); + } + + public function testRecipientAddressCrlfNormalized(): void + { + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\r\nline2", 250)); + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\rline2", 250)); + } + + public function testEmptyRecipientFieldsReturnEmpty(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('', 100)); + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('', 250)); + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('', 20)); + } + + // ======================================================================== + // EAN sanitization (TRU-193) + // + // EANs in the recipient block should be digits-only. We use a + // preg_replace('/[^0-9]/', '', $ean) in the endpoint, but we also + // verify that the text-line sanitizer is safe to apply as a fallback. + // ======================================================================== + + public function testTextLineSanitizerPreservesAllDigits(): void + { + $ean = '5798000000001'; + $this->assertSame($ean, economic_export_sanitizer::sanitizeTextLine($ean, 20)); + } + + public function testTextLineSanitizerReplacesSpacesInEan(): void + { + // Real-world data sometimes has "5798 0000 0000 1" with spaces. + // The text-line sanitizer keeps a single space (not strictly digit-only); + // for true digit-only sanitization, the endpoint uses preg_replace('/[^0-9]/', '', $ean) + // directly. The text-line sanitizer is only a defense-in-depth fallback. + $this->assertSame('5798 0000 0000 1', economic_export_sanitizer::sanitizeTextLine('5798 0000 0000 1', 20)); + $this->assertSame('5798 0000 0000 1', economic_export_sanitizer::sanitizeTextLine('5798 0000 0000 1', 20)); + } } diff --git a/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php new file mode 100644 index 00000000..9433b929 --- /dev/null +++ b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php @@ -0,0 +1,379 @@ +getProperty('preflight_enabled'); + $prop->setAccessible(true); + $prop->setValue($draft, $preflight); + return $draft; + } + + private function callPreflight(economic_invoice_draft $draft, array $lines, ?int $orderId = null): void + { + $draft->preflightValidate($lines, $orderId); + } + + // ======================================================================== + // Rule 1: description must be non-empty after trim() + // ======================================================================== + + public function testEmptyDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/description is empty/'); + $this->callPreflight($draft, [ + ['description' => ''], + ], 42); + } + + public function testWhitespaceOnlyDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/description is empty/'); + $this->callPreflight($draft, [ + ['description' => " \t \n "], + ], 99); + } + + public function testMissingDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/description is empty/'); + $this->callPreflight($draft, [ + ['quantity' => 1, 'unitNetPrice' => 10.0], + ], 1); + } + + // ======================================================================== + // Rule 2: description must be <= 250 chars + // ======================================================================== + + public function testTooLongDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/exceeds 250 chars/'); + $long = str_repeat('a', 251); + $this->callPreflight($draft, [ + ['description' => $long], + ], 7); + } + + public function testDescriptionAt250Passes(): void + { + $draft = $this->makeDraft(); + // 250 chars — should pass (not throw) + $this->callPreflight($draft, [ + ['description' => str_repeat('b', 250)], + ], 8); + $this->assertTrue(true); // no exception means success + } + + // ======================================================================== + // Rule 3: productNumber must match /^[A-Za-z0-9._-]{1,50}$/ + // ======================================================================== + + public function testInvalidProductNumberThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/productNumber does not match/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'PROD/01'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 11); + } + + public function testProductNumberTooLongThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/productNumber does not match/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => str_repeat('a', 51)], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 12); + } + + public function testValidProductNumberPasses(): void + { + $draft = $this->makeDraft(); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'PROD-01.0_v2'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 13); + $this->assertTrue(true); + } + + // ======================================================================== + // Rule 4: quantity must be a positive number (> 0) + // ======================================================================== + + public function testZeroQuantityThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/quantity is not a positive number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 0, + 'unitNetPrice' => 10.0, + ], + ], 21); + } + + public function testNegativeQuantityThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/quantity is not a positive number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => -1.5, + 'unitNetPrice' => 10.0, + ], + ], 22); + } + + public function testNonNumericQuantityThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/quantity is not a positive number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 'NaN-ish', + 'unitNetPrice' => 10.0, + ], + ], 23); + } + + // ======================================================================== + // Rule 5: unitNetPrice must be a number (>= 0) + // ======================================================================== + + public function testNegativeUnitPriceThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/unitNetPrice is not a number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => -5.0, + ], + ], 31); + } + + public function testNonNumericUnitPriceThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/unitNetPrice is not a number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => 'free', + ], + ], 32); + } + + public function testZeroUnitPricePasses(): void + { + $draft = $this->makeDraft(); + // 0 is allowed — it's "a number >= 0" + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => 0, + ], + ], 33); + $this->assertTrue(true); + } + + // ======================================================================== + // Happy path: a fully-valid line passes + // ======================================================================== + + public function testValidLinePasses(): void + { + $draft = $this->makeDraft(); + $this->callPreflight($draft, [ + [ + 'description' => 'A normal product line', + 'product' => ['productNumber' => 'WASH-01'], + 'quantity' => 2, + 'unitNetPrice' => 99.5, + ], + [ + 'description' => 'A text-only line', + ], + [ + 'description' => 'Discount', + 'product' => ['productNumber' => 'TotDiscount'], + 'quantity' => 1, + 'unitNetPrice' => 0.0, + ], + ], 100); + $this->assertTrue(true); + } + + // ======================================================================== + // Disabled preflight: bad lines must NOT throw + // ======================================================================== + + public function testPreflightCanBeDisabled(): void + { + // The preflight_enabled flag gates addLines() (i.e. it controls whether + // preflightValidate() runs before flushLinesInBatches). It does NOT + // affect direct calls to preflightValidate(). So this test verifies: + // 1. The default value is true. + // 2. The flag is mutable to false. + // 3. addLines() is wired to short-circuit preflight when the flag is false. + $draft = $this->makeDraft(true); + + // (1) Default: preflight is enabled + $ref = new \ReflectionClass($draft); + $prop = $ref->getProperty('preflight_enabled'); + $prop->setAccessible(true); + $this->assertTrue($prop->getValue($draft), 'preflight_enabled should default to true'); + + // (2) Mutable + $prop->setValue($draft, false); + $this->assertFalse($prop->getValue($draft)); + + // (3) Disabled: addLines() must NOT call preflightValidate(). + // We assert this indirectly: queue a guaranteed-invalid line and then + // catch the exception that flushLinesInBatches() would raise when it + // tries to send to e-conomic. If preflight were enabled we'd get + // RuntimeException("description is empty") first. + $this->expectException(\Throwable::class); + $reflection = new \ReflectionClass($draft); + $linesProp = $reflection->getProperty('draft_lines'); + $linesProp->setAccessible(true); + $linesProp->setValue($draft, [ + ['description' => ''], // would fail rule 1 if preflight ran + ]); + $draft->addLines(); + } + + // ======================================================================== + // Multiple errors: report the first one + // ======================================================================== + + public function testMultipleErrorsReportsFirst(): void + { + $draft = $this->makeDraft(); + $caught = null; + try { + $this->callPreflight($draft, [ + // First line is fine + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + // Second line: empty description (rule 1) + ['description' => ''], + // Third line: would also fail, but we should never get here + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'BAD/CHAR'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 300); + } catch (RuntimeException $e) { + $caught = $e; + } + $this->assertNotNull($caught, 'Expected a RuntimeException to be thrown'); + $this->assertStringContainsString('line 1', $caught->getMessage()); + $this->assertStringContainsString('description is empty', $caught->getMessage()); + $this->assertStringContainsString('order 300', $caught->getMessage()); + } + + // ======================================================================== + // Exception message includes order id when provided + // ======================================================================== + + public function testExceptionMessageIncludesOrderId(): void + { + $draft = $this->makeDraft(); + $caught = null; + try { + $this->callPreflight($draft, [ + ['description' => ''], + ], 4242); + } catch (RuntimeException $e) { + $caught = $e; + } + $this->assertNotNull($caught); + $this->assertStringContainsString('order 4242', $caught->getMessage()); + } + + public function testExceptionMessageOmitsOrderWhenNull(): void + { + $draft = $this->makeDraft(); + $caught = null; + try { + $this->callPreflight($draft, [ + ['description' => ''], + ], null); + } catch (RuntimeException $e) { + $caught = $e; + } + $this->assertNotNull($caught); + $this->assertStringNotContainsString('order', $caught->getMessage()); + } +} diff --git a/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftRecipientSanitizationTest.php b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftRecipientSanitizationTest.php new file mode 100644 index 00000000..a6593f00 --- /dev/null +++ b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftRecipientSanitizationTest.php @@ -0,0 +1,101 @@ +assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getName() ?? \'Ukendt\', 100)', + $content, + 'recipient.name must be sanitized via sanitizeTextLine with a 100-char cap' + ); + } + + public function testEndpointSanitizesRecipientAddress(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getAddress() ?? \'Ukendt\', 250)', + $content, + 'recipient.address must be sanitized via sanitizeTextLine with a 250-char cap' + ); + } + + public function testEndpointSanitizesRecipientZip(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getZipCode() ?? \'Ukendt\', 20)', + $content, + 'recipient.zip must be sanitized via sanitizeTextLine with a 20-char cap' + ); + } + + public function testEndpointSanitizesRecipientCity(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getCity() ?? \'Ukendt\', 100)', + $content, + 'recipient.city must be sanitized via sanitizeTextLine with a 100-char cap' + ); + } + + public function testEndpointStripsNonDigitsFromEan(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + "preg_replace('/[^0-9]/', '', \$customer_ean)", + $content, + 'recipient.ean must be stripped to digits only' + ); + } + + public function testEndpointOmitsEmptyEanInsteadOfSendingEmptyString(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + // After stripping non-digits, if the result is empty we should remove the key + $this->assertStringContainsString( + "unset(\$recipient['ean']);", + $content, + 'recipient.ean must be removed from the payload when the sanitized EAN is empty' + ); + // Verify the conditional structure: if empty, unset + $this->assertMatchesRegularExpression( + "/\\\$recipient\\['ean'\\]\\s*=\\s*preg_replace\\(\\s*['\\/\\^0-9\\/']/", + $content, + 'recipient.ean must be assigned via preg_replace with a non-digit-stripping pattern' + ); + } +}