## 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>
342 lines
14 KiB
PHP
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));
|
|
}
|
|
}
|