Files
feuerwerk/tests/php/files.test.php
T
adminandClaude Opus 5.5 043c224cc3 Rename Game Maker to Feuerwerk and serve it at /feuerwerk/ (#422)
The editor gets a name of its own that fits beside Brandmauer,
Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the
name, the URL and the stored state in the browser. Repository, CI
runners, containers and the GAME_MAKER_* variables follow in stage 3.

- All paths come from SITE_PATH in src/core/site.ts: the API endpoint,
  the data folders, the play page and the base in vite.config.ts. A
  later move is one line instead of fifteen.
- The window title, the sign-in screen, the playground and the README
  say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the
  writing app in Ogg and Matroska follow.
- The session cookie is now feuerwerk_session, so everyone signs in
  once more.
- localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the
  old game-maker.* keys over on the first start, so settings, panel
  sizes and save games survive.
- tools/ci/serve.sh serves the build under /feuerwerk/. The variable
  GAME_MAKER_BASE_URL keeps its name until stage 3.

Outside the repository, nockal.com/feuerwerk/ is a second symlink to
the same tree. nginx sends the bare /game-maker/ folder there with a
301. Everything else under /game-maker/ is still served directly, so
shared play links and the API keep working.

Finding: when nginx redirects a path without a trailing slash or with
a query, the Brandmauer answers with its own 307 to the same URL plus
a slash, placed after the query. A play link would lose its token that
way, which is why only the bare folder is redirected.

Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests
  (4 new in site.test.ts).
- Counter-check: when the migration misses the old prefix, 3 of the
  4 new tests fail.
- e2e 60 under /feuerwerk/: ALL PASSED, 20 checks.
- From outside: /feuerwerk/ 200, /game-maker/ 301, an old play
  link 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:27:43 +02:00

149 lines
8.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Uploaded files in a folder per account (#344): the stores, the folders in `users.json` and the
* check whether a project still uses a file. Plain PHP like releases.test.php; CI runs it with
* `php tests/php/files.test.php` and reads the last line.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/OriginalName.php';
require __DIR__ . '/../../api/lib/UserStore.php';
require __DIR__ . '/../../api/lib/HashedFileStore.php';
require __DIR__ . '/../../api/lib/FileUsage.php';
require __DIR__ . '/../../api/lib/SoundStore.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got, JSON_UNESCAPED_UNICODE), "\n";
}
/** A WAV header and some silence: enough for the store to take it, different per `$tag`. */
function wav(string $tag): string
{
return 'RIFF' . pack('V', 36) . 'WAVE' . str_repeat("\0", 32) . $tag;
}
$data = sys_get_temp_dir() . '/feuerwerk-php-files-' . bin2hex(random_bytes(4));
mkdir("{$data}/projects", 0775, true);
$users = new UserStore("{$data}/users.json");
$users->put('ann', 'password1');
$users->put('bob', 'password2');
$sounds = new SoundStore("{$data}/sounds");
check('an account has no folder before its first upload', $users->folderOf('ann') === '');
$ann = $users->folderOf('ann', true);
check('the first upload gives it one', preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $ann) === 1, $ann);
check('and keeps it', $users->folderOf('ann', true) === $ann && $users->folderOf('ann') === $ann);
$users->put('ann', 'another password');
check('a new password keeps the folder', $users->folderOf('ann') === $ann);
$users->setRole('ann', 'admin');
check('so does a new role', $users->folderOf('ann') === $ann);
$users->rename('ann', 'anna');
check('and a new name', $users->folderOf('anna') === $ann);
$bob = $users->folderOf('bob', true);
check('another account gets another folder', $bob !== $ann);
check('the folders say whose they are', $users->folderOwners() === [$ann => 'anna', $bob => 'bob'], $users->folderOwners());
$mine = $sounds->store(wav('a'), 'jump.wav', $ann);
check('an upload lands in the folder and says so', $mine['file'] === $ann . '/' . hash('sha256', wav('a')) . '.wav', $mine);
check('the file is there', is_file("{$data}/sounds/{$mine['file']}"));
check('its name lies beside it', $mine['name'] === 'jump.wav');
check('the same bytes again are the same file', $sounds->store(wav('a'), 'jump.wav', $ann)['file'] === $mine['file']);
$theirs = $sounds->store(wav('a'), 'hop.wav', $bob);
check('the same bytes from another account are a file of its own', $theirs['file'] === $bob . '/' . basename($mine['file']), $theirs);
check('and do not rename the first', $sounds->list($ann, false, [])[0]['name'] === 'jump.wav');
// What was uploaded before #344 lies in the kind's own directory and belongs to nobody.
$loose = hash('sha256', wav('old')) . '.wav';
file_put_contents("{$data}/sounds/{$loose}", wav('old'));
check('a name with a folder is valid', SoundStore::isValidName($mine['file']));
check('one without is too', SoundStore::isValidName($loose));
foreach (["../{$loose}", "{$ann}/../{$loose}", "x/{$loose}", "{$ann}/{$ann}/{$loose}", "{$ann}/", strtoupper($ann) . "/{$loose}"] as $bad) {
check("{$bad} is not a stored name", !SoundStore::isValidName($bad));
}
check('the folder of a name', HashedFileStore::folderOf($mine['file']) === $ann && HashedFileStore::folderOf($loose) === '');
$owners = $users->folderOwners();
$files = static fn(array $list): array => array_column($list, 'file');
$listed = $sounds->list($ann, false, $owners);
check('an account lists its own files and nobody\'s', $files($listed) == [$mine['file'], $loose] || $files($listed) == [$loose, $mine['file']], $files($listed));
$byFile = array_column($listed, null, 'file');
check('its own say whose they are and may go', $byFile[$mine['file']]['owner'] === 'anna' && $byFile[$mine['file']]['deletable'] === true, $byFile[$mine['file']]);
check('nobody\'s say so and may not', $byFile[$loose]['owner'] === null && $byFile[$loose]['deletable'] === false, $byFile[$loose]);
check('an account without a folder sees nobody\'s only', $files($sounds->list('', false, $owners)) === [$loose]);
$all = array_column($sounds->list('', true, $owners), null, 'file');
check('an administrator lists every file', count($all) === 3, array_keys($all));
check('and may delete nobody\'s too', $all[$loose]['deletable'] === true && $all[$loose]['owner'] === null);
$users->remove('bob');
$orphans = array_column($sounds->list('', true, $users->folderOwners()), null, 'file');
check('a deleted account\'s files stay, with no owner name', $orphans[$theirs['file']]['owner'] === '', $orphans[$theirs['file']]);
// Which projects use a file: in the project, a snapshot or a release.
$project = static fn(string $id, string $owner, string $name, array $assets): array => [
'id' => $id, 'name' => $name, 'owner' => $owner, 'revision' => 1, 'project' => ['assets' => $assets],
];
$write = static function (string $path, array $doc): void {
@mkdir(dirname($path), 0775, true);
file_put_contents($path, json_encode($doc, JSON_UNESCAPED_SLASHES));
};
$usage = new FileUsage($data);
check('a file no project names is not used', $usage->of($mine['file']) === []);
$write("{$data}/projects/p1.json", $project('p1', 'anna', 'Garden', [['type' => 'sound', 'file' => $mine['file']]]));
check('a project naming it uses it', $usage->of($mine['file']) === [['id' => 'p1', 'owner' => 'anna', 'name' => 'Garden']], $usage->of($mine['file']));
check('the same bytes in another folder are another file', $usage->of($theirs['file']) === []);
check('a name without the folder is another file', $usage->of($loose) === []);
$write("{$data}/projects/p2.json", $project('p2', 'bob', 'Cave', [['type' => 'sound', 'file' => 'x']]));
$write("{$data}/snapshots/p2/s1.json", ['id' => 's1', 'project' => ['assets' => [['file' => $theirs['file']]]]]);
check('a snapshot counts for its project', array_column($usage->of($theirs['file']), 'id') === ['p2'], $usage->of($theirs['file']));
$write("{$data}/releases/" . str_repeat('a', 32) . '.json', ['projectId' => 'p3', 'project' => ['nested' => [[$loose]]]]);
$write("{$data}/projects/p3.json", $project('p3', 'anna', 'Garden', []));
check('so does a release, wherever in it the name is', array_column($usage->of($loose), 'id') === ['p3'], $usage->of($loose));
check('a name that is only part of a string is no use', !FileUsage::uses(['x' => "{$loose} "], $loose));
// Slashes escaped the way PHP writes them by default are found as well.
file_put_contents("{$data}/projects/p4.json", json_encode($project('p4', 'bob', 'Mine', [['file' => $mine['file']]])));
check('a document with escaped slashes counts', count($usage->of($mine['file'])) === 2, $usage->of($mine['file']));
$uses = [
['id' => 'p1', 'owner' => 'anna', 'name' => 'Garden'],
['id' => 'p3', 'owner' => 'anna', 'name' => 'Garden'],
['id' => 'p4', 'owner' => 'bob', 'name' => 'Mine'],
];
check(
'the refusal names the asker\'s projects only',
FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots and releases',
FileUsage::refusal($uses, 'anna', false),
);
check(
'an administrator learns whose the others are',
FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots and releases',
FileUsage::refusal($uses, 'anna', true),
);
check(
'somebody with none of them learns only how many',
FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots and releases',
FileUsage::refusal($uses, 'carl', false),
);
check('deleting a file removes it', $sounds->delete($mine['file']) && !is_file("{$data}/sounds/{$mine['file']}"));
check('and its name', !is_file("{$data}/sounds/{$mine['file']}.json"));
check('the same bytes in the other folder stay', $sounds->exists($theirs['file']));
check('deleting it again says there was none', $sounds->delete($mine['file']) === false);
check('a path out of the store is no file to delete', $sounds->delete('../users.json') === false && is_file("{$data}/users.json"));
exec('rm -rf ' . escapeshellarg($data));
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
exit($failed === 0 ? 0 : 1);