Reject invalid uploads and expand flow tests.

This commit is contained in:
tim
2026-09-26 15:27:51 +02:00
parent 716a609f4e
commit 8f4851e576
9 changed files with 427 additions and 11 deletions
+117
View File
@@ -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>");
+42
View File
@@ -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;
+18
View File
@@ -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']);
+66
View File
@@ -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';
+41
View File
@@ -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'),
+44
View File
@@ -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),
+74
View File
@@ -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' => "&lt;b&gt;notes&lt;/b&gt;.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());