## Problem E-conomic API returns HTTP 400 when text-line descriptions contain certain characters. The most common case is `/` in the order reference field, which causes the entire draft-invoice export to fail. ## Root cause When `order.reference` (or notes, reg_*, po) contains `/`, e-conomic's text-line validation rejects the entire draft with HTTP 400. Same for control characters and very long strings. ## Fix Adds `economic_export_sanitizer` class that sanitizes all user-input fields flowing into e-conomic: - `/` → `-` (the reported 400 trigger) - Control chars stripped (\x00-\x1F except \t and \n) - Tab and newline → single space - Whitespace normalized and trimmed - Lengths capped (text 250, product 50, description 500) with `...` suffix - Multibyte safe (æ, ø, å, emoji, Chinese) ## Applied to In `economic_invoice_draft.php`: - `order.po` - `order.reference` (PRIMARY FIX for the reported issue) - `order.notes` - `order.reg_1/2/3` - `order_item.reference` - `order_item.notes` - `product.description` - `product.productNumber` - `department_name` ## Test coverage - 31 unit tests with 45 assertions - All edge cases (null, empty, control chars, multibyte, very long, HTML, control chars in every position) - Lint and test suite both pass ## Linear Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196 Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
225 lines
8.7 KiB
PHP
225 lines
8.7 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')
|
|
);
|
|
}
|
|
}
|