storage->delete($file); } Session::$current?->reset(); parent::tearDown(); } private function fresh(array $params = []): MainForm { foreach (['servers.json', Keyring::STATE, Keyring::KEY_FILE] as $file) { Engine::get()->storage->delete($file); } $this->game->start(MainForm::class); /** @var MainForm $form */ $form = $this->game->scene(); $form->session->reset(); $this->game->start(MainForm::class, $params); $this->form = $form; return $form; } private function connectDemo(): Connection { $form = $this->fresh(['connectDemo' => true]); $this->game->run(10); $c = $form->session->connections[0]; self::assertTrue($c->isConnected(), 'the demo pool connects: ' . $c->error); return $c; } private function ref(Connection $c, string $class, string $name): string { return (string)$c->cache->byName($class, $name); } private function until(callable $condition, int $frames = 600): void { for ($i = 0; $i < $frames && !$condition(); $i++) { $this->game->run(1); } self::assertTrue((bool)$condition(), 'still waiting after ' . $frames . ' frames'); } /** XenCenter starting again: the session forgets everything it holds in memory and reads var/ again. */ private function restart(): MainForm { $session = $this->form->session; $session->reset(); (new \ReflectionProperty(Session::class, 'loaded'))->setValue($session, false); $this->game->start(MainForm::class); return $this->form; } // folders and tags -------------------------------------------------------------------------------------------------------- public function testTheFoldersViewShowsTheFoldersOfThePoolAndTheirObjects(): void { $c = $this->connectDemo(); $this->form->click($this->form->navFolders); $this->game->run(2); $ids = $this->form->tree->visibleIds(); foreach (['folder:/Production', 'folder:/Production/Web', 'folder:/Production/Databases', 'folder:/Lab', 'folder:/Archive'] as $folder) { self::assertContains($folder, $ids); } self::assertContains($this->ref($c, 'vm', 'web-01'), $ids); self::assertContains($this->ref($c, 'sr', 'NFS storage'), $ids, 'storage goes in folders too'); self::assertNotContains($this->ref($c, 'vm', 'gitlab'), $ids, 'an object in no folder is not in this view'); $this->form->tree->select('folder:/Production/Web'); $this->game->run(2); self::assertSame('Web', $this->form->banner->text); self::assertSame(['web-01', 'web-02'], array_map(fn ($row) => $row['cells'][0], $this->form->grid->rows), 'the folder page lists what is in it'); $this->game->shot('folders'); } public function testTheTagsViewListsEachTagAndTheObjectsHavingIt(): void { $c = $this->connectDemo(); $this->form->command('view.tags'); $this->game->run(2); $ad = $this->ref($c, 'vm', 'ad-dc01'); $win = $this->ref($c, 'vm', 'test-win11'); $ids = $this->form->tree->visibleIds(); self::assertContains('tag:windows', $ids); self::assertContains('tag:windows|' . $ad, $ids); self::assertContains('tag:test|' . $win, $ids); self::assertContains('tag:windows|' . $win, $ids, 'an object with two tags is under both'); $this->form->tree->select('tag:windows|' . $ad); $this->game->run(2); self::assertSame('ad-dc01', $this->form->banner->text, 'an object under a tag is that object'); self::assertSame([$c, $ad, 'vm'], $this->form->selection()); $this->game->shot('tags'); } public function testTheSearchBoxAlsoMatchesTags(): void { $c = $this->connectDemo(); $this->form->search->text = 'shared-storage'; $this->form->refresh(true); $ids = $this->form->tree->visibleIds(); self::assertContains($this->ref($c, 'sr', 'NFS storage'), $ids); self::assertNotContains($this->ref($c, 'vm', 'web-01'), $ids); $this->form->search->text = ''; $this->form->refresh(true); } public function testAVmIsMovedToANewFolderFromTheRightClick(): void { $c = $this->connectDemo(); $vm = $this->ref($c, 'vm', 'gitlab'); $this->form->tree->select($vm); $this->game->run(2); $this->form->onTreeMenu($this->form->tree, 300, 300); $texts = array_column(array_filter($this->form->tree->popup->items, 'is_array'), 'text'); self::assertContains('Move to Folder…', $texts); self::assertContains('Edit Tags…', $texts); $this->form->command('organize.move', $vm); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(FolderForm::class, $dialog); self::assertContains('/Production/Web', $dialog->folders->items); $dialog->click($dialog->value); $dialog->type('Tools/CI'); $this->game->run(1); $this->game->shot('move-dialog'); $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => Organizer::folderOf($c->cache, $vm) === '/Tools/CI'); $this->form->command('view.folders'); $this->game->run(20); self::assertContains('folder:/Tools/CI', $this->form->tree->visibleIds()); self::assertContains($vm, $this->form->tree->visibleIds()); } public function testEditTagsAddsAndRemovesTags(): void { $c = $this->connectDemo(); $vm = $this->ref($c, 'vm', 'web-01'); $this->form->command('organize.tags', $vm); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(TagsForm::class, $dialog); self::assertSame(['production', 'linux'], $dialog->tags->items); $dialog->click($dialog->newTag); $dialog->type('frontend, nginx'); $dialog->click($dialog->add); $dialog->tags->selected = 1; // linux $dialog->click($dialog->remove); self::assertSame(['production', 'frontend', 'nginx'], $dialog->tags->items); $this->game->run(1); $this->game->shot('tags-dialog'); $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => Organizer::tagsOf($c->cache, $vm) === ['production', 'frontend', 'nginx']); } public function testRenamingAFolderMovesItsObjectsAndDeletingOneSendsThemToTheTop(): void { $c = $this->connectDemo(); $web = $this->ref($c, 'vm', 'web-01'); $db = $this->ref($c, 'vm', 'db-primary'); $nfs = $this->ref($c, 'sr', 'NFS storage'); $this->form->command('view.folders'); $this->form->navigate('folder:/Production'); $this->form->command('organize.renameFolder', 'folder:/Production'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(FolderForm::class, $dialog); self::assertSame('Production', $dialog->value->text); $dialog->value->text = 'Prod'; $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => Organizer::folderOf($c->cache, $web) === '/Prod/Web' && Organizer::folderOf($c->cache, $db) === '/Prod/Databases' && Organizer::folderOf($c->cache, $nfs) === '/Prod'); $this->form->command('organize.deleteFolder', 'folder:/Prod/Databases'); $this->game->run(1); $confirm = $this->game->scene(); self::assertInstanceOf(ConfirmForm::class, $confirm); $confirm->click($confirm->ok); $this->game->run(1); $this->until(fn () => Organizer::folderOf($c->cache, $db) === ''); self::assertSame('/Prod/Web', Organizer::folderOf($c->cache, $web), 'the other folders stay'); $this->game->run(20); self::assertNotContains('folder:/Prod/Databases', $this->form->tree->visibleIds()); } public function testANewFolderIsKeptEmptyInThePool(): void { $c = $this->connectDemo(); $this->form->command('organize.newFolder', 'folder:/Lab'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(FolderForm::class, $dialog); $dialog->click($dialog->value); $dialog->type("Sandbox\n"); $this->game->run(1); $this->until(fn () => in_array('/Lab/Sandbox', Organizer::emptyFolders($c->cache), true)); self::assertContains('/Archive', Organizer::emptyFolders($c->cache), 'the other empty folders stay'); } public function testThePropertiesDialogChangesTagsAndFolder(): void { $c = $this->connectDemo(); $vm = $this->ref($c, 'vm', 'monitoring'); $this->form->command('object.properties', $vm); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(PropertiesForm::class, $dialog); self::assertSame('production', $dialog->tags->text); self::assertSame('(top level)', $dialog->folder->value()); $dialog->tags->text = 'production, metrics'; $dialog->folder->selected = (int)array_search('/Lab', $dialog->folder->items, true); $this->game->run(1); $this->game->shot('properties'); $dialog->click($dialog->ok); $this->game->run(1); $this->until(fn () => Organizer::tagsOf($c->cache, $vm) === ['production', 'metrics'] && Organizer::folderOf($c->cache, $vm) === '/Lab'); } // the keyring ------------------------------------------------------------------------------------------------------------- public function testEveryCipherSealsAndOpensAndRefusesATamperedTextOrAnotherKey(): void { foreach (Keyring::CIPHERS as $cipher) { if (($cipher === 'sodium' && !function_exists('sodium_crypto_secretbox')) || ($cipher === 'gcm' && !function_exists('openssl_encrypt'))) { continue; } $dir = sys_get_temp_dir() . '/kr-' . bin2hex(random_bytes(4)); @mkdir($dir); $keyring = new Keyring(new Storage($dir), $cipher); $sealed = (string)$keyring->seal(self::SECRET); self::assertTrue(Keyring::isSealed($sealed), $cipher); self::assertStringNotContainsString(self::SECRET, $sealed); self::assertSame(self::SECRET, $keyring->open($sealed), $cipher); self::assertSame(self::SECRET, (new Keyring(new Storage($dir), $cipher))->open($sealed), "$cipher: the key file is read again"); self::assertSame('0600', substr(sprintf('%o', fileperms($dir . '/' . Keyring::KEY_FILE)), -4), 'the key file is private'); $raw = (string)base64_decode(substr($sealed, (int)strpos($sealed, ':') + 1)); $raw[strlen($raw) - 1] = chr(ord($raw[strlen($raw) - 1]) ^ 1); self::assertNull($keyring->open(substr($sealed, 0, (int)strpos($sealed, ':') + 1) . base64_encode($raw)), "$cipher: a changed text is refused"); $other = sys_get_temp_dir() . '/kr-' . bin2hex(random_bytes(4)); @mkdir($other); $stranger = new Keyring(new Storage($other), $cipher); $stranger->seal('x'); self::assertNull($stranger->open($sealed), "$cipher: another key opens nothing"); } } public function testAWrongMasterPasswordIsRefusedAndTheRightOneOpens(): void { foreach (['sodium', 'hmac'] as $cipher) { if ($cipher === 'sodium' && !function_exists('sodium_crypto_pwhash')) { continue; } $dir = sys_get_temp_dir() . '/kr-' . bin2hex(random_bytes(4)); @mkdir($dir); $keyring = new Keyring(new Storage($dir), $cipher); $keyring->setMaster('correct horse'); $sealed = (string)$keyring->seal(self::SECRET); self::assertFileDoesNotExist($dir . '/' . Keyring::KEY_FILE, 'no random key with a master password'); $again = new Keyring(new Storage($dir), $cipher); self::assertTrue($again->hasMaster()); self::assertFalse($again->isUnlocked()); self::assertNull($again->open($sealed), 'locked'); self::assertFalse($again->unlock('wrong horse')); self::assertFalse($again->isUnlocked()); self::assertTrue($again->unlock('correct horse')); self::assertSame(self::SECRET, $again->open($sealed)); self::assertStringNotContainsString('correct horse', (string)file_get_contents($dir . '/' . Keyring::STATE)); } } public function testAServersFileWithClearPasswordsIsRewrittenSealed(): void { $this->fresh(); $storage = Engine::get()->storage; $storage->writeJson('servers.json', [['host' => '10.9.9.9', 'user' => 'root', 'password' => self::SECRET, 'method' => 'http', 'remember' => true]]); $form = $this->restart(); self::assertSame(self::SECRET, $form->session->connections[0]->profile->password, 'still usable'); $raw = (string)$storage->read('servers.json'); self::assertStringNotContainsString(self::SECRET, $raw, 'no clear password left in servers.json'); self::assertStringNotContainsString('"password"', $raw); self::assertTrue(Keyring::isSealed((string)$storage->readJson('servers.json')[0]['secret'])); self::assertTrue($storage->exists(Keyring::KEY_FILE), 'sealed with the random key of var/keyring.key'); $form = $this->restart(); self::assertSame(self::SECRET, $form->session->connections[0]->profile->password, 'opened again at the next start'); } public function testTheMasterPasswordIsAskedAtStartAWrongOneIsRefusedAndTheServersReconnect(): void { $this->connectDemo(); $this->form->session->add(new ServerProfile('10.9.9.9', 'root', self::SECRET, 'http', 0, true)); $this->form->command('tools.masterPassword'); $this->game->run(1); $dialog = $this->game->scene(); self::assertInstanceOf(MasterPasswordForm::class, $dialog); self::assertFalse($dialog->currentPanel->visible, 'no master password yet'); self::assertStringContainsString('casual reading', $dialog->note->text); $dialog->click($dialog->newPassword); $dialog->type('open sesame'); $dialog->click($dialog->confirm); $dialog->type('open sesam'); $dialog->click($dialog->ok); self::assertSame('The two new passwords are not the same.', $dialog->error->text); $dialog->click($dialog->confirm); $dialog->type("open sesame\n"); $this->game->run(1); self::assertSame($this->form, $this->game->scene()); $storage = Engine::get()->storage; self::assertStringNotContainsString(self::SECRET, (string)$storage->read('servers.json')); self::assertTrue($this->form->session->keyring()->hasMaster()); // XenCenter starts again: the master password is asked before the saved servers reconnect $form = $this->restart(); $ask = $this->game->scene(); self::assertInstanceOf(MasterPasswordForm::class, $ask); self::assertSame('Unlock', $ask->ok->text); self::assertSame(Connection::DISCONNECTED, $form->session->connections[0]->state, 'not reconnected while locked'); $ask->click($ask->current); $ask->type("wrong\n"); self::assertStringContainsString('wrong', $ask->error->text); self::assertSame('', $ask->current->text, 'the field is emptied'); self::assertSame($ask, $this->game->scene(), 'asked again'); $this->game->run(1); $this->game->shot('master-wrong'); $ask->click($ask->current); $ask->type("open sesame\n"); $this->game->run(1); self::assertSame($form, $this->game->scene()); self::assertSame(self::SECRET, $form->session->connections[1]->profile->password); $this->until(fn () => $form->session->connections[0]->isConnected()); // and once more: Forget Saved Passwords $form = $this->restart(); $ask = $this->game->scene(); self::assertInstanceOf(MasterPasswordForm::class, $ask); $ask->click($ask->forget); $this->game->run(1); self::assertSame($form, $this->game->scene()); self::assertFalse($storage->exists(Keyring::STATE)); self::assertSame('', $form->session->connections[1]->profile->password); self::assertSame('', $storage->readJson('servers.json')[1]['secret'], 'nothing kept for it'); } }