Skip to content
Merged
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ A visual suite with `protected string $area = 'bo';` captures the back office. E
| `auth` | `true` | `false`: captured logged out (the login page). Logged-out checkpoints run first; the suite then logs in once with `PRESTAFLOW_BO_EMAIL` / `PRESTAFLOW_BO_PASSWD`. |
| `hide` | `[]` | Selectors set to `display: none` during the capture (popups, modal backdrops). Works in both areas. |

The back-office area also uses `PRESTAFLOW_BO_URL` (admin URL), `PRESTAFLOW_BO_EMAIL` and `PRESTAFLOW_BO_PASSWD` (login).
The back-office area also uses `PRESTAFLOW_BO_URL` (admin URL, default `admin-dev/`), `PRESTAFLOW_BO_EMAIL` and `PRESTAFLOW_BO_PASSWD` (login). A relative `PRESTAFLOW_BO_URL` is completed from `PRESTAFLOW_FO_URL` with the same rule as `$backOfficeUrl` below (shop path kept, query and fragment dropped, `//host` takes the shop scheme); nothing is refused for environment variables.

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`.

Expand All @@ -94,7 +94,7 @@ protected ?string $backOfficeUrl = 'admin123/'; // relative: appended to the eff

- 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.
- 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 or `//host` `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
Expand Down
441 changes: 441 additions & 0 deletions docs/superpowers/plans/2026-10-08-bo-url-resolve.md

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions docs/superpowers/plans/2026-10-08-bo-url-resolve.md.tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"planPath": "docs/superpowers/plans/2026-10-08-bo-url-resolve.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: utilitaire BackOfficeUrl et délégation de VisualTestsSuite",
"status": "completed",
"description": "**Goal:** BackOfficeUrl::resolve()/isRelative() testés ; VisualTestsSuite::resolveBackOfficeUrl() délègue.\n\n```json:metadata\n{\"files\": [\"src/Utils/BackOfficeUrl.php\", \"tests/Unit/Utils/BackOfficeUrlTest.php\", \"src/Tests/VisualTestsSuite.php\", \"tests/Unit/Visual/VisualTestsSuiteUrlsTest.php\"], \"verifyCommand\": \"php vendor/bin/phpunit --filter 'BackOfficeUrlTest|VisualTestsSuiteUrlsTest'\", \"acceptanceCriteria\": [\"13 cas resolve\", \"isRelative\", \"délégation\", \"678 tests 8.4/8.1\"]}\n```"
},
{
"id": 2,
"subject": "Task 2: loadGlobals() utilise BackOfficeUrl, et README",
"status": "completed",
"blockedBy": [
1
],
"description": "**Goal:** PRESTAFLOW_BO_URL résolue par BackOfficeUrl::resolve(), backOfficeRelative par isRelative(), README.\n\n```json:metadata\n{\"files\": [\"src/Tests/TestsSuite.php\", \"tests/Unit/Tests/LoadGlobalsBackOfficeUrlTest.php\", \"README.md\"], \"verifyCommand\": \"php vendor/bin/phpunit --filter 'LoadGlobalsBackOfficeUrlTest|VisualTestsSuiteUrlsTest'\", \"acceptanceCriteria\": [\"tableau Problème\", \"défaut admin-dev\", \"vide\", \"688 tests 8.4/8.1\"]}\n```"
},
{
"id": 3,
"subject": "Task 3: livraison (coordinateur)",
"status": "pending",
"blockedBy": [
2
],
"description": "**Goal:** revue finale, PR vers dev ; release et bump app sur accord.\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"gh pr view --repo PrestaFlow/php-library\", \"acceptanceCriteria\": [\"PR dev ouverte\"]}\n```"
}
],
"lastUpdated": "2026-10-08T09:22:09.024342+02:00"
}
87 changes: 87 additions & 0 deletions docs/superpowers/specs/2026-10-08-bo-url-resolve-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Une seule règle de résolution des URL du back-office : design

Date : 2026-10-08
Statut : validé, à implémenter

