diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ca503f0a2..5bf14ab4a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -104,6 +104,70 @@ jobs: - name: Validate Doctrine schema run: APP_ENV=prod php bin/console doctrine:schema:validate + validate-doctrine-schema-postgres: + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://db:db@127.0.0.1:5432/db?serverVersion=16&charset=utf8 + strategy: + fail-fast: false + matrix: + php: ["8.3"] + name: Validate Doctrine Schema on Postgres (PHP ${{ matrix.php }}) + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: db + POSTGRES_PASSWORD: db + POSTGRES_DB: db + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U db -d db" + --health-interval=5s + --health-timeout=3s + --health-retries=10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php}} + # pgsql/pdo_pgsql added on top of the standard extension list so + # Doctrine can connect to Postgres for the portability gate. + extensions: apcu, ctype, iconv, imagick, json, pdo_pgsql, pgsql, redis, soap, xmlreader, zip + coverage: none + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ matrix.php }}-composer- + + - name: 'Composer install with exported .env variables' + run: | + set -a && source .env && set +a + APP_ENV=prod composer install --no-dev -o + + # The 2.x historical migrations use raw MariaDB SQL and can't run on + # Postgres — that's the whole reason this PR exists. We instead apply + # the entity layer directly via schema:update, which tests the + # load-bearing claim that the *metadata* (after the rename) is + # platform-portable. doctrine:schema:validate then catches any drift + # between Postgres' generated DDL and what Doctrine expects. + - name: Apply entity metadata to Postgres (schema:update) + run: APP_ENV=prod php bin/console doctrine:schema:update --force --complete --no-interaction + + - name: Validate Doctrine schema (Postgres) + run: APP_ENV=prod php bin/console doctrine:schema:validate + php-cs-fixer: runs-on: ubuntu-latest strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 62104634f..5d16b2314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.8.0] - 2026-06-23 + +- [#495](https://github.com/os2display/display-api-service/pull/495) + - Added `app:utils:convert-env-to-3x` command that converts the loaded configuration of a running 2.x + installation (env vars + admin/client config.json) to 3.x environment configuration, with screen, + dotenv and docker compose output formats. +- [#486](https://github.com/os2display/display-api-service/pull/486) + - Changed BRND feed area and facility filters to match on `områdeId` and `facilitetsId` instead of area/facility names. + - Mapped area and facility IDs centrally in `parseBrndBooking()`. + - ID filtering is only supported for BRND API v2.0; area/facility fields are hidden in admin for v1.0 feed sources. +- [#444](https://github.com/os2display/display-api-service/pull/444) + - Renamed 15 `changed_idx` indexes to `_changed_idx` for cross-platform portability + (Postgres scopes index names schema-wide). + - Quoted `user` table identifier in entity metadata so Doctrine emits the platform-native quote on every reference. + - Added Postgres CI gate that runs `doctrine:schema:update --force --complete` + + `doctrine:schema:validate` against a Postgres 16 service container. + ## [2.7.1] - 2026-05-26 - [#460](https://github.com/os2display/display-api-service/pull/460) diff --git a/README.md b/README.md index e2d82128b..957afa157 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,48 @@ variable](https://symfony.com/doc/current/components/phpunit_bridge.html#configu docker compose exec --env SYMFONY_DEPRECATIONS_HELPER=disabled phpfpm composer tests ``` +## Preparing an upgrade to 3.x + +The `app:utils:convert-env-to-3x` command converts the configuration of this +(2.x) installation to 3.x environment configuration. The values _loaded_ in +the running application are treated as canonical — whether they come from +real environment variables, a docker compose `environment` block or +`.env`/`.env.local` files — and every variable the application reads is +written out under its 3.x name. + +Unless `--skip-config-json` is given, the command also fetches the canonical +admin and client configuration from `/admin/config.json` and +`/client/config.json` and converts them to the 3.x `ADMIN_*` and +`CLIENT_*` variables. The base URL is inferred from the loaded OIDC redirect +URIs (or `COMPOSE_DOMAIN`) and can be overridden with `--app-url`. + +```shell +# Review on screen (default): +docker compose exec phpfpm bin/console app:utils:convert-env-to-3x + +# Write a dotenv file ON THE HOST, the starting point for the 3.x .env.local. +# With --output=env (and --output=compose) the document goes to stdout and all +# notes/warnings go to stderr, so the result can be redirected from outside +# the container (-T: no TTY, keeps stdout byte-clean): +docker compose exec -T phpfpm bin/console app:utils:convert-env-to-3x \ + --output=env --app-url=https://display.example.com > env.3x + +# Or a docker compose environment block: +docker compose exec -T phpfpm bin/console app:utils:convert-env-to-3x --output=compose > env.3x.yml +``` + +Note that `--file` writes inside the container, so it only makes sense for +paths on a mounted volume; prefer the host-side redirect above in dockerised +setups. + +Loaded variables the Symfony application cannot read (`COMPOSE_*`, `PHP_*`, +`NGINX_*`, `MARIADB_*`/`MYSQL_*`) are listed in a trailing advisory with a +note on where each belongs in a 3.x deployment: compose orchestration values +go in the docker host `.env`, and the container-tuning values go on the +respective containers (`.env.php`, `.env.nginx`, `.env.mariadb` in the +`os2display-docker-server` file layout). They are never part of the +application env output. + ## CI Github Actions are used to run the test suite and code style checks on all PRs. diff --git a/migrations/Version20260507120000.php b/migrations/Version20260507120000.php new file mode 100644 index 000000000..b073e0b7a --- /dev/null +++ b/migrations/Version20260507120000.php @@ -0,0 +1,59 @@ +addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `changed_idx` TO `%s_changed_idx`', $table, $table)); + } + } + + public function down(Schema $schema): void + { + foreach (self::CHANGED_IDX_TABLES as $table) { + $this->addSql(sprintf('ALTER TABLE `%s` RENAME INDEX `%s_changed_idx` TO `changed_idx`', $table, $table)); + } + } +} diff --git a/src/Command/Utils/ConvertEnvTo3xCommand.php b/src/Command/Utils/ConvertEnvTo3xCommand.php new file mode 100644 index 000000000..059f3469e --- /dev/null +++ b/src/Command/Utils/ConvertEnvTo3xCommand.php @@ -0,0 +1,591 @@ + 'APP_ENV', + 'APP_DEBUG' => 'APP_DEBUG', + 'APP_SECRET' => 'APP_SECRET', + 'TRUSTED_PROXIES' => 'TRUSTED_PROXIES', + // doctrine/doctrine-bundle + 'DATABASE_URL' => 'DATABASE_URL', + // nelmio/cors-bundle + 'CORS_ALLOW_ORIGIN' => 'CORS_ALLOW_ORIGIN', + // App + 'APP_DEFAULT_DATE_FORMAT' => 'DEFAULT_DATE_FORMAT', + 'APP_ACTIVATION_CODE_EXPIRE_INTERNAL' => 'ACTIVATION_CODE_EXPIRE_INTERVAL', + 'APP_KEY_VAULT_SOURCE' => 'KEY_VAULT_SOURCE', + 'APP_KEY_VAULT_JSON' => 'KEY_VAULT_JSON', + // lexik/jwt-authentication-bundle + 'JWT_SECRET_KEY' => 'JWT_SECRET_KEY', + 'JWT_PUBLIC_KEY' => 'JWT_PUBLIC_KEY', + 'JWT_PASSPHRASE' => 'JWT_PASSPHRASE', + 'JWT_TOKEN_TTL' => 'JWT_TOKEN_TTL', + 'JWT_SCREEN_TOKEN_TTL' => 'JWT_SCREEN_TOKEN_TTL', + // gesdinet/jwt-refresh-token-bundle + 'JWT_REFRESH_TOKEN_TTL' => 'JWT_REFRESH_TOKEN_TTL', + 'JWT_SCREEN_REFRESH_TOKEN_TTL' => 'JWT_SCREEN_REFRESH_TOKEN_TTL', + // itk-dev/openid-connect-bundle, internal provider + 'INTERNAL_OIDC_METADATA_URL' => 'INTERNAL_OIDC_METADATA_URL', + 'INTERNAL_OIDC_CLIENT_ID' => 'INTERNAL_OIDC_CLIENT_ID', + 'INTERNAL_OIDC_CLIENT_SECRET' => 'INTERNAL_OIDC_CLIENT_SECRET', + 'INTERNAL_OIDC_REDIRECT_URI' => 'INTERNAL_OIDC_REDIRECT_URI', + 'INTERNAL_OIDC_LEEWAY' => 'INTERNAL_OIDC_LEEWAY', + 'INTERNAL_OIDC_CLAIM_NAME' => 'INTERNAL_OIDC_CLAIM_NAME', + 'INTERNAL_OIDC_CLAIM_EMAIL' => 'INTERNAL_OIDC_CLAIM_EMAIL', + 'INTERNAL_OIDC_CLAIM_GROUPS' => 'INTERNAL_OIDC_CLAIM_GROUPS', + // itk-dev/openid-connect-bundle, external provider + 'EXTERNAL_OIDC_METADATA_URL' => 'EXTERNAL_OIDC_METADATA_URL', + 'EXTERNAL_OIDC_CLIENT_ID' => 'EXTERNAL_OIDC_CLIENT_ID', + 'EXTERNAL_OIDC_CLIENT_SECRET' => 'EXTERNAL_OIDC_CLIENT_SECRET', + 'EXTERNAL_OIDC_REDIRECT_URI' => 'EXTERNAL_OIDC_REDIRECT_URI', + 'EXTERNAL_OIDC_LEEWAY' => 'EXTERNAL_OIDC_LEEWAY', + 'EXTERNAL_OIDC_HASH_SALT' => 'EXTERNAL_OIDC_HASH_SALT', + 'EXTERNAL_OIDC_CLAIM_ID' => 'EXTERNAL_OIDC_CLAIM_ID', + 'OIDC_CLI_REDIRECT' => 'OIDC_CLI_REDIRECT', + // redis + 'REDIS_CACHE_PREFIX' => 'REDIS_CACHE_PREFIX', + 'REDIS_CACHE_DSN' => 'REDIS_CACHE_DSN', + // Calendar Api Feed Source + 'CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT' => 'CALENDAR_API_FEED_SOURCE_LOCATION_ENDPOINT', + 'CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT' => 'CALENDAR_API_FEED_SOURCE_RESOURCE_ENDPOINT', + 'CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT' => 'CALENDAR_API_FEED_SOURCE_EVENT_ENDPOINT', + 'CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS' => 'CALENDAR_API_FEED_SOURCE_CUSTOM_MAPPINGS', + 'CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS' => 'CALENDAR_API_FEED_SOURCE_EVENT_MODIFIERS', + 'CALENDAR_API_FEED_SOURCE_DATE_FORMAT' => 'CALENDAR_API_FEED_SOURCE_DATE_FORMAT', + 'CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE' => 'CALENDAR_API_FEED_SOURCE_DATE_TIMEZONE', + 'CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS' => 'CALENDAR_API_FEED_SOURCE_CACHE_EXPIRE_SECONDS', + 'EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS' => 'EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS', + // Http Client + 'HTTP_CLIENT_TIMEOUT' => 'HTTP_CLIENT_TIMEOUT', + 'HTTP_CLIENT_MAX_DURATION' => 'HTTP_CLIENT_MAX_DURATION', + 'HTTP_CLIENT_LOG_LEVEL' => 'LOG_LEVEL_OUTBOUND_HTTP', + // Screen info tracking + 'TRACK_SCREEN_INFO' => 'TRACK_SCREEN_INFO', + 'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS' => 'TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS', + ]; + + /** + * Variables present in the 2.x .env that are docker compose + * orchestration config, not application config. They are deliberately + * not part of ENV_MAP and are reported through the infrastructure + * advisory instead. + */ + public const array NON_APP_ENV = [ + 'COMPOSE_PROJECT_NAME', + 'COMPOSE_DOMAIN', + ]; + + /** + * Environment prefixes the Symfony application cannot read. Where each + * belongs in a 3.x deployment (os2display-docker-server file layout). + */ + private const array INFRA_PREFIXES = [ + 'COMPOSE_' => 'compose orchestration — belongs in the docker host .env, never in the application env', + 'PHP_' => 'php-fpm container config — set on the phpfpm container (.env.php in os2display-docker-server)', + 'NGINX_' => 'nginx container config — set on the nginx container (.env.nginx in os2display-docker-server)', + 'MARIADB_' => 'database container config — set on the mariadb container (.env.mariadb in os2display-docker-server)', + 'MYSQL_' => 'database container config — set on the mariadb container (.env.mariadb in os2display-docker-server)', + ]; + + /** + * PHP_*-named values that are CGI artefacts or php docker image build + * constants — never operator configuration, so excluded from the + * infrastructure advisory. + */ + private const array INFRA_NOISE = [ + 'PHP_SELF', + 'PHP_AUTH_USER', + 'PHP_AUTH_PW', + 'PHP_AUTH_DIGEST', + 'PHP_VERSION', + 'PHP_INI_DIR', + 'PHP_CFLAGS', + 'PHP_CPPFLAGS', + 'PHP_LDFLAGS', + 'PHP_SHA256', + 'PHP_URL', + 'PHP_ASC_URL', + 'PHP_EXTRA_CONFIGURE_ARGS', + 'PHP_EXTRA_BUILD_DEPS', + 'PHPIZE_DEPS', + ]; + + public function __construct( + private readonly HttpClientInterface $httpClient, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Output format (screen, env or compose)', self::OUTPUT_SCREEN) + ->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'Write the result to this file instead of stdout (env and compose output only)') + ->addOption('app-url', 'u', InputOption::VALUE_REQUIRED, 'Base URL of the 2.x installation, e.g. https://display.example.com. Inferred from the loaded environment when omitted.') + ->addOption('skip-config-json', null, InputOption::VALUE_NONE, 'Do not fetch admin/config.json and client/config.json') + ->setHelp(<<<'HELP' + Converts the configuration of a running 2.x installation to 3.x environment + configuration. The values loaded in the running application — whether they + come from real environment variables, a docker compose environment block or + .env/.env.local files — are treated as canonical, and every variable the 2.x + application reads is written out under its 3.x name. + + Unless --skip-config-json is given, the command also fetches the canonical + admin and client configuration from /admin/config.json and + /client/config.json and converts them to the 3.x ADMIN_* and + CLIENT_* variables. The base URL is inferred from the loaded OIDC redirect + URIs (or COMPOSE_DOMAIN) and can be overridden with --app-url. + HELP); + } + + final protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + // Keep stdout clean when the document itself goes there, so the + // result can be piped or redirected; notes and warnings go to stderr. + $errIo = $io->getErrorStyle(); + + $format = $input->getOption('output'); + if (!in_array($format, [self::OUTPUT_SCREEN, self::OUTPUT_ENV, self::OUTPUT_COMPOSE], true)) { + $errIo->error(sprintf('Invalid output format "%s". Valid formats: %s.', $format, implode(', ', [self::OUTPUT_SCREEN, self::OUTPUT_ENV, self::OUTPUT_COMPOSE]))); + + return Command::INVALID; + } + + $file = $input->getOption('file'); + if (null !== $file && self::OUTPUT_SCREEN === $format) { + $errIo->error('--file requires --output=env or --output=compose.'); + + return Command::INVALID; + } + + $sections = [ + [ + 'title' => 'Converted from the loaded 2.x environment', + 'lines' => $this->convertLoadedEnv(), + ], + ]; + + if (!$input->getOption('skip-config-json')) { + $appUrl = $input->getOption('app-url'); + $baseUrl = is_string($appUrl) && '' !== $appUrl ? rtrim($appUrl, '/') : $this->inferAppUrl(); + + if (null === $baseUrl) { + $errIo->warning('Could not infer the 2.x app URL from the loaded environment. Pass --app-url=https://... to include the admin/client config.json conversion, or --skip-config-json to silence this warning.'); + } else { + foreach (['admin', 'client'] as $type) { + $url = sprintf('%s/%s/config.json', $baseUrl, $type); + $config = $this->fetchConfig($url, $errIo); + if (null !== $config) { + $sections[] = [ + 'title' => sprintf('%s configuration (from %s)', ucfirst($type), $url), + 'lines' => 'admin' === $type ? $this->convertAdminConfig($config) : $this->convertClientConfig($config), + ]; + } + } + } + } + + $infra = $this->collectInfraEnv(); + $document = self::OUTPUT_COMPOSE === $format + ? $this->renderCompose($sections, $infra) + : $this->renderEnv($sections, $infra); + + if (null !== $file) { + if (false === file_put_contents($file, $document)) { + $errIo->error(sprintf('Could not write to "%s".', $file)); + + return Command::FAILURE; + } + $errIo->success(sprintf('Wrote %s configuration to %s. Review the result before use.', $format, $file)); + + return Command::SUCCESS; + } + + if (self::OUTPUT_SCREEN === $format) { + $io->title('2.x configuration converted to 3.x environment configuration'); + $errIo->warning('This output contains secrets (e.g. APP_SECRET, JWT_PASSPHRASE, OIDC client secrets, the database password). Treat it accordingly — do not paste it into logs, issues or chat.'); + $io->writeln($document); + $io->note('Review the result, then save it with --output=env --file= (or --output=compose) and use it as the starting point for the 3.x .env.local.'); + + return Command::SUCCESS; + } + + // write(), not writeln(): the document is newline-terminated, and + // byte-exact stdout matters when redirecting to a file on the host. + $output->write($document); + + return Command::SUCCESS; + } + + /** + * Converts every loaded 2.x application variable to its 3.x name. + * + * @return list + */ + private function convertLoadedEnv(): array + { + $lines = []; + + foreach (self::ENV_MAP as $name2x => $name3x) { + $value = $this->lookupEnv($name2x); + if (null !== $value) { + $lines[] = [$name3x, $value]; + } + } + + return $lines; + } + + /** + * Looks up a loaded value the way Symfony's env processor does: + * $_ENV first, then $_SERVER, then the process environment. + */ + private function lookupEnv(string $name): ?string + { + $value = $_ENV[$name] ?? $_SERVER[$name] ?? getenv($name); + + return is_string($value) ? $value : null; + } + + /** + * Infers the public base URL of the 2.x installation from the loaded + * environment. The OIDC redirect URIs point at the public domain on + * a configured installation; COMPOSE_DOMAIN covers itk-dev + * development setups. + */ + private function inferAppUrl(): ?string + { + foreach (['INTERNAL_OIDC_REDIRECT_URI', 'EXTERNAL_OIDC_REDIRECT_URI', 'OIDC_CLI_REDIRECT'] as $name) { + $value = $this->lookupEnv($name); + if (null === $value) { + continue; + } + + $parts = parse_url($value); + if (!is_array($parts)) { + continue; + } + + $scheme = $parts['scheme'] ?? ''; + $host = $parts['host'] ?? ''; + if (in_array($scheme, ['http', 'https'], true) && '' !== $host) { + $url = $scheme.'://'.$host; + if (isset($parts['port'])) { + $url .= ':'.$parts['port']; + } + + return $url; + } + } + + $domain = $this->lookupEnv('COMPOSE_DOMAIN'); + if (null !== $domain && '' !== $domain) { + return 'https://'.$domain; + } + + return null; + } + + /** + * @return array|null + */ + private function fetchConfig(string $url, SymfonyStyle $errIo): ?array + { + try { + return $this->httpClient->request('GET', $url, [ + // Cap the wait so an unreachable host fails predictably + // rather than hanging on the default connect/response timeout. + 'timeout' => 10, + 'max_duration' => 10, + ])->toArray(); + } catch (\Throwable $throwable) { + $errIo->warning(sprintf('Could not fetch %s (%s). The corresponding section is omitted — pass --app-url to point at the public 2.x URL, or use app:utils:convert-config-json-to-env in 3.x with a config.json file.', $url, $throwable->getMessage())); + + return null; + } + } + + /** + * Converts a 2.x admin config.json to the 3.x ADMIN_* variables. + * + * The 2.x file is generated by confd, which renders booleans and null + * as the strings "true"/"false"/"null" — both the JSON and string + * representations are accepted here. + * + * @param array $config + * + * @return list + */ + private function convertAdminConfig(array $config): array + { + $lines = []; + + $rejseplanenApiKey = $config['rejseplanenApiKey'] ?? null; + if (!$this->isNullish($rejseplanenApiKey) && is_scalar($rejseplanenApiKey)) { + $lines[] = ['ADMIN_REJSEPLANEN_APIKEY', (string) $rejseplanenApiKey]; + } + + $showScreenStatus = $this->toBool($config['showScreenStatus'] ?? null); + if (null !== $showScreenStatus) { + $lines[] = ['ADMIN_SHOW_SCREEN_STATUS', $showScreenStatus ? 'true' : 'false']; + } + + $touchButtonRegions = $this->toBool($config['touchButtonRegions'] ?? null); + if (null !== $touchButtonRegions) { + $lines[] = ['ADMIN_TOUCH_BUTTON_REGIONS', $touchButtonRegions ? 'true' : 'false']; + } + + $loginMethods = $config['loginMethods'] ?? null; + if (is_array($loginMethods)) { + $converted = []; + foreach ($loginMethods as $method) { + if (!is_array($method)) { + continue; + } + // In 2.x a method with enabled=false is hidden; in 3.x + // presence in ADMIN_LOGIN_METHODS means enabled, so disabled + // methods are dropped and the obsolete key stripped. + if (array_key_exists('enabled', $method) && false === $this->toBool($method['enabled'])) { + continue; + } + unset($method['enabled']); + $converted[] = $method; + } + $lines[] = ['ADMIN_LOGIN_METHODS', json_encode($converted, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)]; + } + + // 2.x configured a preview client URL; in 3.x this is a boolean + // toggle for the built-in preview. + if (!$this->isNullish($config['previewClient'] ?? null)) { + $lines[] = ['ADMIN_ENHANCED_PREVIEW', 'true']; + } + + return $lines; + } + + /** + * Converts a 2.x client config.json to the 3.x CLIENT_* variables. + * + * @param array $config + * + * @return list + */ + private function convertClientConfig(array $config): array + { + $lines = []; + + $scalars = [ + 'loginCheckTimeout' => 'CLIENT_LOGIN_CHECK_TIMEOUT', + 'refreshTokenTimeout' => 'CLIENT_REFRESH_TOKEN_TIMEOUT', + 'releaseTimestampIntervalTimeout' => 'CLIENT_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT', + 'schedulingInterval' => 'CLIENT_SCHEDULING_INTERVAL', + ]; + foreach ($scalars as $key => $name3x) { + $value = $config[$key] ?? null; + if (!$this->isNullish($value) && is_scalar($value)) { + $lines[] = [$name3x, (string) $value]; + } + } + + $dataStrategy = $config['dataStrategy'] ?? null; + $dataStrategyConfig = is_array($dataStrategy) ? ($dataStrategy['config'] ?? null) : null; + $pullInterval = is_array($dataStrategyConfig) ? ($dataStrategyConfig['interval'] ?? null) : null; + if (!$this->isNullish($pullInterval) && is_scalar($pullInterval)) { + $lines[] = ['CLIENT_PULL_STRATEGY_INTERVAL', (string) $pullInterval]; + } + + $colorScheme = $config['colorScheme'] ?? null; + if (is_array($colorScheme)) { + $lines[] = ['CLIENT_COLOR_SCHEME', json_encode($colorScheme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)]; + } + + $debug = $this->toBool($config['debug'] ?? null); + if (null !== $debug) { + $lines[] = ['CLIENT_DEBUG', $debug ? 'true' : 'false']; + } + + return $lines; + } + + /** + * Collects loaded variables the Symfony application cannot read + * (container/orchestration config), keyed by name, with the advice on + * where they belong in a 3.x deployment. + * + * @return array name => [value, advice] + */ + private function collectInfraEnv(): array + { + $found = []; + + // Real process env (compose environment blocks land here) merged + // with the dotenv-loaded vars in $_ENV. + $candidates = array_merge(getenv(), $_ENV); + + /** @var mixed $value */ + foreach ($candidates as $name => $value) { + if (!is_string($value) || in_array($name, self::INFRA_NOISE, true)) { + continue; + } + foreach (self::INFRA_PREFIXES as $prefix => $advice) { + if (str_starts_with($name, $prefix)) { + $found[$name] = [$value, $advice]; + break; + } + } + } + + ksort($found); + + return $found; + } + + /** + * @param list}> $sections + * @param array $infra + */ + private function renderEnv(array $sections, array $infra): string + { + $out = []; + + foreach ($sections as $section) { + $out[] = sprintf('###> %s ###', $section['title']); + foreach ($section['lines'] as [$name, $value]) { + $out[] = $name.'='.$this->quoteEnvValue($value); + } + $out[] = sprintf('###< %s ###', $section['title']); + $out[] = ''; + } + + foreach ($this->renderInfraAdvisory($infra) as $line) { + $out[] = '' === $line ? '' : '# '.$line; + } + + return rtrim(implode(PHP_EOL, $out)).PHP_EOL; + } + + /** + * @param list}> $sections + * @param array $infra + */ + private function renderCompose(array $sections, array $infra): string + { + $out = []; + $out[] = '# Merge into the api service in your compose file.'; + $out[] = 'environment:'; + + foreach ($sections as $section) { + $out[] = ' # '.$section['title']; + foreach ($section['lines'] as [$name, $value]) { + // YAML single-quoted scalar; embedded single quotes are + // escaped by doubling. + $out[] = sprintf(" - '%s=%s'", $name, str_replace("'", "''", $value)); + } + } + + $out[] = ''; + foreach ($this->renderInfraAdvisory($infra) as $line) { + $out[] = '' === $line ? '' : '# '.$line; + } + + return rtrim(implode(PHP_EOL, $out)).PHP_EOL; + } + + /** + * @param array $infra + * + * @return list + */ + private function renderInfraAdvisory(array $infra): array + { + if ([] === $infra) { + return []; + } + + $lines = [ + 'The following loaded variables are NOT read by the Symfony application', + 'and must not go in the application env. They configure the container', + 'stack — put them where noted:', + ]; + + $grouped = []; + foreach ($infra as $name => [$value, $advice]) { + $grouped[$advice][] = sprintf('%s=%s', $name, $value); + } + + foreach ($grouped as $advice => $vars) { + $lines[] = ''; + $lines[] = $advice.':'; + foreach ($vars as $var) { + $lines[] = ' '.$var; + } + } + + return $lines; + } + + /** + * Quotes a value for use in a dotenv file when needed. + */ + private function quoteEnvValue(string $value): string + { + if (1 === preg_match('#^[A-Za-z0-9_./:%@+,=~^?&-]*$#', $value)) { + return $value; + } + + if (!str_contains($value, "'")) { + return "'".$value."'"; + } + + return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '\\$'], $value).'"'; + } + + private function isNullish(mixed $value): bool + { + return null === $value || '' === $value || 'null' === $value; + } + + /** + * Interprets JSON and confd-rendered ("true"/"false") booleans. + */ + private function toBool(mixed $value): ?bool + { + if (is_bool($value)) { + return $value; + } + + if (is_string($value)) { + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } + + return null; + } +} diff --git a/src/Entity/ScreenLayout.php b/src/Entity/ScreenLayout.php index 89b66e5af..fea344753 100644 --- a/src/Entity/ScreenLayout.php +++ b/src/Entity/ScreenLayout.php @@ -17,7 +17,7 @@ #[ORM\Entity(repositoryClass: ScreenLayoutRepository::class)] #[ORM\EntityListeners([\App\EventListener\ScreenLayoutDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_layout_changed_idx')] class ScreenLayout extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface { use MultiTenantTrait; diff --git a/src/Entity/ScreenLayoutRegions.php b/src/Entity/ScreenLayoutRegions.php index 0c5466511..e27a7cfda 100644 --- a/src/Entity/ScreenLayoutRegions.php +++ b/src/Entity/ScreenLayoutRegions.php @@ -17,7 +17,7 @@ #[ORM\Entity(repositoryClass: ScreenLayoutRegionsRepository::class)] #[ORM\EntityListeners([\App\EventListener\ScreenLayoutRegionsDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_layout_regions_changed_idx')] class ScreenLayoutRegions extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface { use MultiTenantTrait; diff --git a/src/Entity/Template.php b/src/Entity/Template.php index b85245388..1539ed124 100644 --- a/src/Entity/Template.php +++ b/src/Entity/Template.php @@ -17,7 +17,7 @@ #[ORM\Entity(repositoryClass: TemplateRepository::class)] #[ORM\EntityListeners([\App\EventListener\TemplateDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'template_changed_idx')] class Template extends AbstractBaseEntity implements MultiTenantInterface, RelationsChecksumInterface { use MultiTenantTrait; diff --git a/src/Entity/Tenant/Feed.php b/src/Entity/Tenant/Feed.php index 4c7ed2cd6..70cc438e5 100644 --- a/src/Entity/Tenant/Feed.php +++ b/src/Entity/Tenant/Feed.php @@ -11,7 +11,7 @@ #[ORM\Entity(repositoryClass: FeedRepository::class)] #[ORM\EntityListeners([\App\EventListener\FeedDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'feed_changed_idx')] class Feed extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/FeedSource.php b/src/Entity/Tenant/FeedSource.php index b580bfe7d..26edbb3ca 100644 --- a/src/Entity/Tenant/FeedSource.php +++ b/src/Entity/Tenant/FeedSource.php @@ -14,7 +14,7 @@ #[ORM\Entity(repositoryClass: FeedSourceRepository::class)] #[ORM\EntityListeners([\App\EventListener\FeedSourceDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'feed_source_changed_idx')] class FeedSource extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/Media.php b/src/Entity/Tenant/Media.php index 1b285648e..dff8894c0 100644 --- a/src/Entity/Tenant/Media.php +++ b/src/Entity/Tenant/Media.php @@ -18,7 +18,7 @@ #[Vich\Uploadable] #[ORM\Entity(repositoryClass: MediaRepository::class)] #[ORM\EntityListeners([\App\EventListener\MediaDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'media_changed_idx')] class Media extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/Playlist.php b/src/Entity/Tenant/Playlist.php index d565ff84a..f81f19731 100644 --- a/src/Entity/Tenant/Playlist.php +++ b/src/Entity/Tenant/Playlist.php @@ -17,7 +17,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: PlaylistRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'playlist_changed_idx')] class Playlist extends AbstractTenantScopedEntity implements MultiTenantInterface, RelationsChecksumInterface { use EntityPublishedTrait; diff --git a/src/Entity/Tenant/PlaylistScreenRegion.php b/src/Entity/Tenant/PlaylistScreenRegion.php index 97a7a92b8..59cc1282c 100644 --- a/src/Entity/Tenant/PlaylistScreenRegion.php +++ b/src/Entity/Tenant/PlaylistScreenRegion.php @@ -12,7 +12,7 @@ #[ORM\UniqueConstraint(name: 'unique_playlist_screen_region', columns: ['playlist_id', 'screen_id', 'region_id'])] #[ORM\Entity(repositoryClass: PlaylistScreenRegionRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'playlist_screen_region_changed_idx')] class PlaylistScreenRegion extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/PlaylistSlide.php b/src/Entity/Tenant/PlaylistSlide.php index ae8f0a1da..a3a444909 100644 --- a/src/Entity/Tenant/PlaylistSlide.php +++ b/src/Entity/Tenant/PlaylistSlide.php @@ -10,7 +10,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: PlaylistSlideRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'playlist_slide_changed_idx')] class PlaylistSlide extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/Screen.php b/src/Entity/Tenant/Screen.php index 9fde5553e..d735c09c1 100644 --- a/src/Entity/Tenant/Screen.php +++ b/src/Entity/Tenant/Screen.php @@ -15,7 +15,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_changed_idx')] class Screen extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/ScreenCampaign.php b/src/Entity/Tenant/ScreenCampaign.php index 1101cbf31..599f8ac91 100644 --- a/src/Entity/Tenant/ScreenCampaign.php +++ b/src/Entity/Tenant/ScreenCampaign.php @@ -10,7 +10,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenCampaignRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_campaign_changed_idx')] class ScreenCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/ScreenGroup.php b/src/Entity/Tenant/ScreenGroup.php index 228ebe82d..f36047614 100644 --- a/src/Entity/Tenant/ScreenGroup.php +++ b/src/Entity/Tenant/ScreenGroup.php @@ -13,7 +13,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenGroupRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_group_changed_idx')] class ScreenGroup extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/Tenant/ScreenGroupCampaign.php b/src/Entity/Tenant/ScreenGroupCampaign.php index 8337d3795..8d530575b 100644 --- a/src/Entity/Tenant/ScreenGroupCampaign.php +++ b/src/Entity/Tenant/ScreenGroupCampaign.php @@ -10,7 +10,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ScreenGroupCampaignRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'screen_group_campaign_changed_idx')] class ScreenGroupCampaign extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use RelationsChecksumTrait; diff --git a/src/Entity/Tenant/Slide.php b/src/Entity/Tenant/Slide.php index 82e1fbd7f..43e5e933f 100644 --- a/src/Entity/Tenant/Slide.php +++ b/src/Entity/Tenant/Slide.php @@ -15,7 +15,7 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: SlideRepository::class)] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'slide_changed_idx')] class Slide extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityPublishedTrait; diff --git a/src/Entity/Tenant/Theme.php b/src/Entity/Tenant/Theme.php index 915e48c4d..25af364b7 100644 --- a/src/Entity/Tenant/Theme.php +++ b/src/Entity/Tenant/Theme.php @@ -14,7 +14,7 @@ #[ORM\Entity(repositoryClass: ThemeRepository::class)] #[ORM\EntityListeners([\App\EventListener\ThemeDoctrineEventListener::class])] -#[ORM\Index(fields: ['changed'], name: 'changed_idx')] +#[ORM\Index(fields: ['changed'], name: 'theme_changed_idx')] class Theme extends AbstractTenantScopedEntity implements RelationsChecksumInterface { use EntityTitleDescriptionTrait; diff --git a/src/Entity/User.php b/src/Entity/User.php index 6f5b191a9..8e3a1932b 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -18,6 +18,7 @@ use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\Table(name: '`user`')] class User extends AbstractBaseEntity implements UserInterface, PasswordAuthenticatedUserInterface, \JsonSerializable, TenantScopedUserInterface { #[Assert\NotBlank] diff --git a/src/Feed/BrndFeedType.php b/src/Feed/BrndFeedType.php index 66b9e6fec..06818ef3b 100644 --- a/src/Feed/BrndFeedType.php +++ b/src/Feed/BrndFeedType.php @@ -29,6 +29,8 @@ class BrndFeedType implements FeedTypeInterface */ private const string BRND_API_TIMEZONE = 'Europe/Copenhagen'; + private const string BRND_API_VERSION_WITH_ID_FILTERING = '2.0'; + public function __construct( private readonly FeedService $feedService, private readonly ApiClient $apiClient, @@ -40,7 +42,7 @@ public function getAdminFormOptions(FeedSource $feedSource): array { $feedEntryRecipients = $this->feedService->getFeedSourceConfigUrl($feedSource, 'sport-center'); - return [ + $options = [ [ 'key' => 'brnd-sport-center-id', 'input' => 'input', @@ -49,23 +51,28 @@ public function getAdminFormOptions(FeedSource $feedSource): array 'label' => 'Sportcenter ID', 'formGroupClasses' => 'mb-3', ], - [ + ]; + + if ($this->supportsIdFiltering($feedSource)) { + $options[] = [ 'key' => 'brnd-area', 'input' => 'input', 'type' => 'text', 'name' => 'area', - 'label' => 'Område', + 'label' => 'Område ID', 'formGroupClasses' => 'mb-3', - ], - [ + ]; + $options[] = [ 'key' => 'brnd-facility', 'input' => 'input', 'type' => 'text', 'name' => 'facility', - 'label' => 'Facilitet', + 'label' => 'Facilitet ID', 'formGroupClasses' => 'mb-3', - ], - ]; + ]; + } + + return $options; } public function getData(Feed $feed): array @@ -94,12 +101,13 @@ public function getData(Feed $feed): array return $result; } + $supportsIdFiltering = $this->supportsIdFiltering($feedSource); $areaFilterNormalized = self::normalizeFilterValue($areaFilter); $facilityFilterNormalized = self::normalizeFilterValue($facilityFilter); $bookings = $this->apiClient->getInfomonitorBookingsDetails($feedSource, $sportCenterId); - $result['bookings'] = array_reduce($bookings, function (array $carry, mixed $booking) use ($areaFilterNormalized, $facilityFilterNormalized): array { + $result['bookings'] = array_reduce($bookings, function (array $carry, mixed $booking) use ($areaFilterNormalized, $facilityFilterNormalized, $supportsIdFiltering): array { if (!is_array($booking)) { return $carry; } @@ -111,18 +119,18 @@ public function getData(Feed $feed): array return $carry; } - // Bail out if area filter applies and booking area does not match. - if ('' !== $areaFilterNormalized) { - $bookingArea = self::normalizeFilterValue($parsedBooking['area'] ?? ''); - if ($bookingArea !== $areaFilterNormalized) { + // Bail out if area filter applies and booking area ID does not match. + if ($supportsIdFiltering && '' !== $areaFilterNormalized) { + $bookingAreaId = self::normalizeFilterValue($parsedBooking['areaId'] ?? ''); + if ($bookingAreaId !== $areaFilterNormalized) { return $carry; } } - // Bail out if facility filter applies and booking facility does not match. - if ('' !== $facilityFilterNormalized) { - $bookingFacility = self::normalizeFilterValue($parsedBooking['facility'] ?? ''); - if ($bookingFacility !== $facilityFilterNormalized) { + // Bail out if facility filter applies and booking facility ID does not match. + if ($supportsIdFiltering && '' !== $facilityFilterNormalized) { + $bookingFacilityId = self::normalizeFilterValue($parsedBooking['facilityId'] ?? ''); + if ($bookingFacilityId !== $facilityFilterNormalized) { return $carry; } } @@ -142,13 +150,15 @@ public function getData(Feed $feed): array private static function normalizeFilterValue(mixed $value): string { + if (is_int($value) || is_float($value)) { + return (string) $value; + } + if (!is_string($value)) { return ''; } - $value = trim($value); - - return strtolower($value); + return trim($value); } private function parseBrndBooking(array $booking): array @@ -195,6 +205,8 @@ private function parseBrndBooking(array $booking): array 'complex' => $booking['anlæg'] ?? '', 'area' => $booking['område'] ?? '', 'facility' => $booking['facilitet'] ?? '', + 'areaId' => $booking['områdeId'] ?? '', + 'facilityId' => $booking['facilitetsId'] ?? '', 'activity' => $booking['aktivitet'] ?? '', 'team' => $booking['hold'] ?? '', 'status' => $booking['status'] ?? '', @@ -204,6 +216,17 @@ private function parseBrndBooking(array $booking): array ]; } + private function supportsIdFiltering(FeedSource $feedSource): bool + { + $secrets = $feedSource->getSecrets(); + + if (!is_array($secrets)) { + return false; + } + + return self::BRND_API_VERSION_WITH_ID_FILTERING === ($secrets['api_version'] ?? '1.0'); + } + public function getConfigOptions(Request $request, FeedSource $feedSource, string $name): ?array { return null; diff --git a/tests/Command/ConvertEnvTo3xCommandTest.php b/tests/Command/ConvertEnvTo3xCommandTest.php new file mode 100644 index 000000000..2800e541c --- /dev/null +++ b/tests/Command/ConvertEnvTo3xCommandTest.php @@ -0,0 +1,271 @@ + */ + private array $envBackup = []; + + protected function tearDown(): void + { + foreach ($this->envBackup as $name => $original) { + if (false === $original) { + unset($_ENV[$name]); + } else { + $_ENV[$name] = $original; + } + } + $this->envBackup = []; + + parent::tearDown(); + } + + public function testEveryEnvVarInDotEnvIsCovered(): void + { + $dotEnv = file_get_contents(dirname(__DIR__, 2).'/.env'); + $this->assertNotFalse($dotEnv); + + $this->assertGreaterThan(0, preg_match_all('/^([A-Z][A-Z0-9_]*)=/m', $dotEnv, $matches)); + + $covered = array_merge(array_keys(ConvertEnvTo3xCommand::ENV_MAP), ConvertEnvTo3xCommand::NON_APP_ENV); + foreach ($matches[1] as $name) { + $this->assertContains($name, $covered, sprintf('Env var "%s" from .env is not covered by %s::ENV_MAP — add it with its 3.x name (or to NON_APP_ENV if it is not application config).', $name, ConvertEnvTo3xCommand::class)); + } + } + + public function testRenamesLoadedEnvVarsTo3xNames(): void + { + $this->setEnv('APP_ENV', 'prod'); + $this->setEnv('APP_DEFAULT_DATE_FORMAT', 'Y-m-d'); + $this->setEnv('APP_ACTIVATION_CODE_EXPIRE_INTERNAL', 'P2D'); + $this->setEnv('APP_KEY_VAULT_SOURCE', 'ENVIRONMENT'); + $this->setEnv('APP_KEY_VAULT_JSON', '{}'); + $this->setEnv('HTTP_CLIENT_LOG_LEVEL', 'error'); + $this->setEnv('DATABASE_URL', 'mysql://db:db@mariadb:3306/db?serverVersion=10.11.5-MariaDB'); + + $tester = $this->createCommandTester(new MockHttpClient()); + $exitCode = $tester->execute(['--output' => 'env', '--skip-config-json' => true]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $display = $tester->getDisplay(); + + $this->assertStringContainsString('APP_ENV=prod', $display); + $this->assertStringContainsString('DEFAULT_DATE_FORMAT=Y-m-d', $display); + $this->assertStringContainsString('ACTIVATION_CODE_EXPIRE_INTERVAL=P2D', $display); + $this->assertStringContainsString('KEY_VAULT_SOURCE=ENVIRONMENT', $display); + $this->assertStringContainsString('KEY_VAULT_JSON=', $display); + $this->assertStringContainsString('LOG_LEVEL_OUTBOUND_HTTP=error', $display); + $this->assertStringContainsString('DATABASE_URL=mysql://db:db@mariadb:3306/db?serverVersion=10.11.5-MariaDB', $display); + + $this->assertStringNotContainsString('APP_DEFAULT_DATE_FORMAT=', $display); + $this->assertStringNotContainsString('APP_ACTIVATION_CODE_EXPIRE_INTERNAL=', $display); + $this->assertStringNotContainsString('HTTP_CLIENT_LOG_LEVEL=', $display); + } + + public function testConvertsAdminAndClientConfigJson(): void + { + $requestedUrls = []; + $client = new MockHttpClient(function (string $method, string $url) use (&$requestedUrls): MockResponse { + $requestedUrls[] = $url; + + // The bodies mirror what confd renders in 2.x: booleans and + // null as strings. + if (str_contains($url, '/admin/config.json')) { + return new MockResponse((string) json_encode([ + 'api' => '/', + 'touchButtonRegions' => 'true', + 'previewClient' => 'https://preview.example.com', + 'showScreenStatus' => 'false', + 'rejseplanenApiKey' => 'null', + 'loginMethods' => [ + ['type' => 'oidc', 'enabled' => 'true', 'provider' => 'internal', 'label' => null, 'icon' => 'faCity'], + ['type' => 'oidc', 'enabled' => 'false', 'provider' => 'external', 'label' => '', 'icon' => 'mitID'], + ['type' => 'username-password', 'enabled' => 'true', 'provider' => 'username-password', 'label' => null], + ], + ]), ['response_headers' => ['content-type' => 'application/json']]); + } + + return new MockResponse((string) json_encode([ + 'apiEndpoint' => '/', + 'loginCheckTimeout' => 20000, + 'configFetchInterval' => 600000, + 'refreshTokenTimeout' => 60000, + 'releaseTimestampIntervalTimeout' => 600000, + 'dataStrategy' => ['type' => 'pull', 'config' => ['interval' => 30000]], + 'colorScheme' => ['type' => 'library', 'lat' => 56.0, 'lng' => 10.0], + 'schedulingInterval' => 60000, + 'debug' => false, + ]), ['response_headers' => ['content-type' => 'application/json']]); + }); + + $tester = $this->createCommandTester($client); + $exitCode = $tester->execute(['--output' => 'env', '--app-url' => 'https://display.example.com']); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertSame([ + 'https://display.example.com/admin/config.json', + 'https://display.example.com/client/config.json', + ], $requestedUrls); + + $display = $tester->getDisplay(); + + $this->assertStringContainsString('ADMIN_TOUCH_BUTTON_REGIONS=true', $display); + $this->assertStringContainsString('ADMIN_SHOW_SCREEN_STATUS=false', $display); + $this->assertStringContainsString('ADMIN_ENHANCED_PREVIEW=true', $display); + // "null" means not configured. + $this->assertStringNotContainsString('ADMIN_REJSEPLANEN_APIKEY', $display); + // The disabled external method is dropped, and the obsolete + // "enabled" key stripped from the remaining methods. + $this->assertStringContainsString('ADMIN_LOGIN_METHODS=', $display); + $this->assertStringNotContainsString('"provider":"external"', $display); + $this->assertStringNotContainsString('"enabled"', $display); + $this->assertStringContainsString('"provider":"internal"', $display); + $this->assertStringContainsString('"provider":"username-password"', $display); + + $this->assertStringContainsString('CLIENT_LOGIN_CHECK_TIMEOUT=20000', $display); + $this->assertStringContainsString('CLIENT_REFRESH_TOKEN_TIMEOUT=60000', $display); + $this->assertStringContainsString('CLIENT_RELEASE_TIMESTAMP_INTERVAL_TIMEOUT=600000', $display); + $this->assertStringContainsString('CLIENT_SCHEDULING_INTERVAL=60000', $display); + $this->assertStringContainsString('CLIENT_PULL_STRATEGY_INTERVAL=30000', $display); + $this->assertStringContainsString('"type":"library"', $display); + $this->assertStringContainsString('CLIENT_DEBUG=false', $display); + } + + public function testInfersAppUrlFromOidcRedirectUri(): void + { + $this->setEnv('INTERNAL_OIDC_REDIRECT_URI', 'https://display.example.com/admin/redirect'); + + $requestedUrls = []; + $client = new MockHttpClient(function (string $method, string $url) use (&$requestedUrls): MockResponse { + $requestedUrls[] = $url; + + return new MockResponse('{}', ['response_headers' => ['content-type' => 'application/json']]); + }); + + $tester = $this->createCommandTester($client); + $exitCode = $tester->execute(['--output' => 'env']); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertSame([ + 'https://display.example.com/admin/config.json', + 'https://display.example.com/client/config.json', + ], $requestedUrls); + } + + public function testWarnsWhenAppUrlCannotBeInferred(): void + { + // Placeholder values (the committed .env defaults) must not be + // mistaken for a URL. + $this->setEnv('INTERNAL_OIDC_REDIRECT_URI', 'INTERNAL_OIDC_REDIRECT_URI'); + $this->setEnv('EXTERNAL_OIDC_REDIRECT_URI', 'EXTERNAL_OIDC_REDIRECT_URI'); + $this->setEnv('OIDC_CLI_REDIRECT', 'APP_CLI_REDIRECT_URI'); + $this->setEnv('COMPOSE_DOMAIN', ''); + + $tester = $this->createCommandTester(new MockHttpClient()); + $exitCode = $tester->execute(['--output' => 'env']); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Could not infer the 2.x app URL', $tester->getDisplay(true)); + } + + public function testStdoutStaysCleanWhenFetchFails(): void + { + $this->setEnv('APP_ENV', 'prod'); + + // Every request fails — the warning must go to stderr so stdout can + // be redirected to a file on the host from outside the container. + $client = new MockHttpClient(fn () => new MockResponse('Service Unavailable', ['http_code' => 503])); + + $tester = $this->createCommandTester($client); + $exitCode = $tester->execute( + ['--output' => 'env', '--app-url' => 'https://display.example.com'], + ['capture_stderr_separately' => true], + ); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Could not fetch', $tester->getErrorOutput()); + + $display = $tester->getDisplay(); + $this->assertStringContainsString('APP_ENV=prod', $display); + $this->assertStringNotContainsString('Could not fetch', $display); + // Parseable as dotenv: nothing but KEY=value, comments and blanks. + foreach (explode("\n", rtrim($display)) as $line) { + $this->assertMatchesRegularExpression('/^$|^#|^[A-Z][A-Z0-9_]*=/', $line); + } + } + + public function testComposeOutput(): void + { + $this->setEnv('APP_ENV', 'prod'); + + $tester = $this->createCommandTester(new MockHttpClient()); + $exitCode = $tester->execute(['--output' => 'compose', '--skip-config-json' => true]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $display = $tester->getDisplay(); + + $this->assertStringContainsString('environment:', $display); + $this->assertStringContainsString(" - 'APP_ENV=prod'", $display); + } + + public function testWritesToFile(): void + { + $this->setEnv('APP_ENV', 'prod'); + + $file = tempnam(sys_get_temp_dir(), 'env3x'); + $this->assertNotFalse($file); + + try { + $tester = $this->createCommandTester(new MockHttpClient()); + $exitCode = $tester->execute(['--output' => 'env', '--file' => $file, '--skip-config-json' => true]); + + $this->assertSame(Command::SUCCESS, $exitCode); + + $written = file_get_contents($file); + $this->assertNotFalse($written); + $this->assertStringContainsString('APP_ENV=prod', $written); + } finally { + unlink($file); + } + } + + public function testRejectsInvalidOutputFormat(): void + { + $tester = $this->createCommandTester(new MockHttpClient()); + + $this->assertSame(Command::INVALID, $tester->execute(['--output' => 'json'])); + } + + public function testRejectsFileWithScreenOutput(): void + { + $tester = $this->createCommandTester(new MockHttpClient()); + + $this->assertSame(Command::INVALID, $tester->execute(['--file' => '/tmp/foo'])); + } + + private function createCommandTester(HttpClientInterface $httpClient): CommandTester + { + return new CommandTester(new ConvertEnvTo3xCommand($httpClient)); + } + + private function setEnv(string $name, string $value): void + { + if (!array_key_exists($name, $this->envBackup)) { + $original = array_key_exists($name, $_ENV) && is_string($_ENV[$name]) ? $_ENV[$name] : false; + $this->envBackup[$name] = $original; + } + + $_ENV[$name] = $value; + } +}