A new editor needs a username and a password of at least 12 characters. A persistent login needs an unexpired key and an existing account. Removing an account revokes its persistent logins, and its sessions end.
201 lines
9.6 KiB
PHP
201 lines
9.6 KiB
PHP
<?php
|
|
namespace Xxx\Tests\Support;
|
|
|
|
/*
|
|
* In-memory copies of the site tables (xxx, queue, editors and
|
|
* persistent_sessions), with FakeDb handlers for the queries that the
|
|
* code sends. It knows only those queries, not SQL in general.
|
|
*
|
|
* Story rows have the columns id, title, author, email, date, story,
|
|
* hidden, img, mime, comments and editor.
|
|
*/
|
|
class SiteData {
|
|
public static array $tables = array();
|
|
public static int $lastInsertId = 0;
|
|
// The size of editors.password; NULL means that the column keeps any hash
|
|
public static ?int $passwordLength = NULL;
|
|
|
|
public static function install(array $tables = array()): void {
|
|
self::$tables = $tables + array('xxx' => array(), 'queue' => array(), 'editors' => array(), 'persistent_sessions' => array());
|
|
foreach (array('xxx', 'queue') as $table)
|
|
self::$tables[$table] = array_map(array(self::class, 'story'), self::$tables[$table]);
|
|
self::$lastInsertId = 0;
|
|
|
|
$stories = '(xxx|queue)';
|
|
FakeDb::on("/^SELECT \\* FROM $stories\\b/", array(self::class, 'selectStories'));
|
|
FakeDb::on("/^SELECT COUNT\\(\\*\\) FROM $stories\\b/", function ($sql, $params) {
|
|
return array(array('COUNT(*)' => (string)count(self::filterStories($sql, $params))));
|
|
});
|
|
FakeDb::on('/^SELECT MAX\(`date`\) FROM xxx/', function () {
|
|
$dates = array_column(self::$tables['xxx'], 'date');
|
|
return array(array('MAX(`date`)' => $dates ? max($dates) : NULL));
|
|
});
|
|
FakeDb::on("/^SELECT img, mime FROM $stories\\b/i", function ($sql, $params) {
|
|
$rows = self::filterStories($sql, $params);
|
|
return array_map(function ($row) { return array('img' => $row['img'], 'mime' => $row['mime']); }, array_slice($rows, 0, 1));
|
|
});
|
|
FakeDb::on("/^INSERT INTO $stories SET /", array(self::class, 'insertStory'));
|
|
FakeDb::on("/^UPDATE $stories SET /", array(self::class, 'updateStory'));
|
|
FakeDb::on("/^DELETE FROM $stories WHERE `id` = \\? LIMIT 1/", function ($sql, $params) {
|
|
$table = self::tableOf($sql);
|
|
self::$tables[$table] = array_values(array_filter(self::$tables[$table], function ($row) use ($params) {
|
|
return (int)$row['id'] !== (int)$params[0];
|
|
}));
|
|
return TRUE;
|
|
});
|
|
FakeDb::on('/^SELECT LAST_INSERT_ID\(\)/', function () {
|
|
return array(array('LAST_INSERT_ID()' => (string)self::$lastInsertId));
|
|
});
|
|
|
|
FakeDb::on('/^SELECT (\*|username|password) FROM editors WHERE username = \?/', function ($sql, $params) {
|
|
return array_slice(array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
|
|
return $row['username'] === $params[0];
|
|
})), 0, 1);
|
|
});
|
|
FakeDb::on('/^UPDATE editors SET password = \? WHERE username = \?/', function ($sql, $params) {
|
|
foreach (self::$tables['editors'] as &$row) {
|
|
if ($row['username'] === $params[1])
|
|
$row['password'] = self::cut($params[0]);
|
|
}
|
|
return TRUE;
|
|
});
|
|
FakeDb::on('/^INSERT INTO editors SET `username` = \?, `password` = \?, `email` = \?/', function ($sql, $params) {
|
|
self::$tables['editors'][] = array('username' => $params[0], 'password' => self::cut($params[1]), 'email' => $params[2]);
|
|
return TRUE;
|
|
});
|
|
FakeDb::on('/^DELETE FROM editors WHERE username = \?/', function ($sql, $params) {
|
|
self::$tables['editors'] = array_values(array_filter(self::$tables['editors'], function ($row) use ($params) {
|
|
return $row['username'] !== $params[0];
|
|
}));
|
|
return TRUE;
|
|
});
|
|
|
|
FakeDb::on('/^SELECT \* FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
|
|
return array_slice(array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
|
|
return $row['username'] === $params[0] && $row['session'] === $params[1];
|
|
})), 0, 1);
|
|
});
|
|
FakeDb::on('/^SELECT p\.username, p\.session FROM persistent_sessions p JOIN editors e ON e\.username = p\.username\s+WHERE p\.username = \? AND p\.session = \? AND p\.created > DATE_SUB\(now\(\), INTERVAL 1 YEAR\)/', function ($sql, $params) {
|
|
$editors = array_column(self::$tables['editors'], 'username');
|
|
$limit = gmdate('Y-m-d H:i:s', strtotime('-1 year'));
|
|
return array_slice(array_values(array_map(function ($row) {
|
|
return array('username' => $row['username'], 'session' => $row['session']);
|
|
}, array_filter(self::$tables['persistent_sessions'], function ($row) use ($params, $editors, $limit) {
|
|
return $row['username'] === $params[0] && $row['session'] === $params[1]
|
|
&& in_array($row['username'], $editors, TRUE) && ($row['created'] ?? $limit) > $limit;
|
|
}))), 0, 1);
|
|
});
|
|
FakeDb::on('/^DELETE FROM persistent_sessions WHERE username = \?$/', function ($sql, $params) {
|
|
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
|
|
return $row['username'] !== $params[0];
|
|
}));
|
|
return TRUE;
|
|
});
|
|
FakeDb::on('/^INSERT INTO persistent_sessions \(username, session\) VALUES \(\?, \?\)/', function ($sql, $params) {
|
|
self::$tables['persistent_sessions'][] = array('username' => $params[0], 'session' => $params[1], 'created' => gmdate('Y-m-d H:i:s'));
|
|
return TRUE;
|
|
});
|
|
FakeDb::on('/^DELETE FROM persistent_sessions WHERE username = \? AND session = \?/', function ($sql, $params) {
|
|
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($params) {
|
|
return !($row['username'] === $params[0] && $row['session'] === $params[1]);
|
|
}));
|
|
return TRUE;
|
|
});
|
|
FakeDb::on('/^DELETE FROM persistent_sessions\s+WHERE created < DATE_SUB/', function () {
|
|
$limit = gmdate('Y-m-d H:i:s', strtotime('-1 year'));
|
|
self::$tables['persistent_sessions'] = array_values(array_filter(self::$tables['persistent_sessions'], function ($row) use ($limit) {
|
|
return strlen($row['session']) === 64 && ($row['created'] ?? $limit) >= $limit;
|
|
}));
|
|
return TRUE;
|
|
});
|
|
}
|
|
|
|
// A story row with every column, from the columns given
|
|
public static function story(array $row): array {
|
|
static $next = 1;
|
|
$row += array('id' => $next, 'title' => 'Title', 'author' => 'Author', 'email' => '',
|
|
'date' => '2020-01-01 10:00:00', 'story' => '<p>Story.</p>', 'hidden' => '0',
|
|
'img' => NULL, 'mime' => NULL, 'comments' => '', 'editor' => 'original author');
|
|
$next = max($next, (int)$row['id']) + 1;
|
|
$row['id'] = (string)$row['id'];
|
|
return $row;
|
|
}
|
|
|
|
public static function tableOf(string $sql): string {
|
|
preg_match('/\b(FROM|INTO|UPDATE)\s+`?(xxx|queue)\b/', $sql, $match);
|
|
return $match[2];
|
|
}
|
|
|
|
// The rows of the story table in $sql that its WHERE clause selects
|
|
public static function filterStories(string $sql, array $params): array {
|
|
$rows = self::$tables[self::tableOf($sql)];
|
|
if (stripos($sql, 'hidden IS NOT TRUE') !== FALSE)
|
|
$rows = array_filter($rows, function ($row) { return !$row['hidden']; });
|
|
if (preg_match('/\bid = (\d+)/i', $sql, $match))
|
|
$rows = array_filter($rows, function ($row) use ($match) { return (int)$row['id'] === (int)$match[1]; });
|
|
else if (preg_match('/\bid = \?/i', $sql))
|
|
$rows = array_filter($rows, function ($row) use ($params) { return (int)$row['id'] === (int)$params[0]; });
|
|
if (preg_match('/title LIKE \?/', $sql)) {
|
|
$term = strtolower(trim($params[0], '%'));
|
|
$rows = array_filter($rows, function ($row) use ($term) {
|
|
foreach (array('title', 'author', 'date', 'story') as $column) {
|
|
if (str_contains(strtolower((string)$row[$column]), $term))
|
|
return TRUE;
|
|
}
|
|
return FALSE;
|
|
});
|
|
}
|
|
return array_values($rows);
|
|
}
|
|
|
|
public static function selectStories(string $sql, array $params): array {
|
|
$rows = self::filterStories($sql, $params);
|
|
if (preg_match('/ORDER BY (\w+) (asc|desc)/i', $sql, $match)) {
|
|
$column = $match[1];
|
|
usort($rows, function ($a, $b) use ($column) { return strcmp((string)$a[$column], (string)$b[$column]); });
|
|
if (strtolower($match[2]) === 'desc')
|
|
$rows = array_reverse($rows);
|
|
}
|
|
if (preg_match('/LIMIT (\d+)\s*,\s*(\d+)/', $sql, $match))
|
|
$rows = array_slice($rows, (int)$match[1], (int)$match[2]);
|
|
return $rows;
|
|
}
|
|
|
|
// The column => value pairs of "SET `a` = ?, `b` = ?"
|
|
private static function assignments(string $sql, array $params): array {
|
|
preg_match_all('/`(\w+)` = \?/', explode(' WHERE ', $sql)[0], $matches);
|
|
return array_combine($matches[1], array_slice($params, 0, count($matches[1])));
|
|
}
|
|
|
|
public static function insertStory(string $sql, array $params) {
|
|
$ids = array_merge(array_column(self::$tables['xxx'], 'id'), array_column(self::$tables['queue'], 'id'), array(0));
|
|
$row = self::story(array('id' => max(array_map('intval', $ids)) + 1) + self::assignments($sql, $params));
|
|
self::$tables[self::tableOf($sql)][] = $row;
|
|
self::$lastInsertId = (int)$row['id'];
|
|
return TRUE;
|
|
}
|
|
|
|
public static function updateStory(string $sql, array $params) {
|
|
$id = (int)end($params);
|
|
$table = self::tableOf($sql);
|
|
foreach (self::$tables[$table] as &$row) {
|
|
if ((int)$row['id'] === $id)
|
|
$row = array_merge($row, self::assignments($sql, $params));
|
|
}
|
|
return TRUE;
|
|
}
|
|
|
|
private static function cut(string $value): string {
|
|
return self::$passwordLength === NULL ? $value : substr($value, 0, self::$passwordLength);
|
|
}
|
|
|
|
// The rows of a story table, found by id
|
|
public static function find(string $table, $id): ?array {
|
|
foreach (self::$tables[$table] as $row) {
|
|
if ((int)$row['id'] === (int)$id)
|
|
return $row;
|
|
}
|
|
return NULL;
|
|
}
|
|
}
|