Files
nextcloud-ng_backup/tests/Unit/Repository/CatalogTest.php
T

125 lines
5.0 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
$after = '';
for ($request = 0; count($seen) < 3 && $request < 5; $request++) {
$page = $again->trashPage($after, 0.0);
$this->assertSame(3, $page['total']);
$this->assertCount(1, $page['items']);
$seen += $page['items'];
$after = (string)array_key_last($page['items']);
}
$this->assertEqualsCanonicalizing(array_keys($again->trash()), array_keys($seen));
$this->assertSame(['total' => 3, 'items' => []], $again->trashPage($after, 0.0));
$this->assertCount(3, $again->trashPage('', microtime(true) + 60)['items']);
}
public function testAnEntryThatLeavesTheTrashBetweenPartsMakesNoneComeTwiceOrBeMissed(): 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']);
}
$reader = $this->open();
$ids = array_keys($reader->trashPage('', microtime(true) + 60)['items']);
$first = $reader->trashPage('', 0.0)['items'];
$this->assertSame([$ids[0]], array_keys($first));
$this->open()->untrash($ids[0]); // another administrator, between two parts
$this->assertSame([$ids[1], $ids[2]], array_keys($this->open()->trashPage($ids[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());
}
}