diff --git a/README.md b/README.md index ad5b3b8..1ee9c61 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,22 @@ The back-office area also uses `PRESTAFLOW_BO_URL` (admin URL), `PRESTAFLOW_BO_E CSS transitions are frozen during back-office captures (`protected ?bool $freezeTransitions`; `null` = on for `bo`, off for `fo`). See `src/Tests/Suites/Visual/BackOffice.php`. +### Shop and back-office URLs in the suite file + +A visual suite may pin its own URLs (the PrestaFlow app writes them from its editor). A non-empty value replaces `PRESTAFLOW_FO_URL` / `PRESTAFLOW_BO_URL` (or the URLs sent by the app) before the first checkpoint: + +```php +protected ?string $shopUrl = 'http://localhost:8093/'; +protected ?string $backOfficeUrl = 'admin123/'; // relative: appended to the effective shop URL +``` + +`null` or empty: the environment URL. Rules: + +- Only `http(s)` URLs with a host; no whitespace; `$shopUrl` takes no query (`?`) or fragment (`#`). +- A relative back-office URL is appended to the effective shop URL: the shop path is kept, its query and fragment are dropped (`http://x.test/shop/` + `admin123/` gives `http://x.test/shop/admin123/`). `//host/admin` takes the shop URL scheme. A relative path must be safe: no `?`/`#`, no segment starting with a dot, no dot in the first segment unless it starts with `/` (`shop.test/admin` is refused, not guessed). It needs an `http(s)` shop URL, else it is refused. +- When only `$shopUrl` is set, a back-office URL that the library completed from the environment shop URL (relative `PRESTAFLOW_BO_URL`, or the default `admin-dev/`) follows the new shop URL; an absolute `PRESTAFLOW_BO_URL`, or one sent by the app, is kept. +- Credentials (`user:pass@`) are refused at launch, without echoing the URL: use `PRESTAFLOW_BASIC_USER` / `PRESTAFLOW_BASIC_PASS`. The same goes for every other refusal: the message names the property, never the value. + ### Visual picker API The visual picker of the PrestaFlow app captures a page and lists its elements, so that a user can pick a checkpoint selector. It uses the run's own code, under short ceilings. diff --git a/docs/superpowers/specs/2026-10-07-suite-urls-design.md b/docs/superpowers/specs/2026-10-07-suite-urls-design.md new file mode 100644 index 0000000..8dafae9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-07-suite-urls-design.md @@ -0,0 +1,66 @@ +# URL de la boutique et du back-office dans le fichier de suite visuelle : design (lib) + +Date : 2026-10-07 +Statut : validé, à implémenter + +Partie lib de la spec de l'app (`docs/superpowers/specs/2026-10-07-visual-suite-urls-design.md` +du dépôt `PrestaFlow/app`), recopiée ici au départ de la branche `feat/suite-urls`. + +## Problème + +L'URL de la boutique et l'URL du back-office d'une suite visuelle viennent des globals +(`PRESTAFLOW_FO_URL` / `PRESTAFLOW_BO_URL` en CLI, ou celles que l'app passe au lancement). +L'éditeur de l'app permet de les choisir par suite, mais le fichier PHP n'en garde pas trace : +un run en CI avec la lib seule ignore l'URL choisie. + +## Décisions + +- `VisualTestsSuite` gagne : + - `protected ?string $shopUrl = null;` + - `protected ?string $backOfficeUrl = null;` +- Au lancement de la suite (avant le premier checkpoint, et avant l'import des pages, qui copient + les globals), une propriété chaîne non vide remplace l'URL correspondante des globals + (`FO.URL`, `BO.URL`). `null`, vide ou blanche : URL reçue. +- URL BO relative (ex. `admin123/`, sans schéma) : complétée par l'URL FO effective (propriété, + sinon globals) avec la règle de l'app (`App\Support\BackOfficeUrl::resolve`) : URL FO sans + requête ni fragment, puis le chemin relatif ; `//hôte/admin` prend le schéma de l'URL FO ; + toujours terminée par « / ». Le chemin d'une BO relative doit être sûr (règle de + `BackOfficeUrl::isValid` : pas de blanc, `?` ni `#`, pas de segment commençant par un point, pas de + point dans le premier segment sans « / » initial) ; sinon refusée, jamais devinée. +- Avec `$shopUrl` seule : une URL BO que `loadGlobals()` a complétée sur l'URL FO de l'environnement + (`PRESTAFLOW_BO_URL` relatif, ou défaut `admin-dev/`) est recalculée sur la nouvelle URL FO + (valeur brute mémorisée par `loadGlobals()`, remise à null par `setGlobals()` quand `BO` est fourni) ; + une BO absolue ou fournie par l'app reste telle quelle. +- Refusées au lancement (`InvalidArgumentException`), avec un message qui nomme la propriété mais + jamais la valeur : + - identifiants dans l'URL (`scheme://user:pass@…`, `//user@…`) : l'authentification HTTP passe + par `PRESTAFLOW_BASIC_USER` / `PRESTAFLOW_BASIC_PASS` ; + - schéma autre que `http` / `https` (FO sans schéma compris), ou hôte absent ; + - blanc interne ; `?` ou `#` dans `$shopUrl` ; chemin BO relatif non sûr ; + - BO relative (`admin123/`, `//hôte/admin`) sans URL FO http(s) pour la compléter. +- Les suites qui ne déclarent pas ces propriétés gardent exactement leur comportement. + +## API + +- `VisualTestsSuite::applySuiteUrls(): void` : publique, idempotente ; appelée par `init()` et + `openBackOfficeCheckpoint()` (sélecteur visuel de l'app). +- `VisualTestsSuite::resolveBackOfficeUrl(string $backOffice, string $frontOffice): string` : + statique publique. + +## Tests + +`tests/Unit/Visual/VisualTestsSuiteUrlsTest.php`, sans Chrome : + +- suite sans propriété, propriétés vides : globals inchangées ; +- `$shopUrl` prioritaire, visible des pages à leur import, `resolveUrl()` la suit ; +- `$backOfficeUrl` absolue prioritaire ; relative complétée par l'URL FO du fichier, sinon des + globals ; relative au protocole ; +- BO relative sans aucune URL FO refusée ; +- idempotence ; +- `user:pass@` et schéma non `http(s)` refusés sans afficher la valeur ; +- `openBackOfficeCheckpoint()` applique les URL avant d'importer les pages. + +## Livraison + +PR vers `dev`, puis release `dev` → `main` en commits de merge, sur accord. L'app fait ensuite +le bump (`composer.lock` seul). diff --git a/src/Tests/TestsSuite.php b/src/Tests/TestsSuite.php index 8468ddf..0154059 100644 --- a/src/Tests/TestsSuite.php +++ b/src/Tests/TestsSuite.php @@ -265,6 +265,14 @@ public static function resetBrowser(): void protected $draft = false; protected $groups = 'all'; + /** + * Valeur brute de l'URL BO quand loadGlobals() l'a complétée à partir de + * l'URL FO de l'environnement (PRESTAFLOW_BO_URL relative, ou défaut + * `admin-dev/`) ; null sinon (BO absolue, globals fournies). Permet à + * VisualTestsSuite::applySuiteUrls() de suivre une $shopUrl du fichier. + */ + protected ?string $backOfficeRelative = null; + public function __construct(bool $loadGlobals = true, bool $getBrowser = true) { if ($loadGlobals) { @@ -1100,6 +1108,10 @@ public function setLocale(string $locale) public function setGlobals(array $globals = []) { $this->globals = array_merge($this->globals, $globals); + if (isset($globals['BO'])) { + // URL BO fournie : plus de valeur brute à recompléter. + $this->backOfficeRelative = null; + } $this->resolveVersion(); $this->setLocale($this->globals['LOCALE'] ?? 'en'); @@ -1174,7 +1186,9 @@ public function loadGlobals() } $backOfficeUrl = Env::get('PRESTAFLOW_BO_URL', $frontOfficeUrl . 'admin-dev/'); + $this->backOfficeRelative = Env::has('PRESTAFLOW_BO_URL') ? null : 'admin-dev/'; if (!str_starts_with($backOfficeUrl, 'https://') && !str_starts_with($backOfficeUrl, 'http://')) { + $this->backOfficeRelative = (string) $backOfficeUrl; $backOfficeUrl = $frontOfficeUrl . $backOfficeUrl; } if (!str_ends_with($backOfficeUrl, '/')) { diff --git a/src/Tests/VisualTestsSuite.php b/src/Tests/VisualTestsSuite.php index d2a8b99..924974b 100644 --- a/src/Tests/VisualTestsSuite.php +++ b/src/Tests/VisualTestsSuite.php @@ -12,7 +12,9 @@ /** * Suite de régression visuelle pilotée par données. Les sous-classes ne - * déclarent que $devices, $locales et $checkpoints (littéraux, édités par l'app). + * déclarent que $devices, $locales et $checkpoints (littéraux, édités par l'app), + * et au besoin $shopUrl / $backOfficeUrl : URL propres à la suite, prioritaires + * sur celles de l'environnement ou de l'app (applySuiteUrls()). * Une exécution couvre UNE combinaison device × locale (globals DEVICE / LOCALE, * ou env PRESTAFLOW_DEVICE / PRESTAFLOW_LOCALE en CLI). * @@ -52,6 +54,24 @@ abstract class VisualTestsSuite extends TestsSuite /** Gel des transitions CSS pendant la capture ; null = actif en 'bo' seulement (références 'fo' inchangées). */ protected ?bool $freezeTransitions = null; + /** + * URL de la boutique propre à la suite (écrite par l'éditeur de l'app). + * Chaîne non vide : remplace l'URL FO reçue (globals FO.URL : app, ou + * PRESTAFLOW_FO_URL en CLI) avant le premier checkpoint. null ou vide : + * URL de l'environnement. http(s) seulement, sans identifiants (refusée au + * lancement, applySuiteUrls()). + */ + protected ?string $shopUrl = null; + + /** + * URL du back-office propre à la suite. Chaîne non vide : remplace l'URL BO + * reçue (globals BO.URL, ou PRESTAFLOW_BO_URL). Absolue http(s), ou + * relative (ex. `admin123/`) complétée par l'URL FO effective + * (resolveBackOfficeUrl()). Sans identifiants : l'authentification HTTP + * passe par PRESTAFLOW_BASIC_USER / PRESTAFLOW_BASIC_PASS. + */ + protected ?string $backOfficeUrl = null; + /** * État de la connexion BO (une seule tentative par exécution). Protégés et * non privés : run() lie les étapes à la sous-classe concrète. @@ -294,6 +314,8 @@ public function init() if (!in_array($this->area, ['fo', 'bo'], true)) { throw new \InvalidArgumentException(sprintf('%s : area « %s » inconnue (fo, bo)', static::class, $this->area)); } + // Avant l'import : les pages copient les globals à leur construction. + $this->applySuiteUrls(); $this->importVisualPage(); $backOffice = $this->area === 'bo'; $page = $this->pages[$backOffice ? 'backOfficePage' : 'frontOfficePage'] ?? null; @@ -414,6 +436,132 @@ public function init() return $this; } + /** + * Applique $shopUrl et $backOfficeUrl aux globals FO.URL et BO.URL ; une + * propriété null ou vide laisse l'URL reçue. Appelée par init() et + * openBackOfficeCheckpoint(), avant l'import des pages : une page déjà + * importée garde les globals copiées à sa construction. Idempotente. + * + * Avec $shopUrl seule, une URL BO que loadGlobals() a complétée sur l'URL FO de + * l'environnement (PRESTAFLOW_BO_URL relative, ou défaut) suit la nouvelle URL FO ; + * une BO absolue, ou fournie par setGlobals() (app), reste telle quelle. + * + * @throws \InvalidArgumentException URL à identifiants (`user:pass@`), blanc interne, schéma + * autre que http(s), chemin BO relatif non sûr, ou BO relative + * sans URL FO http(s) ; le message ne cite jamais la valeur + */ + public function applySuiteUrls(): void + { + $shop = trim((string) $this->shopUrl); + $backOffice = trim((string) $this->backOfficeUrl); + if ($shop === '' && $backOffice === '') { + return; + } + + $globals = $this->getGlobals(); + if ($shop !== '') { + $this->assertSuiteUrl('shopUrl', $shop, false); + $globals['FO']['URL'] = str_ends_with($shop, '/') ? $shop : $shop.'/'; + } + if ($backOffice !== '') { + $this->assertSuiteUrl('backOfficeUrl', $backOffice, true); + $resolved = self::resolveBackOfficeUrl($backOffice, trim((string) ($globals['FO']['URL'] ?? ''))); + if (preg_match('#^https?://#i', $resolved) !== 1) { + throw new \InvalidArgumentException(sprintf('%s : $backOfficeUrl relative sans URL de la boutique http(s) pour la compléter', static::class)); + } + $globals['BO']['URL'] = $resolved; + } elseif ($shop !== '' && $this->backOfficeRelative !== null) { + $globals['BO']['URL'] = self::resolveBackOfficeUrl($this->backOfficeRelative, $globals['FO']['URL']); + } + $this->globals = $globals; + } + + /** + * URL BO effective, règle de l'app (App\Support\BackOfficeUrl::resolve) : + * absolue http(s) gardée ; relative au protocole (`//hôte/admin`) : schéma + * de l'URL FO ; relative (`admin123/`, `/admin123`) : URL FO sans requête + * ni fragment, puis le chemin. Toujours terminée par « / ». + */ + public static function resolveBackOfficeUrl(string $backOffice, string $frontOffice): string + { + $backOffice = trim($backOffice); + $frontOffice = trim($frontOffice); + if (str_starts_with($backOffice, '//')) { + if (preg_match('#^(https?)://#i', $frontOffice, $m) === 1) { + $backOffice = strtolower($m[1]).':'.$backOffice; + } + } elseif (preg_match('#^https?://#i', $backOffice) !== 1) { + $frontOffice = (string) preg_replace('/[?#].*\z/s', '', $frontOffice); + $backOffice = rtrim($frontOffice, '/').'/'.ltrim($backOffice, '/'); + } + + return str_ends_with($backOffice, '/') ? $backOffice : $backOffice.'/'; + } + + /** Refuse blancs, identifiants, schéma non http(s) et chemin relatif non sûr, sans citer l'URL (elle peut porter un mot de passe). */ + private function assertSuiteUrl(string $property, string $url, bool $relativeAllowed): void + { + if (preg_match('/\s/u', $url) === 1) { + throw new \InvalidArgumentException(sprintf('%s : $%s refusée : espace ou blanc interdit dans l\'URL', static::class, $property)); + } + if (preg_match('#^(?:[a-z][a-z0-9+.\-]*:)?//[^/?\#]*@#i', $url) === 1) { + throw new \InvalidArgumentException(sprintf( + '%s : $%s refusée : identifiants (user:pass@) interdits dans l\'URL ; authentification HTTP par PRESTAFLOW_BASIC_USER / PRESTAFLOW_BASIC_PASS', + static::class, + $property + )); + } + $absolute = preg_match('#^https?://#i', $url) === 1; + if ($absolute) { + if ((string) parse_url($url, PHP_URL_HOST) === '') { + throw new \InvalidArgumentException(sprintf('%s : $%s refusée : URL http(s) avec un hôte attendue', static::class, $property)); + } + if (!$relativeAllowed && preg_match('/[?#]/', $url) === 1) { + throw new \InvalidArgumentException(sprintf('%s : $%s refusée : requête (?) et fragment (#) interdits', static::class, $property)); + } + + return; + } + if ($relativeAllowed && str_starts_with($url, '//')) { + if ((string) parse_url($url, PHP_URL_HOST) === '') { + throw new \InvalidArgumentException(sprintf('%s : $%s refusée : URL http(s) avec un hôte attendue', static::class, $property)); + } + + return; + } + $otherScheme = preg_match('#^[a-z][a-z0-9+.\-]*:#i', $url) === 1; + if ($otherScheme || !$relativeAllowed) { + throw new \InvalidArgumentException(sprintf( + '%s : $%s refusée : URL http:// ou https:// attendue%s', + static::class, + $property, + $relativeAllowed ? ' (ou chemin relatif à la boutique, ex. admin123/)' : '' + )); + } + if (!self::isSafeRelativePath($url)) { + throw new \InvalidArgumentException(sprintf( + '%s : $%s refusée : chemin relatif invalide (ex. admin123/ : sans espace, ni ? ou #, ni segment commençant par un point, ni point dans le premier segment sans « / » initial)', + static::class, + $property + )); + } + } + + /** Chemin relatif sûr : même règle que App\Support\BackOfficeUrl::isValid() hors URL absolue. */ + private static function isSafeRelativePath(string $url): bool + { + if (preg_match('#^/?[A-Za-z0-9_~%-][A-Za-z0-9._~%-]*(/[A-Za-z0-9_~%-][A-Za-z0-9._~%-]*)*/?\z#', $url) !== 1) { + return false; + } + foreach (explode('/', trim($url, '/')) as $segment) { + if (str_starts_with(rawurldecode($segment), '.')) { + return false; + } + } + + return str_starts_with($url, '/') || !str_contains(explode('/', $url)[0], '.'); + } + /** * Déroulé commun aux deux zones, une fois sur la page : stabilité, attente, * scroll, capture. Protégé (et non privé) : appelé depuis les étapes, que @@ -539,7 +687,9 @@ public function openBackOfficeCheckpoint(array $checkpoint, int $loginTimeoutMs throw new \InvalidArgumentException(sprintf('%s : checkpoint « %s » : auth => false capture la page de connexion, menu interdit', static::class, (string) ($cp['name'] ?? ''))); } - // init() n'est pas appelé (il enregistrerait les étapes du run) : pages importées ici. + // init() n'est pas appelé (il enregistrerait les étapes du run) : URL du fichier + // appliquées, puis pages importées ici. + $this->applySuiteUrls(); if (!isset($this->pages['backOfficePage'])) { $this->importVisualPage(); } diff --git a/tests/Unit/Visual/VisualTestsSuiteUrlsTest.php b/tests/Unit/Visual/VisualTestsSuiteUrlsTest.php new file mode 100644 index 0000000..543d449 --- /dev/null +++ b/tests/Unit/Visual/VisualTestsSuiteUrlsTest.php @@ -0,0 +1,389 @@ +imported[] = ['FO' => $this->globals['FO']['URL'] ?? null, 'BO' => $this->globals['BO']['URL'] ?? null]; + } + + public function define(?string $shopUrl, ?string $backOfficeUrl, string $area): void + { + $this->shopUrl = $shopUrl; + $this->backOfficeUrl = $backOfficeUrl; + $this->area = $area; + } + }; + $suite->define($shopUrl, $backOfficeUrl, $area); + $suite->setGlobals(array_merge([ + 'PS_VERSION' => '8.1.0', 'LOCALE' => 'fr', 'PREFIX_LOCALE' => false, 'DEVICE' => 'desktop', + 'FO' => ['URL' => 'https://env.test/', 'EMAIL' => 'c@env.test', 'PASSWD' => 'c'], + 'BO' => ['URL' => 'https://env.test/admin-env/', 'EMAIL' => 'e@env.test', 'PASSWD' => 'e'], + ], $globals)); + + return $suite; + } + + public function test_suite_without_properties_keeps_the_globals(): void + { + $suite = $this->suite(null, null); + $suite->init(); + + $this->assertSame('https://env.test/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('https://env.test/admin-env/', $suite->getGlobals()['BO']['URL']); + $this->assertSame('c@env.test', $suite->getGlobals()['FO']['EMAIL']); + } + + public function test_empty_properties_keep_the_globals(): void + { + $suite = $this->suite('', ' '); + $suite->init(); + + $this->assertSame('https://env.test/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('https://env.test/admin-env/', $suite->getGlobals()['BO']['URL']); + } + + public function test_shop_url_wins_over_the_globals_before_pages_are_imported(): void + { + $suite = $this->suite('http://localhost:8093', null); + $suite->init(); + + $this->assertSame('http://localhost:8093/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('http://localhost:8093/', $suite->imported[0]['FO']); + $this->assertSame('http://localhost:8093/connexion', $suite->resolveUrl('connexion', 'fr')); + // Identifiants client et URL BO : ceux de l'environnement. + $this->assertSame('c@env.test', $suite->getGlobals()['FO']['EMAIL']); + $this->assertSame('https://env.test/admin-env/', $suite->getGlobals()['BO']['URL']); + } + + public function test_absolute_back_office_url_wins_over_the_globals(): void + { + $suite = $this->suite(null, 'https://admin.shop.test/admin123', 'bo'); + $suite->init(); + + $this->assertSame('https://admin.shop.test/admin123/', $suite->getGlobals()['BO']['URL']); + $this->assertSame('https://admin.shop.test/admin123/', $suite->imported[0]['BO']); + $this->assertSame('e@env.test', $suite->getGlobals()['BO']['EMAIL']); + $this->assertSame('https://env.test/', $suite->getGlobals()['FO']['URL']); + } + + public function test_relative_back_office_url_is_completed_by_the_effective_shop_url(): void + { + // URL FO du fichier : chemin de la boutique gardé (même règle que App\Support\BackOfficeUrl::resolve). + $suite = $this->suite('http://localhost:8093/shop', 'admin123/', 'bo'); + $suite->init(); + $this->assertSame('http://localhost:8093/shop/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('http://localhost:8093/shop/admin123/', $suite->getGlobals()['BO']['URL']); + + // Sans URL FO dans le fichier : celle des globals. + $suite = $this->suite(null, '/admin-dev', 'bo'); + $suite->init(); + $this->assertSame('https://env.test/admin-dev/', $suite->getGlobals()['BO']['URL']); + + // Relative au protocole : schéma de l'URL FO effective. + $suite = $this->suite('http://shop.test/', '//admin.shop.test/admin', 'bo'); + $suite->init(); + $this->assertSame('http://admin.shop.test/admin/', $suite->getGlobals()['BO']['URL']); + } + + public function test_relative_back_office_url_without_any_shop_url_is_refused(): void + { + $suite = $this->suite(null, 'admin123/', 'bo', ['FO' => ['URL' => '', 'EMAIL' => '', 'PASSWD' => '']]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$backOfficeUrl'); + $suite->init(); + } + + public function test_applying_twice_gives_the_same_urls(): void + { + $suite = $this->suite('http://localhost:8093/', 'admin123', 'bo'); + $suite->applySuiteUrls(); + $suite->applySuiteUrls(); + + $this->assertSame('http://localhost:8093/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('http://localhost:8093/admin123/', $suite->getGlobals()['BO']['URL']); + } + + public function test_credentials_in_a_url_are_refused_without_quoting_it(): void + { + $cases = [ + ['https://bob:s3cret@shop.test/', null, '$shopUrl'], + ['https://bob@shop.test/', null, '$shopUrl'], + [null, 'https://bob:s3cret@shop.test/admin/', '$backOfficeUrl'], + [null, '//bob:s3cret@shop.test/admin/', '$backOfficeUrl'], + ]; + foreach ($cases as [$shop, $backOffice, $property]) { + try { + $this->suite($shop, $backOffice, 'bo')->init(); + $this->fail('URL à identifiants acceptée : '.$property); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString($property, $e->getMessage()); + $this->assertStringContainsString('PRESTAFLOW_BASIC_', $e->getMessage()); + $this->assertStringNotContainsString('s3cret', $e->getMessage()); + $this->assertStringNotContainsString('bob', $e->getMessage()); + } + } + } + + public function test_a_scheme_other_than_http_is_refused_without_quoting_the_url(): void + { + $cases = [ + ['ftp://shop.test/', null, '$shopUrl'], + ['localhost:8093/', null, '$shopUrl'], + ['shop.test', null, '$shopUrl'], + ['https:///sans-hote', null, '$shopUrl'], + [null, 'javascript:alert(1)', '$backOfficeUrl'], + [null, 'ftp://shop.test/admin/', '$backOfficeUrl'], + ]; + foreach ($cases as [$shop, $backOffice, $property]) { + try { + $this->suite($shop, $backOffice, 'bo')->init(); + $this->fail('URL refusable acceptée : '.$property); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString($property, $e->getMessage()); + $this->assertStringContainsString('http', $e->getMessage()); + $this->assertStringNotContainsString((string) ($shop ?? $backOffice), $e->getMessage()); + } + } + } + + public function test_open_back_office_checkpoint_applies_the_suite_urls_before_importing_the_pages(): void + { + // Sélecteur visuel de l'app : init() n'est pas appelé. + $suite = $this->suite('http://localhost:8093/', 'admin123/', 'bo'); + + try { + $suite->openBackOfficeCheckpoint(['name' => 'picker']); + $this->fail('page BackOffice absente attendue'); + } catch (\RuntimeException $e) { + $this->assertSame('page BackOffice absente', $e->getMessage()); + } + + $this->assertSame(['FO' => 'http://localhost:8093/', 'BO' => 'http://localhost:8093/admin123/'], $suite->imported[0]); + } + + /** @return array */ + public static function unsafeRelativeBackOfficeUrls(): array + { + return [ + 'hôte sans schéma' => ['shop.test/admin'], + 'premier segment à point' => ['admin.v2/'], + 'remontée' => ['../admin'], + 'segment caché' => ['.hidden/'], + 'segment caché encodé' => ['admin/%2e%2e/x'], + 'espace' => ['admin 1'], + 'requête' => ['admin?x=1'], + 'fragment' => ['admin#x'], + 'double slash interne' => ['admin//x'], + ]; + } + + /** @dataProvider unsafeRelativeBackOfficeUrls */ + public function test_unsafe_relative_back_office_url_is_refused_without_quoting_it(string $value): void + { + try { + $this->suite(null, $value, 'bo')->init(); + $this->fail('BO relative non sûre acceptée'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('$backOfficeUrl', $e->getMessage()); + $this->assertStringNotContainsString($value, $e->getMessage()); + } + } + + public function test_safe_relative_back_office_urls_are_accepted(): void + { + foreach (['/admin.v2/' => 'https://env.test/admin.v2/', 'admin/v2.1' => 'https://env.test/admin/v2.1/', 'adm%C3%AFn' => 'https://env.test/adm%C3%AFn/'] as $value => $expected) { + $suite = $this->suite(null, $value, 'bo'); + $suite->init(); + $this->assertSame($expected, $suite->getGlobals()['BO']['URL'], $value); + } + } + + public function test_internal_whitespace_is_refused_for_both_urls(): void + { + foreach ([["http://shop.test/a b", null], [null, "http://shop.test/a\tb"], [null, "admin\n1"]] as [$shop, $backOffice]) { + try { + $this->suite($shop, $backOffice, 'bo')->init(); + $this->fail('blanc interne accepté'); + } catch (\InvalidArgumentException $e) { + $this->assertStringNotContainsString('shop.test', $e->getMessage()); + } + } + } + + public function test_protocol_relative_back_office_url_without_http_shop_url_is_refused(): void + { + $suite = $this->suite(null, '//admin.shop.test/admin', 'bo', ['FO' => ['URL' => '', 'EMAIL' => '', 'PASSWD' => '']]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$backOfficeUrl'); + $suite->init(); + } + + public function test_shop_url_with_query_or_fragment_is_refused(): void + { + foreach (['http://shop.test/?x=1', 'http://shop.test/#top'] as $shop) { + try { + $this->suite($shop, null)->init(); + $this->fail('$shopUrl avec requête ou fragment acceptée'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('$shopUrl', $e->getMessage()); + $this->assertStringNotContainsString('shop.test', $e->getMessage()); + } + } + } + + public function test_absolute_back_office_url_with_ipv6_and_port(): void + { + $suite = $this->suite(null, 'http://[::1]:8080/admin123', 'bo'); + $suite->init(); + + $this->assertSame('http://[::1]:8080/admin123/', $suite->getGlobals()['BO']['URL']); + } + + public function test_uppercase_scheme_is_accepted(): void + { + $suite = $this->suite('HTTPS://shop.test', 'HTTPS://shop.test/admin', 'bo'); + $suite->init(); + + $this->assertSame('HTTPS://shop.test/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('HTTPS://shop.test/admin/', $suite->getGlobals()['BO']['URL']); + } + + public function test_surrounding_blanks_are_trimmed(): void + { + $suite = $this->suite(' https://shop.test ', null); + $suite->init(); + + $this->assertSame('https://shop.test/', $suite->getGlobals()['FO']['URL']); + } + + public function test_query_and_fragment_of_the_globals_shop_url_are_dropped_for_a_relative_back_office_url(): void + { + $suite = $this->suite(null, 'admin123', 'bo', ['FO' => ['URL' => 'http://fo.test/fr?x=1#a', 'EMAIL' => '', 'PASSWD' => '']]); + $suite->init(); + + $this->assertSame('http://fo.test/fr/admin123/', $suite->getGlobals()['BO']['URL']); + } + + /** @return array */ + public static function backOfficeUrlCases(): array + { + return [ + 'absolue gardée' => ['http://shop.test/admin-dev/', 'http://other.test/', 'http://shop.test/admin-dev/'], + 'absolue rognée' => [' https://shop.test/admin123 ', '', 'https://shop.test/admin123/'], + 'absolue majuscules' => ['HTTP://shop.test/a', '', 'HTTP://shop.test/a/'], + 'relative sans slash' => ['admin-dev', 'http://shop.test', 'http://shop.test/admin-dev/'], + 'relative' => ['admin-dev/', 'http://shop.test/', 'http://shop.test/admin-dev/'], + 'relative à slash initial' => ['/admin-dev/', 'http://shop.test/', 'http://shop.test/admin-dev/'], + 'relative sous chemin' => ['admin/sub', ' http://shop.test/fr/ ', 'http://shop.test/fr/admin/sub/'], + 'protocole https' => ['//h.test/admin', 'https://fo.test', 'https://h.test/admin/'], + 'protocole majuscules' => ['//h.test/admin', 'HTTP://fo.test/fr', 'http://h.test/admin/'], + 'requête et fragment FO ignorés' => ['admin', 'http://fo.test/fr?x=1#a', 'http://fo.test/fr/admin/'], + 'fragment FO seul' => ['/admin', 'http://fo.test/#frag', 'http://fo.test/admin/'], + 'point dans un segment non initial' => ['/admin.v2/', 'http://fo.test', 'http://fo.test/admin.v2/'], + 'segment à point' => ['admin/v2.1/', 'http://fo.test', 'http://fo.test/admin/v2.1/'], + ]; + } + + /** @dataProvider backOfficeUrlCases */ + public function test_resolve_back_office_url(string $backOffice, string $frontOffice, string $expected): void + { + $this->assertSame($expected, VisualTestsSuite::resolveBackOfficeUrl($backOffice, $frontOffice)); + } + + private function cliSuite(array $env, ?string $shopUrl): VisualTestsSuite + { + $keys = ['PRESTAFLOW_FO_URL', 'PRESTAFLOW_BO_URL']; + $saved = []; + foreach ($keys as $key) { + $saved[$key] = array_key_exists($key, $_ENV) ? $_ENV[$key] : null; + unset($_ENV[$key]); + } + foreach ($env as $key => $value) { + $_ENV[$key] = $value; + } + try { + $suite = new class (loadGlobals: true, getBrowser: false) extends VisualTestsSuite { + protected array $devices = ['desktop']; + protected array $locales = ['fr']; + protected array $checkpoints = []; + + protected function importVisualPage(): void + { + } + + public function pin(?string $shopUrl): void + { + $this->shopUrl = $shopUrl; + } + }; + } finally { + foreach ($saved as $key => $value) { + if ($value === null) { + unset($_ENV[$key]); + } else { + $_ENV[$key] = $value; + } + } + } + $suite->pin($shopUrl); + + return $suite; + } + + public function test_cli_default_back_office_url_follows_the_suite_shop_url(): void + { + $suite = $this->cliSuite(['PRESTAFLOW_FO_URL' => 'https://env.test/'], 'http://localhost:8093'); + $suite->applySuiteUrls(); + + $this->assertSame('http://localhost:8093/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('http://localhost:8093/admin-dev/', $suite->getGlobals()['BO']['URL']); + } + + public function test_cli_relative_back_office_url_follows_the_suite_shop_url(): void + { + $suite = $this->cliSuite(['PRESTAFLOW_FO_URL' => 'https://env.test/', 'PRESTAFLOW_BO_URL' => 'admin123'], 'http://localhost:8093'); + $this->assertSame('https://env.test/admin123/', $suite->getGlobals()['BO']['URL']); + $suite->applySuiteUrls(); + $suite->applySuiteUrls(); + + $this->assertSame('http://localhost:8093/admin123/', $suite->getGlobals()['BO']['URL']); + } + + public function test_cli_absolute_back_office_url_is_kept_with_the_suite_shop_url(): void + { + $suite = $this->cliSuite(['PRESTAFLOW_FO_URL' => 'https://env.test/', 'PRESTAFLOW_BO_URL' => 'https://bo.env.test/admin/'], 'http://localhost:8093'); + $suite->applySuiteUrls(); + + $this->assertSame('https://bo.env.test/admin/', $suite->getGlobals()['BO']['URL']); + } + + public function test_provided_globals_back_office_url_is_kept_with_the_suite_shop_url(): void + { + // Cas de l'app : setGlobals() fournit FO et BO ; $shopUrl ne touche pas à BO. + $suite = $this->cliSuite(['PRESTAFLOW_FO_URL' => 'https://env.test/'], 'http://localhost:8093'); + $suite->setGlobals(['BO' => ['URL' => 'https://env.test/admin-app/', 'EMAIL' => 'e', 'PASSWD' => 'p']]); + $suite->applySuiteUrls(); + + $this->assertSame('http://localhost:8093/', $suite->getGlobals()['FO']['URL']); + $this->assertSame('https://env.test/admin-app/', $suite->getGlobals()['BO']['URL']); + } +}