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 buildApplication(): Application { return Application::create(SERVER_ROOT); } // ========================================================================= // Provisioning helpers (run through bin/console, same as CI/ops would) // ========================================================================= /** * Provision a user (optionally with a password credential and roles) and * return its uid, so tests can exercise admin endpoints that operate by uid. * * @param string[] $roles Role IDs to assign (see createRoleWithPermissions()) */ protected static function createUser(string $identity, ?string $password = null, array $roles = []): string { $uid = bin2hex(random_bytes(8)); $args = ['user:create', static::$tenantIdentifier, $identity, '--uid', $uid]; foreach ($roles as $role) { $args[] = '--role'; $args[] = $role; } static::runConsole($args); if ($password !== null) { static::runConsole(['user:password', static::$tenantIdentifier, $identity, $password]); } return $uid; } /** * Create a role granting the given permissions and return its role id. * * There's no `bin/console` command for role management, so this goes * straight through the DI container to UserRolesStore — the same store * the real UserRolesController uses — rather than calling controllers * directly. * * @param string[] $permissions */ protected static function createRoleWithPermissions(array $permissions, string $label = 'Test Role'): string { $application = static::buildApplication(); try { $application->kernel()->boot(); $store = $application->container()->get(UserRolesStore::class); $role = $store->createRole(static::$tenantIdentifier, [ 'label' => $label, 'permissions' => $permissions, ]); } finally { $application->shutdown(); } return $role['rid']; } /** * 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(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); // A fresh application mirrors production while shutdown restores its // process-level error handler after the in-process request. $application = static::buildApplication(); try { $response = $application->runHttpRequest($request); } finally { $application->shutdown(); } $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; } }