1
0

ImportControllerTest.php 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Controllers;
  4. use App\Controllers\ImportController;
  5. use App\Tests\TestCase;
  6. use ReflectionMethod;
  7. /**
  8. * Phase 20 — pure-static guards on ImportController. Mirrors the
  9. * UserControllerTest pattern: exercise the bits that do not need PDO or
  10. * session wiring.
  11. */
  12. final class ImportControllerTest extends TestCase
  13. {
  14. public function testLooksLikeXlsxAcceptsRealZipHeader(): void
  15. {
  16. $tmp = tempnam(sys_get_temp_dir(), 'sptest');
  17. file_put_contents($tmp, "PK\x03\x04rest of the file");
  18. try {
  19. $this->assertTrue(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
  20. $this->assertFalse(self::call('looksLikeXlsx', 'workbook.txt', $tmp), 'wrong extension');
  21. } finally {
  22. unlink($tmp);
  23. }
  24. }
  25. public function testLooksLikeXlsxRejectsNonZip(): void
  26. {
  27. $tmp = tempnam(sys_get_temp_dir(), 'sptest');
  28. file_put_contents($tmp, 'not a zip');
  29. try {
  30. $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
  31. } finally {
  32. unlink($tmp);
  33. }
  34. }
  35. public function testLooksLikeXlsxRejectsTooShortFile(): void
  36. {
  37. $tmp = tempnam(sys_get_temp_dir(), 'sptest');
  38. file_put_contents($tmp, 'PK');
  39. try {
  40. $this->assertFalse(self::call('looksLikeXlsx', 'workbook.xlsx', $tmp));
  41. } finally {
  42. unlink($tmp);
  43. }
  44. }
  45. public function testUploadErrorCodeMapping(): void
  46. {
  47. $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_INI_SIZE));
  48. $this->assertSame('too_big', self::call('uploadErrorCode', UPLOAD_ERR_FORM_SIZE));
  49. $this->assertSame('partial', self::call('uploadErrorCode', UPLOAD_ERR_PARTIAL));
  50. $this->assertSame('no_file', self::call('uploadErrorCode', UPLOAD_ERR_NO_FILE));
  51. $this->assertSame('server', self::call('uploadErrorCode', UPLOAD_ERR_NO_TMP_DIR));
  52. $this->assertSame('server', self::call('uploadErrorCode', UPLOAD_ERR_CANT_WRITE));
  53. $this->assertSame('unknown', self::call('uploadErrorCode', 9999));
  54. }
  55. // ---------------------------------------------------------------------
  56. // R01-N14: per-token serialised-payload cap + abandoned-token audit
  57. // ---------------------------------------------------------------------
  58. public function testEncodedPayloadBytesIsZeroForEmpty(): void
  59. {
  60. $this->assertSame(2, ImportController::encodedPayloadBytes([]), 'JSON for [] is two bytes');
  61. }
  62. public function testEncodedPayloadBytesGrowsWithContent(): void
  63. {
  64. $small = ImportController::encodedPayloadBytes([['sheetName' => 'A', 'tasks' => []]]);
  65. $big = ImportController::encodedPayloadBytes([
  66. ['sheetName' => 'A', 'tasks' => array_fill(0, 100, ['title' => str_repeat('x', 50)])],
  67. ]);
  68. $this->assertGreaterThan($small, $big);
  69. }
  70. public function testEncodedPayloadBytesMatchesJsonStrlen(): void
  71. {
  72. // The cap is "JSON byte length"; the helper must agree with what
  73. // a manual json_encode() of the same payload reports — otherwise
  74. // the contract documented in REVIEW_01.md drifts.
  75. $sheetsArr = [
  76. ['sheetName' => 'Sprint A', 'tasks' => [['title' => 'Älphå', 'days' => 1.5]]],
  77. ['sheetName' => 'Sprint B', 'tasks' => []],
  78. ];
  79. $expected = strlen((string) json_encode(
  80. $sheetsArr,
  81. JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES,
  82. ));
  83. $this->assertSame($expected, ImportController::encodedPayloadBytes($sheetsArr));
  84. }
  85. public function testCapConstantMatchesReviewN14(): void
  86. {
  87. // Pin the cap at exactly 2 MiB. The number is documented in
  88. // REVIEW_01.md and the upload-error message; if it changes, both
  89. // pieces of documentation need to be edited too.
  90. $this->assertSame(
  91. 2 * 1024 * 1024,
  92. ImportController::MAX_SESSION_PAYLOAD_BYTES,
  93. 'cap drift: bumping this constant requires updating REVIEW_01.md + import_upload.twig',
  94. );
  95. }
  96. public function testEncodedPayloadCanCrossTheCap(): void
  97. {
  98. // Sanity: a deliberately-large payload exceeds the cap. We feed
  99. // the helper a payload just past the threshold to prove the
  100. // arithmetic agrees in the direction the controller relies on.
  101. $blob = str_repeat('A', ImportController::MAX_SESSION_PAYLOAD_BYTES);
  102. $sheets = [['sheetName' => 'big', 'data' => $blob]];
  103. $this->assertGreaterThan(
  104. ImportController::MAX_SESSION_PAYLOAD_BYTES,
  105. ImportController::encodedPayloadBytes($sheets),
  106. );
  107. }
  108. public function testAbandonedAuditPayloadShapeAndContents(): void
  109. {
  110. $entry = [
  111. 'created_at' => 1_700_000_000, // 2023-11-14T22:13:20Z
  112. 'file_name' => 'planning.xlsx',
  113. 'payload_bytes' => 1234,
  114. 'sheets' => [
  115. ['sheetName' => 'A'],
  116. ['sheetName' => 'B'],
  117. ['sheetName' => 'C'],
  118. ],
  119. ];
  120. $now = 1_700_000_300; // 5 minutes later
  121. $payload = ImportController::abandonedAuditPayload($entry, $now);
  122. $this->assertSame('planning.xlsx', $payload['file_name']);
  123. $this->assertSame(3, $payload['sheet_count']);
  124. $this->assertSame(1234, $payload['payload_bytes']);
  125. $this->assertSame(300, $payload['age_seconds']);
  126. $this->assertSame('2023-11-14T22:13:20Z', $payload['created_at']);
  127. }
  128. public function testAbandonedAuditPayloadHandlesMissingFields(): void
  129. {
  130. // Old or malformed session entries (e.g. from a session created
  131. // before the payload_bytes field landed): the helper must not
  132. // throw and must fall back to safe defaults rather than leaking
  133. // PHP nulls into an audit row.
  134. $payload = ImportController::abandonedAuditPayload([], 1_700_000_000);
  135. $this->assertSame('', $payload['file_name']);
  136. $this->assertSame(0, $payload['sheet_count']);
  137. $this->assertSame(0, $payload['payload_bytes']);
  138. $this->assertSame(0, $payload['age_seconds'], 'created_at=0 → age clamped to 0, not negative');
  139. $this->assertSame('', $payload['created_at'], 'no spoofed timestamp when created_at is missing');
  140. }
  141. public function testAbandonedAuditAgeNeverNegative(): void
  142. {
  143. // Defensive: clock skew or a session entry from "the future"
  144. // (e.g. a system clock that just rolled back) must not produce
  145. // a negative age — the audit row would render badly in /audit.
  146. $payload = ImportController::abandonedAuditPayload(
  147. ['created_at' => 2_000_000_000, 'sheets' => []],
  148. 1_999_999_900,
  149. );
  150. $this->assertSame(0, $payload['age_seconds']);
  151. }
  152. /**
  153. * Reflectively call a private static helper on ImportController so we
  154. * don't need to expand its public surface for testability.
  155. */
  156. private static function call(string $method, mixed ...$args): mixed
  157. {
  158. $r = new ReflectionMethod(ImportController::class, $method);
  159. $r->setAccessible(true);
  160. return $r->invoke(null, ...$args);
  161. }
  162. }