written .= $bytes; return true; } public function close(): void { $this->closed = true; } } final class XapiTest extends TestCase { public function testTwoRequestsShareOneKeepAliveConnectionAndReadContentLengthAndChunkedBodies(): void { $streams = []; $channel = new HttpChannel(function () use (&$streams) { return $streams[] = new FakeStream(); }, 'xcp.local'); $first = $channel->request('POST', '/jsonrpc', '{"a":1}'); $second = $channel->request('GET', '/rrd_updates?x=1'); self::assertCount(1, $streams); $s = $streams[0]; $s->emit('open'); self::assertStringStartsWith("POST /jsonrpc HTTP/1.1\r\nHost: xcp.local\r\n", $s->written); self::assertStringContainsString("Content-Length: 7\r\n\r\n{\"a\":1}", $s->written); self::assertStringNotContainsString('GET', $s->written, 'one request at a time'); // the answer arrives in two pieces $s->emit('data', ["HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel"]); self::assertFalse($first->isDone()); $s->emit('data', ["lo"]); self::assertSame('hello', $first->result()->body); self::assertStringContainsString('GET /rrd_updates?x=1', $s->written, 'the next request on the same stream'); $s->emit('data', ["HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\n\r\n0\r\n\r\n"]); self::assertSame('', $second->result()->body); self::assertCount(1, $streams); } public function testABigAnswerArrivingInSmallPiecesIsReadAtOnceNotInQuadraticTime(): void { // the first event.from of a pool: tens of MB, in the browser thousands of WebSocket messages $size = 16 * 1024 * 1024; $body = str_repeat('{"x":1},', $size / 8); foreach (['length' => "Content-Length: $size\r\n\r\n$body", 'chunked' => "Transfer-Encoding: chunked\r\n\r\n" . implode('', array_map(fn ($c) => dechex(strlen($c)) . "\r\n$c\r\n", str_split($body, 16384))) . "0\r\n\r\n"] as $mode => $raw) { $s = null; $channel = new HttpChannel(function () use (&$s) { return $s = new FakeStream(); }, 'h'); $task = $channel->request('POST', '/jsonrpc', '{}'); $s->emit('open'); $start = microtime(true); foreach (str_split("HTTP/1.1 200 OK\r\n" . $raw, 4096) as $piece) { $s->emit('data', [$piece]); } self::assertTrue($task->isDone(), $mode); self::assertSame($size, strlen($task->result()->body), $mode); self::assertLessThan(2.0, microtime(true) - $start, "$mode: each byte read once"); } } public function testTheTimeoutCountsSilenceNotTheLengthOfAnAnswerStillArriving(): void { $s = null; $channel = new HttpChannel(function () use (&$s) { return $s = new FakeStream(); }, 'h', 10.0); $task = $channel->request('POST', '/jsonrpc', '{}'); $s->emit('open'); $s->emit('data', ["HTTP/1.1 200 OK\r\nContent-Length: 30\r\n\r\n"]); for ($i = 0; $i < 29; $i++) { $channel->poll(1.0); // 29 s in all, a byte a second $s->emit('data', ['x']); } self::assertFalse($task->isDone()); $s->emit('data', ['y']); self::assertSame(30, strlen($task->result()->body)); $late = $channel->request('POST', '/jsonrpc', '{}'); $channel->poll(11.0); // on the kept-alive stream: sent again once (it may have died idle) $channel->poll(11.0); self::assertInstanceOf(XapiError::class, $late->error(), 'silence still times out'); } public function testAClosedConnectionIsOpenedAgainForTheNextRequest(): void { $streams = []; $channel = new HttpChannel(function () use (&$streams) { return $streams[] = new FakeStream(); }, 'h'); $a = $channel->request('POST', '/jsonrpc', 'x'); $streams[0]->emit('open'); $streams[0]->emit('data', ["HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok"]); self::assertSame('ok', $a->result()->body); self::assertTrue($streams[0]->closed); $b = $channel->request('POST', '/jsonrpc', 'y'); self::assertCount(2, $streams); $streams[1]->emit('error', ['connection refused']); self::assertInstanceOf(XapiError::class, $b->error()); self::assertSame('connection refused', $b->error()->getMessage()); } public function testARequestOnAKeptAliveConnectionTheServerClosedIsSentAgainOnANewOne(): void { $streams = []; $channel = new HttpChannel(function () use (&$streams) { return $streams[] = new FakeStream(); }, 'h'); $a = $channel->request('POST', '/jsonrpc', 'a'); $streams[0]->emit('open'); $streams[0]->emit('data', ["HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nA"]); self::assertSame('A', $a->result()->body); $b = $channel->request('POST', '/jsonrpc', 'b'); // written on the idle connection... $streams[0]->emit('close', [null]); // ...that was already gone self::assertFalse($b->isDone(), 'not failed: sent again'); self::assertCount(2, $streams); $streams[1]->emit('open'); $streams[1]->emit('data', ["HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nB"]); self::assertSame('B', $b->result()->body); } public function testAJsonRpcFailureBecomesAXapiError(): void { [, $error] = XapiClient::decode(new HttpReply(200, [], '{"jsonrpc":"2.0","error":{"code":1,"message":"SESSION_AUTHENTICATION_FAILED","data":["SESSION_AUTHENTICATION_FAILED","root","Authentication failure"]},"id":1}'), 'session.login_with_password'); self::assertInstanceOf(XapiError::class, $error); self::assertSame('SESSION_AUTHENTICATION_FAILED', $error->code()); self::assertStringContainsString('Authentication failed', $error->getMessage()); [$result] = XapiClient::decode(new HttpReply(200, [], '{"jsonrpc":"2.0","result":"OpaqueRef:1","id":2}'), 'x'); self::assertSame('OpaqueRef:1', $result); } public function testTheDemoPoolLogsInAndAnswersRecordsAndEvents(): void { $fake = new FakeXapi(); $client = new XapiClient($fake); $session = $client->login('root', 'demo'); $fake->poll(0.1); self::assertStringStartsWith('OpaqueRef:', $session->result()); $vms = $client->call('VM.get_all_records'); $events = $client->events(['vm'], '', 1.0); $fake->poll(0.1); self::assertNotEmpty($vms->result()); $batch = $events->result(); self::assertSame(count($vms->result()), count($batch['events']), 'the first event.from gives every object'); self::assertSame('add', $batch['events'][0]['operation']); $bad = (new XapiClient($fake))->login('root', 'wrong'); $fake->poll(0.1); self::assertSame('SESSION_AUTHENTICATION_FAILED', $bad->error()->code()); } public function testRrdUpdatesAreParsed(): void { $xml = '100511022AVERAGE:vm:u1:cpu0' . 'AVERAGE:vm:u1:cpu11100.50.11050.3NaN'; $rrd = Rrd::parse($xml); self::assertSame([[105, 0.3], [110, 0.5]], $rrd->series('vm', 'u1', 'cpu0')); self::assertSame(['cpu0', 'cpu1'], $rrd->names('vm', 'u1')); self::assertEqualsWithDelta(0.3, $rrd->average('vm', 'u1', ['cpu0', 'cpu1'])[1][1], 0.001); } public function testTheRfbClientDoesTheHandshakeThenAppliesRawAndCopyRectUpdates(): void { $stream = new FakeStream(); $rfb = new \App\Vnc\RfbClient($stream, "CONNECT /console?ref=x HTTP/1.0\r\n\r\n"); $stream->emit('open'); self::assertStringStartsWith('CONNECT /console', $stream->written); $stream->emit('data', ["HTTP/1.1 200 OK\r\n\r\nRFB 003.008\n"]); self::assertStringEndsWith("RFB 003.008\n", $stream->written); $stream->emit('data', ["\x01\x01" . pack('N', 0)]); $stream->emit('data', [pack('nn', 4, 2) . str_repeat("\0", 16) . pack('N', 4) . 'test']); self::assertSame(\App\Vnc\RfbClient::READY, $rfb->state); self::assertSame('test', $rfb->name); // a raw red pixel at (1, 0), then a copy of it to (3, 1) $stream->emit('data', [pack('Cxn', 0, 2) . pack('nnnnN', 1, 0, 1, 1, 0) . "\x00\x00\xff\x00" . pack('nnnnN', 3, 1, 1, 1, 1) . pack('nn', 1, 0)]); self::assertSame([255, 0, 0], $rfb->fb->pixel(1, 0)); self::assertSame([255, 0, 0], $rfb->fb->pixel(3, 1)); self::assertSame([0, 0, 0], $rfb->fb->pixel(0, 0)); // keys and the pointer go out as RFB messages $stream->written = ''; $rfb->tap(0x61); $rfb->pointer(2, 1, 1); self::assertSame(pack('CCnN', 4, 1, 0, 0x61) . pack('CCnN', 4, 0, 0, 0x61) . pack('CCnn', 5, 1, 2, 1), $stream->written); } public function testAFramebufferRectangleBecomesABmpPicture(): void { $fb = new \App\Vnc\Framebuffer(2, 2); $fb->put(0, 0, 2, 2, str_repeat("\x01\x02\x03\x00", 4)); $bmp = $fb->bmp(0, 0, 2, 2); self::assertSame('BM', substr($bmp, 0, 2)); self::assertSame(54 + 16, strlen($bmp)); self::assertSame(-2, unpack('l', substr($bmp, 22, 4))[1], 'top-down rows'); self::assertSame([0, 0, 2, 2], $fb->takeDirty()); self::assertNull($fb->takeDirty()); } }