Reject invalid uploads and expand flow tests.
This commit is contained in:
+23
-9
@@ -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;
|
||||
|
||||
@@ -7,8 +7,8 @@ echo '<p>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
|
||||
|
||||
@@ -297,6 +297,18 @@ test("the source button swaps the editor for the textarea and back", () => {
|
||||
assert.equal(env.textarea.value, "<p>edited</p>");
|
||||
});
|
||||
|
||||
test("submitting in source mode sends the source edits", (t) => {
|
||||
const env = editorWindow("<p>Old story</p>");
|
||||
t.after(() => env.window.close());
|
||||
button(env.document, "Source code").click();
|
||||
const source = "<p>Source edit</p>\n<p>Second paragraph.</p>";
|
||||
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("<p>a</p>");
|
||||
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, "<p>a</p>");
|
||||
});
|
||||
|
||||
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("<p>a</p>");
|
||||
t.after(() => env.window.close());
|
||||
let now = 10000;
|
||||
t.mock.method(env.window.Date, "now", () => now);
|
||||
type(env, "<p>ab</p>");
|
||||
now += pause;
|
||||
type(env, "<p>abc</p>");
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, pause < 1000 ? "<p>a</p>" : "<p>ab</p>");
|
||||
key(env.window, env.box, "y", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>abc</p>");
|
||||
});
|
||||
}
|
||||
|
||||
test("a change of input type starts a separate undo step", (t) => {
|
||||
const env = editorWindow("<p>a</p>");
|
||||
t.after(() => env.window.close());
|
||||
t.mock.method(env.window.Date, "now", () => 10000);
|
||||
type(env, "<p>abc</p>", "insertText");
|
||||
type(env, "<p>ab</p>", "deleteContentBackward");
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>abc</p>");
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>a</p>");
|
||||
});
|
||||
|
||||
test("a new edit after undo clears the old redo history", (t) => {
|
||||
const env = editorWindow("<p>a</p>");
|
||||
t.after(() => env.window.close());
|
||||
let now = 10000;
|
||||
t.mock.method(env.window.Date, "now", () => now);
|
||||
type(env, "<p>ab</p>");
|
||||
now += 1000;
|
||||
type(env, "<p>abc</p>");
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>ab</p>");
|
||||
type(env, "<p>abX</p>");
|
||||
key(env.window, env.box, "y", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>abX</p>");
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>ab</p>");
|
||||
key(env.window, env.box, "y", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>abX</p>");
|
||||
});
|
||||
|
||||
test("undo keeps the latest 100 steps and redo restores them", (t) => {
|
||||
const env = editorWindow("<p>0</p>");
|
||||
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, `<p>${i}</p>`);
|
||||
}
|
||||
for (let i = 104; i >= 5; i--) {
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, `<p>${i}</p>`);
|
||||
}
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>5</p>", "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, `<p>${i}</p>`);
|
||||
}
|
||||
key(env.window, env.box, "y", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>105</p>");
|
||||
});
|
||||
|
||||
test("undo restores a selection across nodes and redo restores the caret", (t) => {
|
||||
const original = "<p>alpha<em>beta</em></p>";
|
||||
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, "<p>altered</p>");
|
||||
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, "<p>altered</p>");
|
||||
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("<p>a</p>");
|
||||
t.after(() => env.window.close());
|
||||
type(env, "<p>b</p>");
|
||||
key(env.window, env.box, "z", { ctrlKey: true });
|
||||
assert.equal(env.textarea.value, "<p>a</p>");
|
||||
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, "<p>b</p>");
|
||||
});
|
||||
|
||||
test("undo from the browser's history input", () => {
|
||||
const env = editorWindow("<p>a</p>");
|
||||
type(env, "<p>b</p>");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Xxx\Tests\Support\FakeDb;
|
||||
use Xxx\Tests\Support\Mail;
|
||||
use Xxx\Tests\Support\SiteData;
|
||||
@@ -110,6 +111,71 @@ class SaveDataTest extends TestCase {
|
||||
$this->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' => '<p>Text.</p>', 'uploaded' => 'pending'),
|
||||
'get' => $get, 'session' => $session,
|
||||
)));
|
||||
$file = var_export($state, TRUE);
|
||||
$code = <<<PHP
|
||||
\$state = unserialize(file_get_contents($file));
|
||||
Xxx\Tests\Support\SiteData::install(\$state['tables']);
|
||||
Xxx\Tests\Support\FakeDb::on(\$state['pattern'], FALSE);
|
||||
open_db('open');
|
||||
\$is_admin = \$state['admin'];
|
||||
\$action = \$state['action'];
|
||||
\$_POST = \$state['post'];
|
||||
\$_GET = \$state['get'];
|
||||
\$_REQUEST = array_merge(\$_GET, \$_POST);
|
||||
\$_SESSION = \$state['session'];
|
||||
\$before = Xxx\Tests\Support\SiteData::\$tables;
|
||||
\$returned = FALSE;
|
||||
// db_fail() exits, so save the result at shutdown.
|
||||
register_shutdown_function(function () use (\$before, &\$returned) {
|
||||
file_put_contents($file, serialize(array(
|
||||
'before' => \$before, 'tables' => Xxx\Tests\Support\SiteData::\$tables,
|
||||
'session' => \$_SESSION, 'queries' => Xxx\Tests\Support\FakeDb::queries(),
|
||||
'returned' => \$returned, 'status' => http_response_code(),
|
||||
)));
|
||||
});
|
||||
save_data();
|
||||
\$returned = TRUE;
|
||||
PHP;
|
||||
$result = Mail::run($code, array('UPLOAD_DIR' => $this->tmp . '/uploads'));
|
||||
$this->assertSame(0, $result['status'], $result['stderr']);
|
||||
$this->assertSame('', $result['stderr']);
|
||||
$this->assertSame('<p>Sorry, something went wrong. Please try again later.</p>', $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';
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Xxx\Tests\Support\TestCase;
|
||||
|
||||
// The upload directory and the pending upload in get_data.lib.php and publish/post.lib.php
|
||||
@@ -88,6 +89,49 @@ class UploadTest extends TestCase {
|
||||
$this->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),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace Xxx\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Xxx\Tests\Support\FakeRecaptcha;
|
||||
use Xxx\Tests\Support\TestCase;
|
||||
|
||||
@@ -120,6 +121,79 @@ class ValidateDataTest extends TestCase {
|
||||
$this->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());
|
||||
|
||||
Reference in New Issue
Block a user