1
0

ImportControllerTest.php 7.6 KB

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