| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Controllers;
- use App\Controllers\ImportController;
- use App\Tests\TestCase;
- use ReflectionMethod;
- /**
- * Phase 20 — pure-static guards on ImportController. Mirrors the
- * UserControllerTest pattern: exercise the bits that do not need PDO or
- * session wiring.
- */
- final class ImportControllerTest extends TestCase
- {
- public function testLooksLikeXlsxAcceptsRealZipHeader(): void
- {
- $tmp = tempnam(sys_get_temp_dir(), 'sptest');
- file_put_contents($tmp, "PK\x03\x04rest of the file");
- try {
- $this->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);
- }
- }
|