Fait suite à `2026-10-07-suite-urls-design.md` (PR #123, release #124).

## Problème

La lib complète une URL du back-office relative de deux façons :

- **`TestsSuite::loadGlobals()`** (toutes les suites, CLI et CI) colle `PRESTAFLOW_BO_URL`, ou le défaut `admin-dev/`, derrière `PRESTAFLOW_FO_URL`. Toute valeur qui ne commence pas par `http://` ou `https://` en minuscules est prise pour un chemin.
- **`VisualTestsSuite::resolveBackOfficeUrl()`** (propriété `$backOfficeUrl`, puis `$shopUrl` seule) applique la règle de l'app, `App\Support\BackOfficeUrl::resolve()`.

Le même `PRESTAFLOW_BO_URL` peut donc viser un autre back-office selon le chemin :

| Valeur | `loadGlobals()` aujourd'hui | Règle de l'app |
|---|---|---|
| `/admin` (FO `https://x/`) | `https://x//admin/` | `https://x/admin/` |
| `admin-dev/` (FO `https://x/?lang=2`) | `https://x/?lang=2/admin-dev/` | `https://x/admin-dev/` |
| `//autre/admin` | `https://x///autre/admin/` | `https://autre/admin/` |
| `HTTPS://x/admin` | `https://x/HTTPS://x/admin/` | `HTTPS://x/admin/` |

## Décisions

| Sujet | Décision |
|---|---|
| Portée | Une seule règle de **résolution** pour `loadGlobals()` et les suites visuelles. |
| Refus | Aucun nouveau refus pour les variables d'environnement : une valeur exotique, même avec des identifiants, est résolue sans erreur. Les refus restent propres aux propriétés `$shopUrl` / `$backOfficeUrl` du fichier de suite. |
| Emplacement | Nouvel utilitaire `PrestaFlow\Library\Utils\BackOfficeUrl`, à côté de `Env`. |
| Compatibilité | `VisualTestsSuite::resolveBackOfficeUrl()` reste publique, avec la même signature, et délègue à l'utilitaire. |

## Design

### `PrestaFlow\Library\Utils\BackOfficeUrl`

```php
final class BackOfficeUrl
{
public static function resolve(string $backOffice, string $frontOffice): string;
public static function isRelative(string $backOffice): bool;
}
```

- `resolve()` reprend sans changement le corps actuel de `VisualTestsSuite::resolveBackOfficeUrl()` :
- les deux valeurs sont nettoyées des espaces en bordure ;
- une URL absolue `http(s)`, schéma insensible à la casse, est gardée telle quelle ;
- une URL `//hôte/…` prend le schéma de l'URL FO ;
- un autre chemin s'ajoute à l'URL FO, privée de sa requête et de son fragment, en gardant son chemin, avec un seul `/` entre les deux ;
- le résultat se termine toujours par `/`.
- `isRelative()` renvoie vrai si la valeur dépend de l'URL FO, autrement dit si elle n'est ni absolue `http(s)` ni `//hôte`. Une valeur vide après nettoyage n'est pas relative.

### `TestsSuite::loadGlobals()`

- `PRESTAFLOW_FO_URL` ne change pas : il reçoit seulement un `/` final.
- L'URL BO vaut `PRESTAFLOW_BO_URL`, ou `admin-dev/` si la variable est absente. Elle passe par `BackOfficeUrl::resolve($bo, $frontOfficeUrl)`.
- `$backOfficeRelative` vaut la valeur brute nettoyée quand `BackOfficeUrl::isRelative()` est vrai, sinon `null`. Avec `$shopUrl` seule, un `//hôte` ou une URL absolue ne suit donc plus l'URL FO du fichier. C'est cohérent : leur hôte ne dépend pas de la FO.
- `PRESTAFLOW_BO_URL` défini mais vide : l'URL FO elle-même, comme aujourd'hui ; `$backOfficeRelative` vaut `''`, pour que la BO suive une `$shopUrl` du fichier (comportement de `dev` conservé).

### `VisualTestsSuite`

- `resolveBackOfficeUrl()` devient `return BackOfficeUrl::resolve($backOffice, $frontOffice);`, avec un docblock qui renvoie à l'utilitaire.
- `applySuiteUrls()`, `assertSuiteUrl()` et `isSafeRelativePath()` ne changent pas.

## Tests

- **Unitaires `BackOfficeUrl`** :
- le jeu de cas de `resolve()` aujourd'hui dans `VisualTestsSuiteUrlsTest` (13 cas repris de `BackOfficeUrlTest` de l'app) est déplacé dans `tests/Unit/Utils/BackOfficeUrlTest.php` ;
- `isRelative()` est vrai pour `admin/`, `/admin` et `admin-dev/`, faux pour `http://x/a`, `HTTPS://x/a`, `//x/a` et `''`.
- **`loadGlobals()`**, via des variables d'environnement, sans Chrome : chaque ligne du tableau « Problème », le défaut `admin-dev/`, une URL absolue gardée, et `$backOfficeRelative` (relatif, `//hôte`, absolu, défaut).
- `VisualTestsSuiteUrlsTest` reste vert : `resolveBackOfficeUrl()` délègue, et le comportement de `$shopUrl` / `$backOfficeUrl` ne change pas.
- Suite complète verte en PHP 8.4 et 8.1.

