environment() === 'prod') { self::fail('Refusing to run integration tests against a server configured for the "prod" environment.'); } static::$tenantDomain = 'ci-' . bin2hex(random_bytes(4)) . '.test'; static::$tenantIdentifier = 'ci_' . bin2hex(random_bytes(4)); static::runConsole([ 'tenant:create', static::$tenantDomain, '--identifier', static::$tenantIdentifier, ]); static::runConsole(['tenant:auth:enable', static::$tenantIdentifier, 'password']); } public static function tearDownAfterClass(): void { if (isset(static::$tenantIdentifier)) { static::runConsole(['tenant:delete', static::$tenantIdentifier, '--force']); } } protected static function buildServer(): Server { return new Server(KTRIX_SERVER_ROOT); } // ========================================================================= // Provisioning helpers (run through bin/console, same as CI/ops would) // ========================================================================= /** * Provision a user (optionally with a password credential) and return * its uid, so tests can exercise admin endpoints that operate by uid. */ protected static function createUser(string $identity, ?string $password = null): string { $uid = bin2hex(random_bytes(8)); static::runConsole(['user:create', static::$tenantIdentifier, $identity, '--uid', $uid]); if ($password !== null) { static::runConsole(['user:password', static::$tenantIdentifier, $identity, $password]); } return $uid; } /** * Run a bin/console command as a real subprocess and fail the test on * a non-zero exit code. * * @param string[] $args Command name followed by its arguments/options */ protected static function runConsole(array $args): string { $command = escapeshellcmd(PHP_BINARY) . ' ' . escapeshellarg(KTRIX_SERVER_ROOT . '/bin/console'); foreach ($args as $arg) { $command .= ' ' . escapeshellarg((string) $arg); } $command .= ' --no-interaction 2>&1'; exec($command, $outputLines, $exitCode); $output = implode("\n", $outputLines); if ($exitCode !== 0) { static::fail("Console command failed (exit {$exitCode}): {$command}\n{$output}"); } return $output; } // ========================================================================= // HTTP helpers (dispatched in-process through the real kernel) // ========================================================================= /** * Dispatch a request through the real application and return the * decoded JSON body alongside the status code. * * @return array{status: int, body: mixed, response: Response} */ protected static function request(string $method, string $path, array $data = [], array $headers = []): array { $server = [ 'HTTP_HOST' => static::$tenantDomain, 'SERVER_NAME' => static::$tenantDomain, 'REMOTE_ADDR' => '127.0.0.1', ]; foreach ($headers as $name => $value) { $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; } $content = null; if (!empty($data) && $method !== 'GET') { $content = json_encode($data, JSON_THROW_ON_ERROR); $server['CONTENT_TYPE'] = 'application/json'; } $uri = 'http://' . static::$tenantDomain . $path; $request = Request::create($uri, $method, [], [], [], $server, $content); // Kernel::boot() registers a global error/exception handler per // instance and never removes it (fine under one-process-per-request // in production; here it would otherwise stack a handler per test). // Pop back to whatever was in place before this request. try { $response = static::buildServer()->handle($request); } finally { restore_error_handler(); restore_exception_handler(); } $body = $response->getContent(); $decoded = null; if (is_string($body) && $body !== '') { $decoded = json_decode($body, true); } return ['status' => $response->getStatusCode(), 'body' => $decoded, 'response' => $response]; } protected static function authorizedRequest(string $token, string $method, string $path, array $data = []): array { return static::request($method, $path, $data, ['Authorization' => 'Bearer ' . $token]); } /** * Read a cookie's value off a Response. Authentication issues its * access/refresh JWTs exclusively as Set-Cookie headers (accessToken, * refreshToken) — AuthenticationResponse::toArray() never puts them in * the JSON body — so tests read the token straight off the Response * object we already have in-process, the equivalent of a browser * reading Set-Cookie. */ protected static function cookieValue(Response $response, string $name): ?string { foreach ($response->headers->getCookies() as $cookie) { if ($cookie->getName() === $name) { return $cookie->getValue(); } } return null; } /** * Drive the real /auth/start -> /auth/identify -> /auth/verify flow for * the password method. * * @return array{status: int, body: mixed, response: Response, accessToken: ?string, refreshToken: ?string} * The /auth/verify response, with tokens (if any) pulled from Set-Cookie. */ protected static function loginWithPassword(string $identity, string $password): array { $start = static::request('GET', '/auth/start'); $session = $start['body']['session'] ?? null; static::assertNotNull($session, 'auth/start did not return a session id: ' . json_encode($start)); static::request('POST', '/auth/identify', ['session' => $session, 'identity' => $identity]); $verify = static::request('POST', '/auth/verify', [ 'session' => $session, 'method' => 'password', 'response' => $password, ]); $verify['accessToken'] = static::cookieValue($verify['response'], 'accessToken'); $verify['refreshToken'] = static::cookieValue($verify['response'], 'refreshToken'); return $verify; } }