assertTrue(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp)); $this->assertFalse(self::call('looksLikeXlsx', 'workbook.txt', $tmp), 'wrong extension'); } finally { unlink($tmp); } } public function testLooksLikeXlsxRejectsNonZip(): void { $tmp = tempnam(sys_get_temp_dir(), 'sptest'); file_put_contents($tmp, 'not a zip'); try { $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp)); } finally { unlink($tmp); } } public function testLooksLikeXlsxRejectsTooShortFile(): void { $tmp = tempnam(sys_get_temp_dir(), 'sptest'); file_put_contents($tmp, 'PK'); try { $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp)); } finally { unlink($tmp); } } public function testUploadErrorCodeMapping(): void { $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_INI_SIZE)); $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_FORM_SIZE)); $this->assertSame('partial', self::call('uploadErrorCode', UPLOAD_ERR_PARTIAL)); $this->assertSame('no_file', self::call('uploadErrorCode', UPLOAD_ERR_NO_FILE)); $this->assertSame('server', self::call('uploadErrorCode', UPLOAD_ERR_NO_TMP_DIR)); $this->assertSame('server', self::call('uploadErrorCode', UPLOAD_ERR_CANT_WRITE)); $this->assertSame('unknown', self::call('uploadErrorCode', 9999)); } // --------------------------------------------------------------------- // R01-N14: per-token serialised-payload cap + abandoned-token audit // --------------------------------------------------------------------- public function testEncodedPayloadBytesIsZeroForEmpty(): void { $this->assertSame(2, ImportController::encodedPayloadBytes([]), 'JSON for [] is two bytes'); } public function testEncodedPayloadBytesGrowsWithContent(): void { $small = ImportController::encodedPayloadBytes([['sheetName' => 'A', 'tasks' => []]]); $big = ImportController::encodedPayloadBytes([ ['sheetName' => 'A', 'tasks' => array_fill(0, 100, ['title' => str_repeat('x', 50)])], ]); $this->assertGreaterThan($small, $big); } public function testEncodedPayloadBytesMatchesJsonStrlen(): void { // The cap is "JSON byte length"; the helper must agree with what // a manual json_encode() of the same payload reports — otherwise // the contract documented in REVIEW_01.md drifts. $sheetsArr = [ ['sheetName' => 'Sprint A', 'tasks' => [['title' => 'Älphå', 'days' => 1.5]]], ['sheetName' => 'Sprint B', 'tasks' => []], ]; $expected = strlen((string) json_encode( $sheetsArr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, )); $this->assertSame($expected, ImportController::encodedPayloadBytes($sheetsArr)); } public function testCapConstantMatchesReviewN14(): void { // Pin the cap at exactly 2 MiB. The number is documented in // REVIEW_01.md and the upload-error message; if it changes, both // pieces of documentation need to be edited too. $this->assertSame( 2 * 1024 * 1024, ImportController::MAX_SESSION_PAYLOAD_BYTES, 'cap drift: bumping this constant requires updating REVIEW_01.md + import_upload.twig', ); } public function testEncodedPayloadCanCrossTheCap(): void { // Sanity: a deliberately-large payload exceeds the cap. We feed // the helper a payload just past the threshold to prove the // arithmetic agrees in the direction the controller relies on. $blob = str_repeat('A', ImportController::MAX_SESSION_PAYLOAD_BYTES); $sheets = [['sheetName' => 'big', 'data' => $blob]]; $this->assertGreaterThan( ImportController::MAX_SESSION_PAYLOAD_BYTES, ImportController::encodedPayloadBytes($sheets), ); } public function testAbandonedAuditPayloadShapeAndContents(): void { $entry = [ 'created_at' => 1_700_000_000, // 2023-11-14T22:13:20Z 'file_name' => 'planning.xlsx', 'payload_bytes' => 1234, 'sheets' => [ ['sheetName' => 'A'], ['sheetName' => 'B'], ['sheetName' => 'C'], ], ]; $now = 1_700_000_300; // 5 minutes later $payload = ImportController::abandonedAuditPayload($entry, $now); $this->assertSame('planning.xlsx', $payload['file_name']); $this->assertSame(3, $payload['sheet_count']); $this->assertSame(1234, $payload['payload_bytes']); $this->assertSame(300, $payload['age_seconds']); $this->assertSame('2023-11-14T22:13:20Z', $payload['created_at']); } public function testAbandonedAuditPayloadHandlesMissingFields(): void { // Old or malformed session entries (e.g. from a session created // before the payload_bytes field landed): the helper must not // throw and must fall back to safe defaults rather than leaking // PHP nulls into an audit row. $payload = ImportController::abandonedAuditPayload([], 1_700_000_000); $this->assertSame('', $payload['file_name']); $this->assertSame(0, $payload['sheet_count']); $this->assertSame(0, $payload['payload_bytes']); $this->assertSame(0, $payload['age_seconds'], 'created_at=0 → age clamped to 0, not negative'); $this->assertSame('', $payload['created_at'], 'no spoofed timestamp when created_at is missing'); } public function testAbandonedAuditAgeNeverNegative(): void { // Defensive: clock skew or a session entry from "the future" // (e.g. a system clock that just rolled back) must not produce // a negative age — the audit row would render badly in /audit. $payload = ImportController::abandonedAuditPayload( ['created_at' => 2_000_000_000, 'sheets' => []], 1_999_999_900, ); $this->assertSame(0, $payload['age_seconds']); } /** * Reflectively call a private static helper on ImportController so we * don't need to expand its public surface for testability. */ private static function call(string $method, mixed ...$args): mixed { $r = new ReflectionMethod(ImportController::class, $method); $r->setAccessible(true); return $r->invoke(null, ...$args); } }