## Documentation

README : ajouter sous la ligne qui mentionne `PRESTAFLOW_BO_URL` une phrase qui renvoie à la règle commune. Exemple : « A relative `PRESTAFLOW_BO_URL` follows the same rule as `$backOfficeUrl` below. » La règle elle-même reste décrite une seule fois, dans la section « Shop and back-office URLs in the suite file ».

## Livraison

1. PR vers `dev`, puis release `dev` → `main`, en commits de merge, sur accord de l'utilisateur.
2. App : bump de `prestaflow/php-library`, `composer.lock` seul. Aucun autre changement dans l'app.

## Hors périmètre

- Valider ou refuser les variables d'environnement.
- `PRESTAFLOW_FO_URL` : requête, fragment ou schéma.
- `App\Support\BackOfficeUrl` côté app, qui reste la référence de la règle.
25 changes: 13 additions & 12 deletions src/Tests/TestsSuite.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use PrestaFlow\Library\Traits\ImportPage;
use PrestaFlow\Library\Traits\Locale;
use PrestaFlow\Library\Traits\Version;
use PrestaFlow\Library\Utils\BackOfficeUrl;
use PrestaFlow\Library\Utils\Env;
use PrestaFlow\Library\Utils\Output;
use PrestaFlow\Library\Utils\OutputStates;
Expand Down Expand Up @@ -267,9 +268,10 @@ public static function resetBrowser(): void

/**
* 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.
* l'URL FO de l'environnement (PRESTAFLOW_BO_URL relative : chemin, ni
* absolue ni `//hôte`, vide, ou défaut `admin-dev/`) ; null sinon (BO
* absolue, globals fournies). Permet à VisualTestsSuite::applySuiteUrls()
* de suivre une $shopUrl du fichier.
*/
protected ?string $backOfficeRelative = null;

Expand Down Expand Up @@ -1185,15 +1187,14 @@ public function loadGlobals()
$frontOfficeUrl .= '/';
}

$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, '/')) {
$backOfficeUrl .= '/';
}
// Règle unique (BackOfficeUrl, la même que l'app et que $backOfficeUrl) :
// chemin relatif complété par l'URL FO sans requête ni fragment,
// `//hôte` au schéma de la FO, absolue gardée. Aucun refus ici.
$rawBackOffice = (string) Env::get('PRESTAFLOW_BO_URL', 'admin-dev/');
$trimmedBackOffice = trim($rawBackOffice);
// Une valeur vide reste « relative » ('') : la BO suit alors une $shopUrl du fichier.
$this->backOfficeRelative = ($trimmedBackOffice === '' || BackOfficeUrl::isRelative($trimmedBackOffice)) ? $trimmedBackOffice : null;
$backOfficeUrl = BackOfficeUrl::resolve($rawBackOffice, $frontOfficeUrl);

$this->globals = [
'PS_VERSION' => Env::get('PRESTAFLOW_PS_VERSION', '8.1.0'),
Expand Down
21 changes: 5 additions & 16 deletions src/Tests/VisualTestsSuite.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use PrestaFlow\Library\Exceptions\BackOfficeTimeoutException;
use PrestaFlow\Library\Exceptions\TimeoutException;
use PrestaFlow\Library\Expects\Expect;
use PrestaFlow\Library\Utils\BackOfficeUrl;
use PrestaFlow\Library\Utils\Env;
use PrestaFlow\Library\Visual\VisualDevices;

Expand Down Expand Up @@ -477,25 +478,13 @@ public function applySuiteUrls(): void
}

/**
* 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 « / ».
* URL BO effective : voir BackOfficeUrl::resolve() (règle unique ; même règle que
* App\Support\BackOfficeUrl::resolve() pour les valeurs valides, rien n'est refusé ici).
* Gardée publique pour compatibilité.
*/
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.'/';
return BackOfficeUrl::resolve($backOffice, $frontOffice);
}

/** 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). */
Expand Down
49 changes: 49 additions & 0 deletions src/Utils/BackOfficeUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace PrestaFlow\Library\Utils;

