diff --git a/publish/post.lib.php b/publish/post.lib.php index afc0e33..4d9b2c9 100644 --- a/publish/post.lib.php +++ b/publish/post.lib.php @@ -157,8 +157,11 @@ function get_mimetype($image_file) { function save_uploaded_file() { // A new upload in this request: PHP removes the file after the request - if (is_uploaded_file($_FILES['upload']['tmp_name'] ?? '')) { - return $_FILES['upload']['tmp_name']; + $upload_tmp = $_FILES['upload']['tmp_name'] ?? ''; + if (!is_string($upload_tmp)) + return FALSE; + if (is_uploaded_file($upload_tmp)) { + return $upload_tmp; } // An image uploaded at the preview step; the session knows which one if (($_POST['uploaded'] ?? '') === 'pending') { @@ -188,6 +191,7 @@ function validate_data() { extract($result[0], EXTR_OVERWRITE); $upload_error = $_FILES['upload']['error'] ?? UPLOAD_ERR_NO_FILE; $upload_tmp = $_FILES['upload']['tmp_name'] ?? ''; + $upload_name = $_FILES['upload']['name'] ?? ''; // Visitors must solve the captcha before a preview or a save if (!$is_admin && !captcha_check()) { @@ -206,22 +210,32 @@ function validate_data() { $validate['story'] = "You've tried to remove an image without replacing it with text."; } + if (!is_array($_FILES['upload'] ?? array()) || !is_int($upload_error) || + !is_string($upload_tmp) || !is_string($upload_name)) { + $validate['upload'] = 'The upload is invalid. Please choose the image again.'; + return TRUE; + } + if ($upload_error === UPLOAD_ERR_NO_FILE) + return TRUE; + $upload_max = upload_max_bytes(); - $too_large = htmlspecialchars((string)($_FILES['upload']['name'] ?? '')) . + $too_large = htmlspecialchars($upload_name) . ($upload_max === NULL ? ' is larger than the server accepts.' : ' is larger than '.upload_max_mb().' MB.') . ' Please choose a smaller image.'; // 0 == no error; 4 == no file; all others are errors. if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) { $validate['upload'] = $too_large; - } else if ($upload_error > 0 && - $upload_error !== 4) { + } else if ($upload_error !== UPLOAD_ERR_OK) { $validate['upload'] = 'There was an error uploading the file ('.$upload_error.')'; - } else if ($upload_tmp && $upload_max !== NULL && (int)@filesize($upload_tmp) > $upload_max) { + } else if ($upload_tmp === '' || !is_file($upload_tmp) || !is_readable($upload_tmp)) { + $validate['upload'] = 'The uploaded file is unavailable. Please choose the image again.'; + } else if ($upload_max !== NULL && (int)@filesize($upload_tmp) > $upload_max) { $validate['upload'] = $too_large; - } else if ($upload_tmp) { - $upload = getimagesize($upload_tmp); + } else { + // The file can disappear after the check above, or contain incomplete image data. + $upload = @getimagesize($upload_tmp); if (!$upload) - $validate['upload'] = htmlspecialchars($_FILES['upload']['name']) . + $validate['upload'] = htmlspecialchars($upload_name) . " wasn't an image file. Please go back."; } return TRUE; diff --git a/publish/preview.inc.php b/publish/preview.inc.php index 385f19a..6070e65 100644 --- a/publish/preview.inc.php +++ b/publish/preview.inc.php @@ -7,8 +7,8 @@ echo '
Please review your submission. Once you\'ve finished, please click "Sub // Keep a new image outside the web root until the story is saved; // display.php shows it to this session only. A request that failed the // captcha or the form token keeps nothing. -if (is_uploaded_file($_FILES['upload']['tmp_name'] ?? '') && - !isset($validate['upload']) && !isset($validate['captcha']) && !isset($validate['csrf'])) { +if (!isset($validate['upload']) && !isset($validate['captcha']) && !isset($validate['csrf']) && + is_string($_FILES['upload']['tmp_name'] ?? '') && is_uploaded_file($_FILES['upload']['tmp_name'] ?? '')) { if (store_upload($_FILES['upload']['tmp_name'])) $file_src = pending_upload_url(); else diff --git a/tests/js/editor.test.js b/tests/js/editor.test.js index 9c5fab3..a4e22eb 100644 --- a/tests/js/editor.test.js +++ b/tests/js/editor.test.js @@ -297,6 +297,18 @@ test("the source button swaps the editor for the textarea and back", () => { assert.equal(env.textarea.value, "
edited
"); }); +test("submitting in source mode sends the source edits", (t) => { + const env = editorWindow("Old story
"); + t.after(() => env.window.close()); + button(env.document, "Source code").click(); + const source = "Source edit
\nSecond paragraph.
"; + env.textarea.value = source; + env.textarea.form.dispatchEvent(new env.window.Event("submit", { cancelable: true })); + assert.equal(env.textarea.value, source); + assert.equal(env.editor.getContent(), source); + assert.equal(new env.window.FormData(env.textarea.form).get("story"), source); +}); + test("setContent leaves source mode", () => { const env = editorWindow("a
"); button(env.document, "Source code").click(); @@ -327,6 +339,111 @@ test("undo keeps a run of typing as one step", () => { assert.equal(env.textarea.value, "a
"); }); +for (const pause of [999, 1000, 1001]) { + test(`undo groups typing only when the pause is below one second (${pause} ms)`, (t) => { + const env = editorWindow("a
"); + t.after(() => env.window.close()); + let now = 10000; + t.mock.method(env.window.Date, "now", () => now); + type(env, "ab
"); + now += pause; + type(env, "abc
"); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, pause < 1000 ? "a
" : "ab
"); + key(env.window, env.box, "y", { ctrlKey: true }); + assert.equal(env.textarea.value, "abc
"); + }); +} + +test("a change of input type starts a separate undo step", (t) => { + const env = editorWindow("a
"); + t.after(() => env.window.close()); + t.mock.method(env.window.Date, "now", () => 10000); + type(env, "abc
", "insertText"); + type(env, "ab
", "deleteContentBackward"); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, "abc
"); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, "a
"); +}); + +test("a new edit after undo clears the old redo history", (t) => { + const env = editorWindow("a
"); + t.after(() => env.window.close()); + let now = 10000; + t.mock.method(env.window.Date, "now", () => now); + type(env, "ab
"); + now += 1000; + type(env, "abc
"); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, "ab
"); + type(env, "abX
"); + key(env.window, env.box, "y", { ctrlKey: true }); + assert.equal(env.textarea.value, "abX
"); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, "ab
"); + key(env.window, env.box, "y", { ctrlKey: true }); + assert.equal(env.textarea.value, "abX
"); +}); + +test("undo keeps the latest 100 steps and redo restores them", (t) => { + const env = editorWindow("0
"); + t.after(() => env.window.close()); + let now = 10000; + t.mock.method(env.window.Date, "now", () => now); + for (let i = 1; i <= 105; i++) { + now += 1000; + type(env, `${i}
`); + } + for (let i = 104; i >= 5; i--) { + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, `${i}
`); + } + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, "5
", "the oldest five steps are discarded"); + for (let i = 6; i <= 105; i++) { + key(env.window, env.box, "y", { ctrlKey: true }); + assert.equal(env.textarea.value, `${i}
`); + } + key(env.window, env.box, "y", { ctrlKey: true }); + assert.equal(env.textarea.value, "105
"); +}); + +test("undo restores a selection across nodes and redo restores the caret", (t) => { + const original = "alphabeta
"; + const env = editorWindow(original); + t.after(() => env.window.close()); + select(env.window, env.box.querySelector("p").firstChild, 2, env.box.querySelector("em").firstChild, 3); + type(env, "altered
"); + select(env.window, env.box.querySelector("p").firstChild, 4); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, original); + let range = env.window.getSelection().getRangeAt(0); + assert.equal(range.startContainer, env.box.querySelector("p").firstChild); + assert.equal(range.startOffset, 2); + assert.equal(range.endContainer, env.box.querySelector("em").firstChild); + assert.equal(range.endOffset, 3); + assert.equal(range.toString(), "phabet"); + key(env.window, env.box, "y", { ctrlKey: true }); + assert.equal(env.textarea.value, "altered
"); + range = env.window.getSelection().getRangeAt(0); + assert.equal(range.startContainer, env.box.querySelector("p").firstChild); + assert.equal(range.startOffset, 4); + assert.equal(range.collapsed, true); +}); + +test("redo from the browser's history input", (t) => { + const env = editorWindow("a
"); + t.after(() => env.window.close()); + type(env, "b
"); + key(env.window, env.box, "z", { ctrlKey: true }); + assert.equal(env.textarea.value, "a
"); + const event = new env.window.InputEvent("beforeinput", { inputType: "historyRedo", bubbles: true, cancelable: true }); + env.box.dispatchEvent(event); + assert.ok(event.defaultPrevented); + assert.equal(env.textarea.value, "b
"); +}); + test("undo from the browser's history input", () => { const env = editorWindow("a
"); type(env, "b
"); diff --git a/tests/js/utilities.test.js b/tests/js/utilities.test.js index dd12833..47f4012 100644 --- a/tests/js/utilities.test.js +++ b/tests/js/utilities.test.js @@ -139,6 +139,48 @@ test("review stops an empty story and warns about the length", () => { assert.deepEqual(w.confirms, []); }); +const imageReviews = [ + { name: "a new upload with no text", upload: true }, + { name: "a new upload with short text", upload: true, story: "short text" }, + { name: "a retained image with no text", uploaded: "5" }, + { name: "a retained image with short text", uploaded: "5", story: "short text" }, + { name: "a pending image with no text", uploaded: "pending" }, + { name: "a pending image with short text", uploaded: "pending", story: "short text" }, + { name: "an image removed without text", uploaded: "5", remove: true, alert: true, accepted: false }, + { name: "a removed image and cancelled short text", uploaded: "5", remove: true, story: "short text", confirm: true, accepted: false }, + { name: "a removed image and accepted short text", uploaded: "5", remove: true, story: "short text", confirm: true }, + { name: "an image replaced with 100 words", uploaded: "5", remove: true, story: "word ".repeat(100) }, + { name: "an empty image reference and no text", uploaded: "", alert: true, accepted: false }, + { name: "a new upload replacing a removed image", upload: true, uploaded: "5", remove: true }, +]; + +for (const entry of imageReviews) { + test(`review handles ${entry.name}`, (t) => { + const w = publishWindow(entry.story || ""); + t.after(() => w.close()); + for (const [id, value] of [["title", "T"], ["author", "A"], ["email", "a@example.org"]]) + w.document.getElementById(id).value = value; + if (entry.upload) { + // jsdom cannot choose a file. review() reads only the input's value. + Object.defineProperty(w.document.getElementById("upload"), "value", { value: "C:\\fakepath\\a.png" }); + } + if (entry.uploaded !== undefined) { + const uploaded = w.document.createElement("input"); + uploaded.type = "hidden"; + uploaded.id = "uploaded"; + uploaded.value = entry.uploaded; + w.document.getElementById("form").appendChild(uploaded); + } + if (entry.remove) w.toggle_remove(); + w.confirmAnswer = entry.accepted !== false; + assert.equal(w.review(), entry.accepted !== false); + assert.equal(w.alerts.length, entry.alert ? 1 : 0); + assert.equal(w.confirms.length, entry.confirm ? 1 : 0); + if (entry.alert) assert.match(w.alerts[0], /haven't submitted anything/); + if (entry.confirm) assert.match(w.confirms[0], /doesn't appear to be 100 words long/); + }); +} + test("delete asks first, and then skips the review", () => { const w = publishWindow(""); w.confirmAnswer = false; diff --git a/tests/php/Pages/SubmitTest.php b/tests/php/Pages/SubmitTest.php index d3b0c2a..99e5810 100644 --- a/tests/php/Pages/SubmitTest.php +++ b/tests/php/Pages/SubmitTest.php @@ -181,6 +181,24 @@ class SubmitTest extends PageTestCase { $this->assertNotSame($first, $second); } + #[DataProvider('uploadActions')] + public function testANestedUploadIsRejectedWithoutSaving(string $action): void { + $token = $this->csrfToken($this->get('/publish')['body']); + $before = $this->tables(); + $page = $this->postFiles('/publish', $this->story(array('action' => $action, 'csrf_token' => $token, + 'g-recaptcha-response' => 'pass')), + array('upload[]' => array('a.png', TestCase::png()))); + $this->assertSame(200, $page['status']); + $this->assertStringContainsString('The upload is invalid. Please choose the image again.', $page['body']); + $this->assertSame($before, $this->tables()); + $this->assertSame(array(), $this->uploads()); + $this->assertArrayNotHasKey('pending_upload', $this->session()); + } + + public static function uploadActions(): array { + return array('preview' => array('preview'), 'submit' => array('submit')); + } + public function testAFailedCaptchaKeepsNoImage(): void { $page = $this->previewImage(array('g-recaptcha-response' => 'fail')); $this->assertStringContainsString('Please complete the captcha.', $page['body']); diff --git a/tests/php/Unit/SaveDataTest.php b/tests/php/Unit/SaveDataTest.php index d689d82..cefad78 100644 --- a/tests/php/Unit/SaveDataTest.php +++ b/tests/php/Unit/SaveDataTest.php @@ -1,6 +1,7 @@ assertContains("O'Brien\"; DROP TABLE xxx; --", $insert['params']); } + public static function failedQueries(): array { + return array( + 'visitor insert' => array(FALSE, 'post', array(), array(), '/^INSERT INTO queue/'), + 'editor insert' => array(TRUE, 'submit', array(), array(), '/^INSERT INTO queue/'), + 'queue update' => array(TRUE, 'submit', array('id' => '5'), array(), '/^UPDATE queue/'), + 'live update' => array(TRUE, 'post', array('id' => '1'), array(), '/^UPDATE xxx/'), + 'move from queue' => array(TRUE, 'post', array('id' => '5'), array('queue' => '1'), '/^INSERT INTO xxx/'), + 'queue image lookup' => array(TRUE, 'post', array('id' => '5', 'uploaded' => '5'), array('queue' => '1'), '/^SELECT img, mime FROM queue/'), + ); + } + + #[DataProvider('failedQueries')] + public function testAFailedQueryKeepsTheStoryAndUploadWithoutSendingMail(bool $admin, string $action, array $post, array $get, string $pattern): void { + $pending = $this->pendingImage(); + $session = $_SESSION + array('captcha_ok' => time()); + $state = $this->tmp . '/failed-save'; + file_put_contents($state, serialize(array( + 'tables' => self::TABLES, 'admin' => $admin, 'action' => $action, 'pattern' => $pattern, + 'post' => $post + array('title' => 'Title', 'author' => 'Ann', 'story' => 'Text.
', 'uploaded' => 'pending'), + 'get' => $get, 'session' => $session, + ))); + $file = var_export($state, TRUE); + $code = <<Sorry, something went wrong. Please try again later.
', $result['stdout']); + $this->assertStringContainsString('xxx: database error at post.lib.php:', $result['log']); + $this->assertStringNotContainsString('Warning', $result['log']); + $this->assertStringNotContainsString('Notice', $result['log']); + $this->assertSame(array(), $result['mails']); + $saved = unserialize(file_get_contents($state)); + $this->assertFalse($saved['returned']); + $this->assertSame(500, $saved['status']); + $this->assertSame($saved['before'], $saved['tables']); + $this->assertSame($session, $saved['session']); + $this->assertFileExists($pending); + $this->assertSame(self::png(), file_get_contents($pending)); + $this->assertCount(1, preg_grep($pattern, $saved['queries'])); + $this->assertMatchesRegularExpression($pattern, end($saved['queries']), 'no queries run after the failure'); + } + // Visitors always add to the queue, and each story sends mail public function testVisitorsCanOnlyAddToTheQueue(): void { $state = $this->tmp . '/state'; diff --git a/tests/php/Unit/SessionTest.php b/tests/php/Unit/SessionTest.php index 2483640..4729878 100644 --- a/tests/php/Unit/SessionTest.php +++ b/tests/php/Unit/SessionTest.php @@ -140,6 +140,47 @@ class SessionTest extends TestCase { $this->assertNotSame(persistent_key_hash('key'), $rows[0]['session'], 'the used key is gone'); } + public function testAUsedPersistentCookieCannotBeReplayedAndOtherDevicesStillWork(): void { + SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => '', 'email' => ''); + foreach (array('first-device', 'second-device') as $key) { + SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash($key), + 'created' => gmdate('Y-m-d H:i:s')); + } + session_start(); + $_COOKIE['PersistentAuth'] = 'ed|first-device'; + $this->assertTrue(check_persistent_login()); + $this->assertSame('ed', $_SESSION['editor']); + $rotated = SiteData::$tables['persistent_sessions']; + $this->assertCount(2, $rotated); + $this->assertNotContains(persistent_key_hash('first-device'), array_column($rotated, 'session')); + $this->assertContains(persistent_key_hash('second-device'), array_column($rotated, 'session')); + + // Replay the old cookie without the authenticated PHP session. + session_destroy(); + $_SESSION = array(); + session_id(bin2hex(random_bytes(16))); + session_start(); + $_COOKIE['PersistentAuth'] = 'ed|first-device'; + $this->assertFalse(check_persistent_login()); + $this->assertArrayNotHasKey('editor', $_SESSION); + $this->assertSame($rotated, SiteData::$tables['persistent_sessions']); + + session_destroy(); + $_SESSION = array(); + session_id(bin2hex(random_bytes(16))); + session_start(); + $_COOKIE['PersistentAuth'] = 'ed|second-device'; + $this->assertTrue(check_persistent_login()); + $this->assertSame('ed', $_SESSION['editor']); + $hashes = array_column(SiteData::$tables['persistent_sessions'], 'session'); + $this->assertCount(2, $hashes); + $this->assertNotContains(persistent_key_hash('second-device'), $hashes); + foreach ($rotated as $row) { + if ($row['session'] !== persistent_key_hash('second-device')) + $this->assertContains($row['session'], $hashes, 'the first device keeps its rotated token'); + } + } + public function testCheckPersistentLoginRefusesAnExpiredKey(): void { SiteData::$tables['editors'][] = array('username' => 'ed', 'password' => '', 'email' => ''); SiteData::$tables['persistent_sessions'][] = array('username' => 'ed', 'session' => persistent_key_hash('key'), diff --git a/tests/php/Unit/UploadTest.php b/tests/php/Unit/UploadTest.php index e8196d6..ea98824 100644 --- a/tests/php/Unit/UploadTest.php +++ b/tests/php/Unit/UploadTest.php @@ -1,6 +1,7 @@ assertFalse(save_uploaded_file()); } + public static function arrayTemporaryNames(): array { + return array( + 'empty array' => array(array()), + 'file list' => array(array('/tmp/not-an-upload')), + ); + } + + #[DataProvider('arrayTemporaryNames')] + public function testAnArrayTemporaryNameIsRefused(array $name): void { + $pending = $this->pending(); + $_FILES['upload'] = array('tmp_name' => $name, 'name' => array('a.png'), 'error' => array(UPLOAD_ERR_OK)); + $this->assertFalse(save_uploaded_file()); + $_POST['uploaded'] = 'pending'; + $this->assertFalse(save_uploaded_file(), 'an invalid new upload does not select the pending image'); + $this->assertFileExists($pending); + $this->assertSame(basename($pending), $_SESSION['pending_upload']); + } + + public static function missingTemporaryNames(): array { + return array( + 'absent entry' => array(array()), + 'absent name' => array(array('upload' => array('error' => UPLOAD_ERR_NO_FILE))), + 'empty name' => array(array('upload' => array('tmp_name' => ''))), + ); + } + + #[DataProvider('missingTemporaryNames')] + public function testAMissingTemporaryNameLeavesThePendingUploadAlone(array $files): void { + $pending = $this->pending(); + $_FILES = $files; + $this->assertFalse(save_uploaded_file()); + $this->assertFileExists($pending); + $this->assertSame(basename($pending), $_SESSION['pending_upload']); + $_POST['uploaded'] = 'pending'; + $this->assertSame($pending, save_uploaded_file()); + } + + public function testAMissingTemporaryFileIsNotSaved(): void { + $_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => $this->tmp . '/missing', 'error' => UPLOAD_ERR_OK); + $this->assertFalse(save_uploaded_file()); + $this->assertArrayNotHasKey('pending_upload', $_SESSION); + } + // Files in the upload directory: an old upload, a new one and a file that is not an upload private function others(): array { $files = array('old' => upload_dir() . '/' . str_repeat('c', 32), 'new' => upload_dir() . '/' . str_repeat('d', 32), diff --git a/tests/php/Unit/ValidateDataTest.php b/tests/php/Unit/ValidateDataTest.php index b0ac3d5..7c1315e 100644 --- a/tests/php/Unit/ValidateDataTest.php +++ b/tests/php/Unit/ValidateDataTest.php @@ -1,6 +1,7 @@ assertSame(array('upload' => "<b>notes</b>.txt wasn't an image file. Please go back."), $this->validate(array())); } + public static function invalidUploadFields(): array { + return array( + 'array filename' => array('name', array('a.png')), + 'array temporary file' => array('tmp_name', array('/tmp/not-an-upload')), + 'array error code' => array('error', array(UPLOAD_ERR_OK)), + 'numeric filename' => array('name', 123), + 'boolean temporary file' => array('tmp_name', FALSE), + 'string error code' => array('error', '0'), + ); + } + + #[DataProvider('invalidUploadFields')] + public function testAnInvalidUploadFieldIsRejected(string $field, mixed $value): void { + $this->imageOf(strlen(self::png())); + $_FILES['upload'][$field] = $value; + $this->assertArrayHasKey('upload', $this->validate(array())); + $this->assertArrayNotHasKey('pending_upload', $_SESSION); + } + + public function testANestedUploadIsRejected(): void { + $_FILES['upload'] = array('name' => array('a.png'), 'tmp_name' => array('/tmp/not-an-upload'), + 'error' => array(UPLOAD_ERR_OK), 'size' => array(1), 'type' => array('image/png')); + $this->assertArrayHasKey('upload', $this->validate(array())); + $this->assertArrayNotHasKey('pending_upload', $_SESSION); + } + + public static function absentUploads(): array { + return array( + 'no upload entry' => array(array()), + 'empty entry' => array(array('upload' => array())), + 'no file chosen' => array(array('upload' => array('error' => UPLOAD_ERR_NO_FILE))), + ); + } + + #[DataProvider('absentUploads')] + public function testAnAbsentUploadDoesNotBlockATextStory(array $files): void { + $_FILES = $files; + $this->assertSame(array(), $this->validate(array())); + } + + public function testASuccessfulUploadNeedsATemporaryFilename(): void { + $_FILES['upload'] = array('name' => 'a.png', 'error' => UPLOAD_ERR_OK); + $this->assertArrayHasKey('upload', $this->validate(array())); + } + + public function testAMissingTemporaryFileIsRejected(): void { + $_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => $this->tmp . '/missing', 'error' => UPLOAD_ERR_OK); + $this->assertArrayHasKey('upload', $this->validate(array())); + $this->assertArrayNotHasKey('pending_upload', $_SESSION); + } + + public function testAScalarUploadEntryIsRejected(): void { + $_FILES['upload'] = 'not a file entry'; + $this->assertArrayHasKey('upload', $this->validate(array())); + } + + public function testAnEmptyTemporaryFilenameIsRejected(): void { + $_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => '', 'error' => UPLOAD_ERR_OK); + $this->assertArrayHasKey('upload', $this->validate(array())); + } + + public function testADirectoryIsNotAnUpload(): void { + $_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => $this->tmp, 'error' => UPLOAD_ERR_OK); + $this->assertArrayHasKey('upload', $this->validate(array())); + } + + public function testAnEmptyImageFileIsRejectedWithoutWarnings(): void { + $file = $this->tmp . '/empty'; + file_put_contents($file, ''); + $_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => $file, 'error' => UPLOAD_ERR_OK); + $this->assertArrayHasKey('upload', $this->validate(array())); + } + public function testAnImageUploadPasses(): void { $file = $this->tmp . '/upload'; file_put_contents($file, self::png());