Files
nextcloud-ng_backup/tests/Unit/Repository/CatalogTest.php
T
dillard b14b16c66c Empty folders in backup and restore, a full disk stops a restore, progress in the Bin
- Record folders without files in the meta record of a snapshot and restore them (#27)
- Errors writing to the restore directory stop the restore, also with --salvage
- Resume: check the folder before skipping, clean temporaries in folders that are complete
- Clean only read-ahead folders of our own; refresh the lease on skipped files
- Drop tables a failed database restore created; keep --salvage from calling a restored database fresh
- Retry a pack that fails to open on the storage (SMB)
- Without cron, running backups take turns from the open page
- Bin: read in parts with a counter and progress bar, show read errors, refresh when a run ends
2026-11-06 15:50:02 +01:00

111 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Repository;
use OCA\NgBackup\Backend\LocalBackend;
use OCA\NgBackup\Crypto\KeyRing;
use OCA\NgBackup\Repository\ICatalogAnchor;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Repository\RepositoryException;
use OCA\NgBackup\Tests\Unit\TempDirTrait;
use PHPUnit\Framework\TestCase;
class CatalogTest extends TestCase {
use TempDirTrait;
private LocalBackend $backend;
private KeyRing $keys;
private ICatalogAnchor $anchor;
private string $root;
private string $src;
protected function setUp(): void {
$this->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]; }
};
$this->src = $this->tempDir();
file_put_contents("$this->src/a", random_bytes(100000));
}
private function open(): Repository {
return Repository::openWithKey($this->backend, $this->keys, null, $this->anchor);
}
public function testNormalUseAdvancesTheChain(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
$s1 = $repo->backupDirectory($this->src);
file_put_contents("$this->src/b", 'x');
$s2 = $repo->backupDirectory($this->src, $s1['snapshot']);
$repo->forget($s1['snapshot']);
$again = $this->open();
$this->assertSame([$s2['snapshot']], $again->listSnapshots());
$this->assertArrayHasKey($s1['snapshot'], $again->trash());
$this->assertSame(4, $again->catalogGeneration());
}
public function testTheTrashIsReadInPartsThatTogetherAreAll(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
for ($i = 0; $i < 3; $i++) {
$repo->forget($repo->backupDirectory($this->src)['snapshot']);
}
$again = $this->open();
$seen = [];
// A deadline already past: one entry per request, as on a slow location
for ($request = 0; count($seen) < 3 && $request < 5; $request++) {
$page = $again->trashPage(count($seen), 0.0);
$this->assertSame(3, $page['total']);
$this->assertCount(1, $page['items']);
$seen += $page['items'];
}
$this->assertSame(array_keys($again->trash()), array_intersect(array_keys($again->trash()), array_keys($seen)));
$this->assertCount(3, $seen);
$this->assertSame(['total' => 3, 'items' => []], $again->trashPage(3, 0.0));
$this->assertCount(3, $again->trashPage(0, microtime(true) + 60)['items']);
}
public function testRollbackIsDetected(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
$repo->backupDirectory($this->src);
$repo->backupDirectory($this->src);
// The location "loses" the newest generation and presents an older, validly encrypted state.
$catalogs = $this->backend->list('catalog');
unlink($this->root . '/' . end($catalogs));
$this->expectException(RepositoryException::class);
$this->expectExceptionMessage('rolled back');
$this->open();
}
public function testHiddenSnapshotIsDetected(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
$s = $repo->backupDirectory($this->src);
unlink($this->root . '/snapshots/' . $s['snapshot']);
$this->expectException(RepositoryException::class);
$this->open()->listSnapshots();
}
public function testSwappedGenerationIsRejected(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
$repo->backupDirectory($this->src);
$repo->backupDirectory($this->src);
$c = $this->backend->list('catalog');
// Put generation 2's bytes where generation 3 should be.
copy($this->root . '/' . $c[1], $this->root . '/' . $c[2]);
$this->expectException(\RuntimeException::class);
$this->open();
}
public function testWithoutAnchorTheNewestChainIsAccepted(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
// Disaster recovery on a new server: opened with the passphrase, no anchor.
$this->assertSame([$s['snapshot']], Repository::open($this->backend, 'pw')->listSnapshots());
}
}