Files
adminandClaude Opus 5.5 925a39a276
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s
Let guests try a demo project without an account (#428)
The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.

- Demo copies are frozen copies like a release, in data/demos/. An
  administrator makes, updates and removes one with "Demo copy..." in
  the project's main menu. GET demos and GET demos/{token} need no
  session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
  as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
  list carries it with demo: true, for accounts and guests alike.
  Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
  copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
  would write refuses in the tab before a request is made, and the
  autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
  pickers drop Upload and Delete, a dropped or pasted file is refused,
  and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
  "Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
  sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
  on. It only reads the server's own modules.

Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
  autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
  rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:58:30 +02:00

154 lines
8.6 KiB
PHP
Raw Permalink 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);
// A command-line script: under a web server it does nothing (#427).
if (PHP_SAPI !== 'cli') {
exit(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, releases and demo copies',
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, releases and demo copies',
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, releases and demo copies',
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);