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);