RU
games/xencenter-xenadmin-xcp-jumphp / tests / OrganizeKeyringTest.php · commit fb1b7c9
<?phpnamespace Tests;use App\Feature\Organize\Organizer;use App\Form\ConfirmForm;use App\Form\FolderForm;use App\Form\MainForm;use App\Form\MasterPasswordForm;use App\Form\PropertiesForm;use App\Form\TagsForm;use App\Model\Connection;use App\Model\Keyring;use App\Model\ServerProfile;use App\Model\Session;use JumPHP\Kernel\Engine;use JumPHP\Save\Storage;use JumPHP\Testing\GameTestCase;/** Folders and tags (the Organization views) through the window, and the saved passwords' keyring. */final class OrganizeKeyringTest extends GameTestCase{    private const SECRET = 'Sup3r-Secret!pw';    private MainForm $form;    protected function tearDown(): void    {        // the next tests (XenCenterTest) start with no keyring and no saved server        foreach (['servers.json', Keyring::STATE, Keyring::KEY_FILE] as $file) {            Engine::get()->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');    }}