/**
* Règle unique de l'URL du back-office : même règle que App\Support\BackOfficeUrl::resolve()
* (app PrestaFlow) pour les valeurs valides (absolue, `//hôte`, chemin sûr) ; ici rien n'est refusé.
* Sert à loadGlobals() (PRESTAFLOW_BO_URL) et aux suites
* visuelles ($backOfficeUrl). Ne refuse rien : la validation reste à l'appelant.
*/
final class BackOfficeUrl
{
/**
* URL BO effective :
* - absolue http(s) (casse indifférente) : gardée telle quelle ;
* - relative au protocole (`//hôte/admin`) : schéma de l'URL FO ;
* - relative (`admin123/`, `/admin123`) : URL FO sans requête ni fragment,
* chemin FO gardé, puis le chemin, avec un seul « / » entre les deux.
* Toujours terminée par « / ».
*/
public static function resolve(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.'/';
}

/**
* Vrai si la valeur est un chemin à coller à l'URL FO : ni absolue http(s), ni `//hôte`.
* Une valeur vide n'est pas relative par convention : resolve() rend alors l'URL FO elle-même.
*/
public static function isRelative(string $backOffice): bool
{
$backOffice = trim($backOffice);

return $backOffice !== ''
&& !str_starts_with($backOffice, '//')
&& preg_match('#^https?://#i', $backOffice) !== 1;
}
}
96 changes: 96 additions & 0 deletions tests/Unit/Tests/LoadGlobalsBackOfficeUrlTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace PrestaFlow\Tests\Unit\Tests;

use PHPUnit\Framework\TestCase;
use PrestaFlow\Library\Tests\TestsSuite;

final class LoadGlobalsBackOfficeUrlTest extends TestCase
{
/**
* Construit une suite qui appelle loadGlobals() avec ces variables, puis restaure $_ENV.
* Une valeur null retire la variable (cas « absente »).
*
* @param array<string, ?string> $env
*/
private function suite(array $env): TestsSuite
{
$saved = [];
foreach ($env as $key => $value) {
$saved[$key] = [
array_key_exists($key, $_ENV) ? $_ENV[$key] : null,
getenv($key),
];
if ($value === null) {
unset($_ENV[$key]);
putenv($key);
} else {
$_ENV[$key] = $value;
}
}
try {
return new class (loadGlobals: true, getBrowser: false) extends TestsSuite {
public function relative(): ?string
{
return $this->backOfficeRelative;
}
};
} finally {
foreach ($saved as $key => [$envValue, $processValue]) {
if ($envValue === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $envValue;
}
if ($processValue === false) {
putenv($key);
} else {
putenv($key . '=' . $processValue);
}
}
}
}

/** @return array<string, array{string, string, string, ?string}> */
public static function cases(): array
{
return [
'slash initial' => ['https://x/', '/admin', 'https://x/admin/', '/admin'],
'relatif' => ['https://x/', 'admin123', 'https://x/admin123/', 'admin123'],
'requête FO ignorée' => ['https://x/?lang=2', 'admin-dev/', 'https://x/admin-dev/', 'admin-dev/'],
'protocole' => ['https://x/', '//autre.test/admin', 'https://autre.test/admin/', null],
'absolue majuscules' => ['https://x/', 'HTTPS://bo.test/admin', 'HTTPS://bo.test/admin/', null],
'absolue' => ['https://x/', 'https://bo.test/admin/', 'https://bo.test/admin/', null],
'relatif rogné' => ['https://x/', ' admin ', 'https://x/admin/', 'admin'],
'vide' => ['https://x/', '', 'https://x/', ''],
];
}

/** @dataProvider cases */
public function test_back_office_url_follows_the_single_rule(string $fo, string $bo, string $expected, ?string $relative): void
{
$suite = $this->suite(['PRESTAFLOW_FO_URL' => $fo, 'PRESTAFLOW_BO_URL' => $bo]);

$this->assertSame($expected, $suite->getGlobals()['BO']['URL']);
$this->assertSame($relative, $suite->relative());
}

public function test_front_office_url_is_unchanged(): void
{
$suite = $this->suite(['PRESTAFLOW_FO_URL' => 'https://x/?lang=2', 'PRESTAFLOW_BO_URL' => 'admin-dev/']);

$this->assertSame('https://x/?lang=2/', $suite->getGlobals()['FO']['URL']);
}

public function test_missing_back_office_url_defaults_to_admin_dev(): void
{
$suite = $this->suite(['PRESTAFLOW_FO_URL' => 'https://x/fr?y=1', 'PRESTAFLOW_BO_URL' => null]);

if (getenv('PRESTAFLOW_BO_URL') !== false || isset($_ENV['PRESTAFLOW_BO_URL'])) {
$this->markTestSkipped('Un .env* définit PRESTAFLOW_BO_URL : le cas « absente » est faussé.');
}

$this->assertSame('https://x/fr/admin-dev/', $suite->getGlobals()['BO']['URL']);
$this->assertSame('admin-dev/', $suite->relative());
}
}
Loading
Loading