root = $this->tempDir(); $this->backend = new LocalBackend($this->root); $this->keys = KeyRing::generate(); $this->anchor = new class implements ICatalogAnchor { public array $data = []; public function get(string $r): ?array { return $this->data[$r] ?? null; } public function set(string $r, int $g, string $h): void { $this->data[$r] = ['gen' => $g, 'hash' => $h]; } }; } private function open(): Repository { return Repository::openWithKey($this->backend, $this->keys, null, $this->anchor); } /** Stands in for an export destination's close(): a manifest object plus a catalog record. */ private function putManifest(Repository $repo, string $path): void { $repo->putObject($path, json_encode(['uid' => 'alice'])); $repo->recordUserExport($path); } public function testRecordAndListSurviveAReopen(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001'); $again = $this->open(); $this->assertSame(['users/alice/20261005T120000Z-aaaa0001'], $again->listUserExports()); $this->assertSame(['users/alice/20261005T120000Z-aaaa0001'], $again->listUserExports('alice')); $this->assertSame([], $again->listUserExports('bob')); } public function testListIsNewestFirst(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001'); $this->putManifest($repo, 'users/alice/20261006T120000Z-aaaa0002'); $this->assertSame( ['users/alice/20261006T120000Z-aaaa0002', 'users/alice/20261005T120000Z-aaaa0001'], $repo->listUserExports('alice'), ); } public function testForgetRemovesItFromTheCatalogAndLocation(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $path = 'users/alice/20261005T120000Z-aaaa0001'; $this->putManifest($repo, $path); $repo->forgetUserExport($path); $this->assertSame([], $repo->listUserExports()); $this->assertFalse($this->backend->exists($path)); } public function testForgetRejectsAPathNotInTheCatalog(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $this->expectException(RepositoryException::class); $repo->forgetUserExport('users/nobody/not-a-real-export'); } public function testIsRecordedUserExport(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $path = 'users/alice/20261005T120000Z-aaaa0001'; $this->putManifest($repo, $path); $this->assertTrue($repo->isRecordedUserExport($path)); $this->assertFalse($repo->isRecordedUserExport('users/alice/not-the-real-one')); } public function testListingDetectsAManifestRemovedBehindItsBack(): void { // A catalog-listed export whose object disappeared from the location (deleted or // hidden outside NG Backup) must be detected, the same as a missing snapshot. $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $path = 'users/alice/20261005T120000Z-aaaa0001'; $this->putManifest($repo, $path); $this->backend->delete($path); $this->expectException(RepositoryException::class); $this->expectExceptionMessage('missing on the location'); $repo->listUserExports(); } public function testResolveTakesARecordedPathAsIs(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $path = 'users/alice/20261005T120000Z-aaaa0001'; $this->putManifest($repo, $path); $this->assertSame($path, $repo->resolveUserExport($path)); } public function testResolveTakesAUserIdWithOneExport(): void { // Issue #22: "backup:user:restore New_User" was rejected as not a recorded export. $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $path = 'users/alice/20261005T120000Z-aaaa0001'; $this->putManifest($repo, $path); $this->putManifest($repo, 'users/alice2/20261005T120000Z-aaaa0002'); $this->assertSame($path, $repo->resolveUserExport('alice')); $this->assertSame($path, $repo->resolveUserExport('users/alice/')); } public function testResolveListsTheExportsOfAUserWithMore(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001'); $this->putManifest($repo, 'users/alice/20261006T120000Z-aaaa0002'); $this->expectException(RepositoryException::class); $this->expectExceptionMessage("alice has 2 exports here; pass one of these (newest first):\n users/alice/20261006T120000Z-aaaa0002\n users/alice/20261005T120000Z-aaaa0001"); $repo->resolveUserExport('alice'); } public function testResolveListsTheExportsOfTheUserOfAWrongPath(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001'); $this->expectException(RepositoryException::class); $this->expectExceptionMessage("users/alice/typo is not a recorded user export for this location; pass one of these (newest first):\n users/alice/20261005T120000Z-aaaa0001"); $repo->resolveUserExport('users/alice/typo'); } public function testResolveNamesTheExpectedFormForAUserWithoutExports(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); $this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001'); $this->expectException(RepositoryException::class); $this->expectExceptionMessage('bob has no exports here. Expected users//; occ backup:user:list shows the exports'); $repo->resolveUserExport('bob'); } }