Files
api/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php
T
ea9bdbe12c fix(economic): audit and sanitize additional export fields (TRU-193) (#393)
## Summary

Audit and (where needed) fix additional fields in the e-conomic export
path. PR #391 covered the main order.* and order_item.* fields; this PR
covers the remaining fields that could carry special characters.

## Changes

1. Pre-flight validation (defense in depth): 5 rules per line throw on
violation.
2. addTextLine() and addProductLine() now sanitize at insertion (defense
in depth).
3. Recipient block sanitization in add(): name/address/zip/city via
sanitizeTextLine, EAN via preg_replace.
4. Audit document: documentation/economic/export-field-audit.md.
5. Tests: 94 tests / 171 assertions (14 + 19 + 6 + 24 new tests).

## Refs
- TRU-193, TRU-188, TRU-194, PR #391

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
Co-authored-by: Bugfix Subagent <bugfix@subagent.local>
2026-08-17 12:52:04 +02:00

342 lines
14 KiB
PHP

<?php
namespace tests\Unit\Economic;
use classes\economic_export_sanitizer;
use PHPUnit\Framework\TestCase;
require_once __DIR__ . '/../../../classes/economic_export_sanitizer.php';
class EconomicExportSanitizerTest extends TestCase
{
// ========================================================================
// sanitizeTextLine
// ========================================================================
public function testSlashIsReplacedWithDash(): void
{
$this->assertSame('ABC-123-XYZ', economic_export_sanitizer::sanitizeTextLine('ABC/123/XYZ'));
$this->assertSame('Order 1 - 2 - 3', economic_export_sanitizer::sanitizeTextLine('Order 1 / 2 / 3'));
$this->assertSame('-leading and trailing-', economic_export_sanitizer::sanitizeTextLine('/leading and trailing/'));
}
public function testControlCharactersAreStripped(): void
{
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x00lo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x01lo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x1Flo"));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x7F\x7Flo"));
}
public function testTabIsReplacedWithSpace(): void
{
$this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine("a\tb\tc"));
}
public function testNewlinesCollapsedToSpace(): void
{
$this->assertSame('line1 line2 line3', economic_export_sanitizer::sanitizeTextLine("line1\nline2\nline3"));
$this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\r\nline2"));
$this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\n\n\nline2"));
}
public function testMultipleSpacesCollapsed(): void
{
$this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine('a b c'));
}
public function testTrimsLeadingAndTrailingWhitespace(): void
{
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine(' hello '));
$this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("\n\thello\n\t"));
}
public function testTruncatesAtMaxLengthWithEllipsis(): void
{
$text = str_repeat('a', 300);
$result = economic_export_sanitizer::sanitizeTextLine($text, 250);
$this->assertSame(250, mb_strlen($result));
$this->assertStringEndsWith('...', $result);
}
public function testTruncatesAtMaxLengthWithoutEllipsisWhenTooShort(): void
{
// When maxLength is 3, no room for ellipsis
$text = str_repeat('a', 100);
$result = economic_export_sanitizer::sanitizeTextLine($text, 3);
$this->assertSame(3, mb_strlen($result));
$this->assertSame('aaa', $result);
}
public function testDoesNotTruncateWhenShorterThanMaxLength(): void
{
$this->assertSame('short text', economic_export_sanitizer::sanitizeTextLine('short text', 250));
}
public function testNullReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(null));
}
public function testEmptyReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(''));
}
public function testWhitespaceOnlyReturnsEmptyString(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeTextLine(" \t\n "));
}
public function testWhitespaceOnlyWithSlashesReturnsEmptyString(): void
{
// After all transformations, "///" becomes "---"
// After trim of whitespace-only, " / " becomes "" (since / is replaced but space was there)
// Actually let's see: " / " -> " " stays; " - " -> "-"; then trim -> "-"
// So it doesn't become empty in this case. Let me re-test:
$result = economic_export_sanitizer::sanitizeTextLine(' / ');
$this->assertSame('-', $result);
}
public function testHandlesMultibyteChars(): void
{
$this->assertSame('æøå', economic_export_sanitizer::sanitizeTextLine('æøå'));
$this->assertSame('中文', economic_export_sanitizer::sanitizeTextLine('中文'));
$this->assertSame('🚗 car', economic_export_sanitizer::sanitizeTextLine('🚗 car'));
}
public function testTruncationRespectsMultibyteBoundaries(): void
{
$text = str_repeat('æ', 300);
$result = economic_export_sanitizer::sanitizeTextLine($text, 10);
$this->assertSame(10, mb_strlen($result));
$this->assertStringEndsWith('...', $result);
}
public function testHtmlTagsAreNotStripped(): void
{
// We don't strip HTML — that's a different concern (XSS). We just sanitize for e-conomic.
// The "/" in </b> gets replaced with "-" (per the rules).
$this->assertSame('<b>notags<-b>', economic_export_sanitizer::sanitizeTextLine('<b>notags</b>'));
}
public function testSlashesInTheMiddleOfValueAreReplaced(): void
{
$this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeTextLine('foo/bar/baz'));
}
public function testMultipleProblemCharsCombined(): void
{
$input = "AB/\nC\t\rD\x00E ";
$result = economic_export_sanitizer::sanitizeTextLine($input);
// After: strip control -> "AB/\nC\tDE ", tab->space -> "AB/\nC DE ",
// newline->space -> "AB/ C DE ", slash->dash -> "AB- C DE ",
// collapse spaces -> "AB- C DE ", trim -> "AB- C DE"
$this->assertSame('AB- C DE', $result);
}
public function testIntegerIsConvertedToString(): void
{
$this->assertSame('42', economic_export_sanitizer::sanitizeTextLine(42));
}
public function testFloatIsConvertedToString(): void
{
$this->assertSame('3.14', economic_export_sanitizer::sanitizeTextLine(3.14));
}
// ========================================================================
// sanitizeProductNumber
// ========================================================================
public function testProductNumberRemovesPathSeparators(): void
{
$this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC/DEF'));
$this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC\\DEF'));
}
public function testProductNumberRemovesForbiddenChars(): void
{
$input = "PROD:01?*<>|\"";
$result = economic_export_sanitizer::sanitizeProductNumber($input);
$this->assertSame('PROD01', $result);
}
public function testProductNumberTruncatesAt50Chars(): void
{
$text = str_repeat('a', 100);
$result = economic_export_sanitizer::sanitizeProductNumber($text);
$this->assertSame(50, mb_strlen($result));
}
public function testProductNumberTrimsWhitespace(): void
{
$this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber(' PROD01 '));
}
public function testProductNumberStripsControlChars(): void
{
$this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber("PROD\x0001"));
}
public function testProductNumberNullReturnsEmpty(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeProductNumber(null));
}
public function testProductNumberAllForbiddenReturnsEmpty(): void
{
$this->assertSame('', economic_export_sanitizer::sanitizeProductNumber('///\\\\::'));
}
public function testProductNumberKeepsDotsAndDashes(): void
{
$this->assertSame('PROD-01.0', economic_export_sanitizer::sanitizeProductNumber('PROD-01.0'));
}
// ========================================================================
// sanitizeProductDescription
// ========================================================================
public function testProductDescriptionTruncatesAt500(): void
{
$text = str_repeat('a', 1000);
$result = economic_export_sanitizer::sanitizeProductDescription($text);
$this->assertSame(500, mb_strlen($result));
}
public function testProductDescriptionReplacesSlashes(): void
{
$this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeProductDescription('foo/bar/baz'));
}
// ========================================================================
// sanitizeForEconApi
// ========================================================================
public function testSanitizeForEconApiIsAliasForTextLine(): void
{
$this->assertSame(
economic_export_sanitizer::sanitizeTextLine('foo/bar'),
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));
}
}