diff --git a/.github/workflows/theme-release.yml b/.github/workflows/theme-release.yml new file mode 100644 index 0000000..63ed079 --- /dev/null +++ b/.github/workflows/theme-release.yml @@ -0,0 +1,187 @@ +name: Publication d'un thème + +on: + workflow_call: + inputs: + tools-ref: + description: Version de release-tools utilisée pour les scripts (doit correspondre à la version du workflow). + type: string + default: v1.1 + +permissions: + contents: read + +jobs: + build: + name: Contrôles et paquet + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + outputs: + slug: ${{ steps.meta.outputs.slug }} + version: ${{ steps.meta.outputs.version }} + steps: + - name: Code du thème + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + path: theme + persist-credentials: false + + - name: Outils de publication + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: wearewp/release-tools + ref: ${{ inputs.tools-ref }} + path: tools + persist-credentials: false + + # Un relèvement de la version minimale sur main s'applique même aux appels épinglés sur un ancien tag. + - name: Version minimale de wawp-theme-updater exigée sur main + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: wearewp/release-tools + ref: main + path: tools-main + sparse-checkout: config + persist-credentials: false + + - name: Slug et version + id: meta + run: | + echo "slug=${GITHUB_REPOSITORY#*/}" >> "$GITHUB_OUTPUT" + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Contrôles avant publication + env: + SLUG: ${{ steps.meta.outputs.slug }} + run: | + ref_min="$(cat tools/config/min-theme-updater-version.txt)" + main_min="$(cat tools-main/config/min-theme-updater-version.txt)" + min="$(printf '%s\n%s\n' "$ref_min" "$main_min" | sort -V | tail -n 1)" + php tools/scripts/check-headers.php --type=theme theme "$SLUG" "$GITHUB_REF_NAME" "$min" + + - name: Syntaxe PHP + run: find theme -name '*.php' -not -path '*/node_modules/*' -print0 | xargs -0 -n1 php -l > /dev/null + + - name: Dépendances Composer + if: hashFiles('theme/composer.json') != '' + working-directory: theme + run: composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + + - name: Assets + if: hashFiles('theme/package.json') != '' + working-directory: theme + run: npm ci && npm run build + + - name: Paquet + env: + SLUG: ${{ steps.meta.outputs.slug }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + mkdir -p "build/$SLUG" dist + rsync -a --exclude-from=theme/.distignore --exclude=.git --exclude=.github theme/ "build/$SLUG/" + php tools/scripts/check-package.php --type=theme "build/$SLUG" "$SLUG" "$VERSION" + (cd build && zip -rqX "../dist/$SLUG-$VERSION.zip" "$SLUG") + + - name: Conservation du paquet + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: paquet + path: dist/ + retention-days: 1 + if-no-files-found: error + overwrite: true + + sign-upload: + name: Signature et envoi + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + concurrency: + group: release-upload-${{ github.repository }} + cancel-in-progress: false + steps: + - name: Outils de publication + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: wearewp/release-tools + ref: ${{ inputs.tools-ref }} + path: tools + persist-credentials: false + + - name: Récupération du paquet + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: paquet + path: dist + + - name: Installation de lftp + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq lftp > /dev/null + + - name: Signature + env: + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + ZIP: dist/${{ needs.build.outputs.slug }}-${{ needs.build.outputs.version }}.zip + run: php tools/scripts/sign.php "$ZIP" + + - name: Envoi FTPS vers le serveur de mises à jour + env: + FTP_HOST: ${{ secrets.FTP_HOST }} + FTP_USER: ${{ secrets.FTP_USER }} + LFTP_PASSWORD: ${{ secrets.FTP_PASSWORD }} + ZIP: dist/${{ needs.build.outputs.slug }}-${{ needs.build.outputs.version }}.zip + run: bash tools/scripts/upload-ftps.sh "$ZIP" + + - name: Conservation de la signature + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: signature + path: dist/*.sig + retention-days: 1 + if-no-files-found: error + overwrite: true + + publish-release: + name: Archive dans la Release GitHub + needs: [build, sign-upload] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Récupération du paquet + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: paquet + path: dist + + - name: Récupération de la signature + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: signature + path: dist + + - name: Release + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + assets="$(gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name')" + for file in dist/*; do + while IFS= read -r asset; do + if [ "$asset" = "$(basename "$file")" ]; then + echo "La Release $GITHUB_REF_NAME contient déjà $asset : aucun remplacement automatique. Vérifier l'asset existant et le supprimer à la main s'il doit être remplacé, puis relancer le job." >&2 + exit 1 + fi + done <<< "$assets" + done + gh release upload "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" + else + gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --notes "Paquet signé publié sur updates.wearewp.pro." + fi diff --git a/README.md b/README.md index 66558d4..7d3b50d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ # release-tools -Outils de publication des plugins WeAre[WP] et client de mise à jour `wawp-updater`. +Outils de publication des plugins et des thèmes WeAre[WP], clients de mise à jour `wawp-updater` (plugins) et `wawp-theme-updater` (thèmes). -- `wawp-updater/` : bibliothèque embarquée dans chaque plugin (PHP 7.4+, WordPress 5.8+). Vérifie les mises à jour sur `updates.wearewp.pro` avec la clé `WAWP_LICENSE_KEY` et refuse tout paquet dont la signature Ed25519 est invalide. -- `.github/workflows/plugin-release.yml` : workflow réutilisable, déclenché par un tag `vX.Y.Z` dans le dépôt d'un plugin. -- `scripts/`, `bin/` : contrôles, signature, envoi FTPS, génération des clés, préparation d'un dépôt. +- `wawp-updater/` : bibliothèque embarquée dans chaque plugin (PHP 7.4+, WordPress 5.8+). Vérifie les mises à jour sur `updates.wearewp.pro` avec la clé `WAWP_LICENSE_KEY` et refuse tout paquet dont la signature Ed25519 est invalide. Figée en version 1.0.0. +- `wawp-theme-updater/` : bibliothèque embarquée dans le thème FSE autonome d'un client (PHP 7.4+, WordPress 6.1+), mêmes garanties, chargée par `functions.php`. +- `.github/workflows/plugin-release.yml` (tag `v1`) et `.github/workflows/theme-release.yml` (tag `v1.1`) : workflows réutilisables, déclenchés par un tag `vX.Y.Z` dans le dépôt d'un plugin ou d'un thème. +- `scripts/`, `bin/` : contrôles (`--type=theme` pour un thème), signature, envoi FTPS, génération des clés, préparation d'un dépôt. Ce dépôt ne contient aucun secret. Mise en place : `docs/mise-en-place.md`. diff --git a/bin/setup-repo.sh b/bin/setup-repo.sh index 4fab521..1110fd4 100644 --- a/bin/setup-repo.sh +++ b/bin/setup-repo.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Prépare un dépôt de plugin pour la publication automatique. Ne manipule jamais la valeur d'un secret. -# Usage : bash bin/setup-repo.sh wearewp/ +# Prépare un dépôt de plugin ou de thème pour la publication automatique. Ne manipule jamais la valeur d'un secret. +# Usage : bash bin/setup-repo.sh wearewp/ set -euo pipefail repo="${1:-}" @@ -17,6 +17,15 @@ if [ -z "$path" ] || [ ! -d "$path" ]; then fi slug="${repo#wearewp/}" +# Thème : style.css avec Theme Name et pas de .php (même règle que l'ingestion du serveur). +kind=plugin +template="release.yml" +if [ -f "$path/style.css" ] && grep -q 'Theme Name:' "$path/style.css" && [ ! -f "$path/$slug.php" ]; then + kind=theme + template="theme-release.yml" +fi +echo "Type détecté : $kind." + visibility="$(gh api "repos/$repo" --jq '.visibility')" if [ "$visibility" != "private" ]; then echo "Le dépôt $repo doit être privé (visibilité actuelle : $visibility)." >&2 @@ -49,8 +58,8 @@ mkdir -p "$path/.github/workflows" if [ -f "$path/.github/workflows/release.yml" ]; then echo "release.yml déjà présent : non modifié." else - cp "$tools/templates/release.yml" "$path/.github/workflows/release.yml" - echo "release.yml créé : à commiter." + cp "$tools/templates/$template" "$path/.github/workflows/release.yml" + echo "release.yml créé à partir de templates/$template : à commiter." fi status=0 @@ -58,12 +67,23 @@ if [ ! -f "$path/.distignore" ]; then echo "À faire : créer $path/.distignore (fichiers exclus du paquet)." >&2 status=1 fi -if ! grep -qE '^[[:space:]*#@/]*Update URI:[[:space:]]*https://updates\.wearewp\.pro[[:space:]]*$' "$path/$slug.php" 2>/dev/null; then - echo "À faire : ajouter l'en-tête « Update URI: https://updates.wearewp.pro » dans $slug.php." >&2 - status=1 -fi -if [ ! -f "$path/wawp-updater/load.php" ]; then - echo "À faire : copier wawp-updater/ dans le plugin et ajouter le chargement (voir docs/mise-en-place.md)." >&2 - status=1 +if [ "$kind" = theme ]; then + if ! grep -qE '^[[:space:]*#@/]*Update URI:[[:space:]]*https://updates\.wearewp\.pro[[:space:]]*$' "$path/style.css" 2>/dev/null; then + echo "À faire : ajouter l'en-tête « Update URI: https://updates.wearewp.pro » dans style.css." >&2 + status=1 + fi + if [ ! -f "$path/wawp-theme-updater/load.php" ]; then + echo "À faire : copier wawp-theme-updater/ dans le thème et ajouter le chargement dans functions.php (voir docs/mise-en-place.md)." >&2 + status=1 + fi +else + if ! grep -qE '^[[:space:]*#@/]*Update URI:[[:space:]]*https://updates\.wearewp\.pro[[:space:]]*$' "$path/$slug.php" 2>/dev/null; then + echo "À faire : ajouter l'en-tête « Update URI: https://updates.wearewp.pro » dans $slug.php." >&2 + status=1 + fi + if [ ! -f "$path/wawp-updater/load.php" ]; then + echo "À faire : copier wawp-updater/ dans le plugin et ajouter le chargement (voir docs/mise-en-place.md)." >&2 + status=1 + fi fi exit "$status" diff --git a/config/min-theme-updater-version.txt b/config/min-theme-updater-version.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/config/min-theme-updater-version.txt @@ -0,0 +1 @@ +1.0.0 diff --git a/docs/mise-en-place.md b/docs/mise-en-place.md index 67fc6db..78f6ffa 100644 --- a/docs/mise-en-place.md +++ b/docs/mise-en-place.md @@ -1,4 +1,4 @@ -# Mise en place de la publication des plugins WeAre[WP] +# Mise en place de la publication des plugins et des thèmes WeAre[WP] Procédure unique, à suivre dans l'ordre. Les étapes marquées 🔒 manipulent des secrets : Thierry les exécute lui-même, dans son propre terminal, jamais dans une session automatisée. @@ -98,6 +98,39 @@ Suivre la section 11 de la spec (`docs/superpowers/specs/2026-10-12-serveur-mise La version de rotation de `wawp-updater` incrémente `$wawp_updater_version` dans `load.php` et `Config::VERSION` dans `src/class-config.php`, toujours égales (par exemple `1.0.0` vers `1.1.0`) : c'est la copie la plus récente qui est chargée sur un site. Une fois cette version taguée, relever `config/min-updater-version.txt` sur la branche `main` de release-tools à cette même version : chaque publication lit aussi ce fichier sur `main` et retient la plus haute des deux valeurs, ce qui bloque la publication d'un plugin qui embarque encore l'ancien client, même si son workflow est épinglé sur un ancien tag. +## 9. Tag `v1.1` de release-tools (une seule fois) + +Les thèmes utilisent `theme-release.yml@v1.1` ; les plugins restent sur `@v1`. Après fusion sur `main` de la branche qui ajoute `wawp-theme-updater/` et `theme-release.yml`, depuis un clone de release-tools, dans Git Bash (`gh` authentifié comme à l'étape 5) : + +``` +git checkout main && git pull +git tag v1.1 +git push origin v1.1 +``` + +Le ruleset de l'étape 5 protège aussi ce tag (motif `refs/tags/v*`) : il ne pourra être ni déplacé ni supprimé. + +## 10. Préparer un dépôt de thème + +Le thème est un thème FSE autonome (pas de thème parent, pas d'en-tête `Template`), actif sur un seul site. Le dépôt appartient à l'organisation `wearewp`, est privé et porte le nom du slug du thème, qui est aussi le nom de son dossier et son `Text Domain`. + +1. Copier `wawp-theme-updater/` (version taguée `v1.1`) à la racine du thème : `git -C archive v1.1 wawp-theme-updater | tar -x -C `. +2. Dans `functions.php`, après le contrôle `ABSPATH` : + ```php + require __DIR__ . '/wawp-theme-updater/load.php'; + \WAWP\ThemeUpdater\register( __DIR__ ); + ``` +3. Dans `style.css` : `Update URI: https://updates.wearewp.pro`, `Requires at least: 6.1` (ou plus), `Requires PHP`, `Text Domain: `, `Version` à la prochaine version mineure. `Theme URI` est facultatif, mais s'il est vide le lien « Voir les détails de la version » de WordPress ne mène nulle part d'utile : renseigner `Theme URI: https://www.wearewp.pro/` (recommandé). +4. Créer `.distignore` avec des motifs racine ancrés par `/` (`/.git`, `/.github`, `/node_modules`, `/src`…). +5. Contrôler localement : `php /scripts/check-headers.php --type=theme v 1.0.0` → `Contrôles réussis`. +6. Lancer, dans Git Bash, depuis le dossier de release-tools : `bash bin/setup-repo.sh wearewp/ ` (`Type détecté : theme.`), puis commiter `.github/workflows/release.yml`. +7. Ajouter le slug à la liste `themes` de la licence du site (`bin/new-key.php --theme=` pour une nouvelle licence, ou édition du registre puis `bin/check-licences.php`). +8. Publier comme un plugin (section 7, sans `readme.txt`), puis, après sauvegarde du site, installer une fois le zip publié à la main (Apparence › Thèmes › Ajouter › Téléverser, remplacer le thème actif). Les versions suivantes passent par les mises à jour de WordPress. + +Le client des thèmes ne place jamais l'adresse de téléchargement dans le cache de WordPress, mais un marqueur `https://updates.wearewp.pro/v1/theme-update/` : `wawp-updater` 1.0.0, embarqué dans les plugins, refuse toute adresse `/v1/download/` hors d'une mise à jour de plugin. Une rotation de clé de signature (section 8) concerne aussi `wawp-theme-updater/src/class-config.php` (même `PUBLIC_KEYS`) et `config/min-theme-updater-version.txt`. + ## Limite connue WordPress n'appelle les filtres `Update URI` qu'après une réponse réussie d'`api.wordpress.org`. Si ce service est injoignable depuis un site, les mises à jour WeAre[WP] n'y apparaissent pas tant que la situation dure ; elles réapparaissent dès le contrôle suivant. + +Il en va de même pour les thèmes (filtre `update_themes_updates.wearewp.pro`). WordPress envoie aussi à api.wordpress.org la liste des thèmes installés, dont le slug du thème client : si un thème public de wordpress.org portait le même slug, sa mise à jour serait proposée à la place. Choisir des slugs de thèmes propres au client. diff --git a/phpcs.xml.dist b/phpcs.xml.dist index aa3a56f..a31fa46 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,7 +1,8 @@ - Standard WordPress et compatibilité PHP 7.4 pour la bibliothèque embarquée. + Standard WordPress et compatibilité PHP 7.4 pour les bibliothèques embarquées. wawp-updater + wawp-theme-updater @@ -12,6 +13,7 @@ + @@ -20,6 +22,8 @@ + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bac3a86..07e9325 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,6 +5,7 @@ parameters: phpVersion: 70400 paths: - wawp-updater + - wawp-theme-updater - scripts - bin bootstrapFiles: diff --git a/scripts/check-headers.php b/scripts/check-headers.php index 380bcee..5579315 100644 --- a/scripts/check-headers.php +++ b/scripts/check-headers.php @@ -1,7 +1,8 @@ + * Contrôles d'un plugin ou d'un thème avant construction du paquet. Lit les fichiers, n'exécute jamais leur code. + * Usage : php check-headers.php [--type=plugin|theme] + * Sans --type : plugin (comportement d'origine). Pour un thème, la bibliothèque est wawp-theme-updater. */ declare(strict_types=1); @@ -10,10 +11,28 @@ exit(1); } -$dir = rtrim($argv[1] ?? '', '/\\'); -$slug = $argv[2] ?? ''; -$tag = $argv[3] ?? ''; -$minUpdater = trim($argv[4] ?? ''); +$type = 'plugin'; +$args = []; +foreach (array_slice($argv, 1) as $arg) { + if (strncmp($arg, '--type=', 7) === 0) { + $type = substr($arg, 7); + } else { + $args[] = $arg; + } +} +if ($type !== 'plugin' && $type !== 'theme') { + fwrite(STDERR, "Type invalide : « $type » (plugin ou theme).\n"); + exit(1); +} +$isTheme = $type === 'theme'; +$kind = $isTheme ? 'thème' : 'plugin'; +$library = $isTheme ? 'wawp-theme-updater' : 'wawp-updater'; +$minFile = $isTheme ? 'min-theme-updater-version.txt' : 'min-updater-version.txt'; + +$dir = rtrim($args[0] ?? '', '/\\'); +$slug = $args[1] ?? ''; +$tag = $args[2] ?? ''; +$minUpdater = trim($args[3] ?? ''); $errors = []; $version = ''; @@ -36,15 +55,15 @@ }; if ($dir === '' || !is_dir($dir)) { - fwrite(STDERR, "Dossier du plugin introuvable : « $dir ».\n"); + fwrite(STDERR, "Dossier du $kind introuvable : « $dir ».\n"); exit(1); } if (preg_match('/^\d+\.\d+\.\d+\z/', $minUpdater) !== 1) { - fwrite(STDERR, "Version minimale de wawp-updater invalide : « $minUpdater » (format attendu X.Y.Z, config/min-updater-version.txt).\n"); + fwrite(STDERR, "Version minimale de $library invalide : « $minUpdater » (format attendu X.Y.Z, config/$minFile).\n"); exit(1); } if (preg_match('/^[a-z0-9-]{1,64}\z/', $slug) !== 1) { - $errors[] = "Slug invalide : « $slug » (le nom du dépôt doit être le slug du plugin, en minuscules)."; + $errors[] = "Slug invalide : « $slug » (le nom du dépôt doit être le slug du $kind, en minuscules)."; } if (preg_match('/^v(\d{1,5}\.\d{1,5}\.\d{1,5})\z/', $tag, $m) === 1) { $version = $m[1]; @@ -52,48 +71,100 @@ $errors[] = "Tag invalide : « $tag » (format attendu vX.Y.Z)."; } -$main = $read("$dir/$slug.php", 8192); -if ($main === null) { - $errors[] = "Fichier principal $slug.php absent."; -} else { - $name = $header($main, 'Plugin Name'); - if ($name === null || preg_match('/^[^\p{Cc}]{1,200}\z/u', $name) !== 1) { - $errors[] = 'En-tête Plugin Name absent ou invalide (UTF-8, 200 caractères au plus, sans caractère de contrôle).'; +if ($isTheme) { + $style = $read("$dir/style.css", 8192); + if ($style === null) { + $errors[] = 'style.css absent.'; + } else { + $name = $header($style, 'Theme Name'); + if ($name === null || preg_match('/^[^\p{Cc}]{1,200}\z/u', $name) !== 1) { + $errors[] = 'En-tête Theme Name absent ou invalide (UTF-8, 200 caractères au plus, sans caractère de contrôle).'; + } + $declared = $header($style, 'Version'); + if ($version !== '' && $declared !== $version) { + $errors[] = 'En-tête Version (' . ($declared ?? 'absent') . ") différent du tag ($version)."; + } + if ($header($style, 'Update URI') !== 'https://updates.wearewp.pro') { + $errors[] = 'En-tête Update URI absent ou différent de https://updates.wearewp.pro.'; + } + $themeUri = $header($style, 'Theme URI'); + if ($themeUri !== null && preg_match('/^https?:\/\/[^\s"<>\\\\\p{Cc}]{1,2040}\z/u', $themeUri) !== 1) { + $errors[] = 'En-tête Theme URI invalide (adresse http ou https, sans espace, guillemet, chevron, barre oblique inverse ni caractère de contrôle, 2048 caractères au plus).'; + } + $requires = $header($style, 'Requires at least'); + if ($requires === null || preg_match('/^\d{1,5}(?:\.\d{1,5}){1,3}\z/', $requires) !== 1 || version_compare($requires, '6.1', '<')) { + $errors[] = 'En-tête Requires at least absent ou inférieur à 6.1 (filtre update_themes_{hôte} de WordPress).'; + } + $requiresPhp = $header($style, 'Requires PHP'); + if ($requiresPhp === null || preg_match('/^\d{1,5}(?:\.\d{1,5}){1,3}\z/', $requiresPhp) !== 1) { + $errors[] = 'En-tête Requires PHP absent ou invalide.'; + } + if ($header($style, 'Text Domain') !== $slug) { + $errors[] = "En-tête Text Domain absent ou différent du slug ($slug) : le client s'en sert pour vérifier le dossier du thème."; + } + if ($header($style, 'Template') !== null) { + $errors[] = 'En-tête Template présent : les thèmes enfants ne sont pas pris en charge.'; + } } - $declared = $header($main, 'Version'); - if ($version !== '' && $declared !== $version) { - $errors[] = 'En-tête Version (' . ($declared ?? 'absent') . ") différent du tag ($version)."; + if (is_file("$dir/$slug.php")) { + $errors[] = "Fichier $slug.php présent à la racine : le serveur refuserait le paquet (plugin et thème ambigus)."; } - if ($header($main, 'Update URI') !== 'https://updates.wearewp.pro') { - $errors[] = 'En-tête Update URI absent ou différent de https://updates.wearewp.pro.'; + if (!is_file("$dir/functions.php")) { + $errors[] = 'functions.php absent.'; } - if ($header($main, 'GitHub Plugin URI') !== null) { - $errors[] = 'En-tête GitHub Plugin URI présent : retirer les en-têtes Git Updater.'; + if (!is_file("$dir/.distignore")) { + $errors[] = 'Fichier .distignore absent.'; + } + $themeConfig = $read("$dir/wawp-theme-updater/src/class-config.php"); + if ($themeConfig === null || preg_match("/const VERSION = '(\d+\.\d+\.\d+)';/", $themeConfig, $m) !== 1) { + $errors[] = 'wawp-theme-updater absent ou version illisible (wawp-theme-updater/src/class-config.php).'; + } elseif (version_compare($m[1], $minUpdater, '<')) { + $errors[] = "wawp-theme-updater {$m[1]} trop ancien (minimum $minUpdater)."; + } +} else { + $main = $read("$dir/$slug.php", 8192); + if ($main === null) { + $errors[] = "Fichier principal $slug.php absent."; + } else { + $name = $header($main, 'Plugin Name'); + if ($name === null || preg_match('/^[^\p{Cc}]{1,200}\z/u', $name) !== 1) { + $errors[] = 'En-tête Plugin Name absent ou invalide (UTF-8, 200 caractères au plus, sans caractère de contrôle).'; + } + $declared = $header($main, 'Version'); + if ($version !== '' && $declared !== $version) { + $errors[] = 'En-tête Version (' . ($declared ?? 'absent') . ") différent du tag ($version)."; + } + if ($header($main, 'Update URI') !== 'https://updates.wearewp.pro') { + $errors[] = 'En-tête Update URI absent ou différent de https://updates.wearewp.pro.'; + } + if ($header($main, 'GitHub Plugin URI') !== null) { + $errors[] = 'En-tête GitHub Plugin URI présent : retirer les en-têtes Git Updater.'; + } } -} -$readme = $read("$dir/readme.txt"); -if ($readme === null) { - $errors[] = 'readme.txt absent.'; -} elseif (preg_match('/^Stable tag:[ \t]*(\S+)[ \t]*$/mi', $readme, $m) !== 1 || ($version !== '' && $m[1] !== $version)) { - $errors[] = "Stable tag du readme.txt absent ou différent de $version."; -} + $readme = $read("$dir/readme.txt"); + if ($readme === null) { + $errors[] = 'readme.txt absent.'; + } elseif (preg_match('/^Stable tag:[ \t]*(\S+)[ \t]*$/mi', $readme, $m) !== 1 || ($version !== '' && $m[1] !== $version)) { + $errors[] = "Stable tag du readme.txt absent ou différent de $version."; + } -if (!is_file("$dir/.distignore")) { - $errors[] = 'Fichier .distignore absent.'; -} -if (is_dir("$dir/plugin-update-checker") || is_dir("$dir/vendor/yahnis-elsts/plugin-update-checker")) { - $errors[] = 'Plugin Update Checker encore embarqué : le retirer.'; -} + if (!is_file("$dir/.distignore")) { + $errors[] = 'Fichier .distignore absent.'; + } + if (is_dir("$dir/plugin-update-checker") || is_dir("$dir/vendor/yahnis-elsts/plugin-update-checker")) { + $errors[] = 'Plugin Update Checker encore embarqué : le retirer.'; + } -$load = $read("$dir/wawp-updater/load.php"); -if ($load === null || preg_match("/\\\$wawp_updater_version = '(\d+\.\d+\.\d+)';/", $load, $m) !== 1) { - $errors[] = 'wawp-updater absent ou version illisible (wawp-updater/load.php).'; -} elseif (version_compare($m[1], $minUpdater, '<')) { - $errors[] = "wawp-updater {$m[1]} trop ancien (minimum $minUpdater)."; + $load = $read("$dir/wawp-updater/load.php"); + if ($load === null || preg_match("/\\\$wawp_updater_version = '(\d+\.\d+\.\d+)';/", $load, $m) !== 1) { + $errors[] = 'wawp-updater absent ou version illisible (wawp-updater/load.php).'; + } elseif (version_compare($m[1], $minUpdater, '<')) { + $errors[] = "wawp-updater {$m[1]} trop ancien (minimum $minUpdater)."; + } } -$config = $read("$dir/wawp-updater/src/class-config.php"); +$config = $read("$dir/$library/src/class-config.php"); $keys = []; if ($config !== null && preg_match('/const PUBLIC_KEYS = array\(([^)]*)\);/', $config, $m) === 1) { preg_match_all("/'([A-Za-z0-9+\\/=]+)'/", $m[1], $found); @@ -105,7 +176,7 @@ return $raw !== false && strlen($raw) === 32; }); if (count($validKeys) !== 2 || count(array_unique($validKeys)) !== 2) { - $errors[] = 'PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes (wawp-updater/src/class-config.php).'; + $errors[] = "PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes ($library/src/class-config.php)."; } if ($errors !== []) { diff --git a/scripts/check-package.php b/scripts/check-package.php index 4cf49a1..889e844 100644 --- a/scripts/check-package.php +++ b/scripts/check-package.php @@ -1,7 +1,8 @@ )> + * Contrôle du paquet construit, juste avant le zip. Lecture seule, n'exécute jamais le code du plugin ou du thème. + * Usage : php check-package.php [--type=plugin|theme] )> + * Sans --type : plugin (comportement d'origine). */ declare(strict_types=1); @@ -10,9 +11,25 @@ exit(1); } -$dir = rtrim($argv[1] ?? '', '/\\'); -$slug = $argv[2] ?? ''; -$version = $argv[3] ?? ''; +$type = 'plugin'; +$args = []; +foreach (array_slice($argv, 1) as $arg) { + if (strncmp($arg, '--type=', 7) === 0) { + $type = substr($arg, 7); + } else { + $args[] = $arg; + } +} +if ($type !== 'plugin' && $type !== 'theme') { + fwrite(STDERR, "Type invalide : « $type » (plugin ou theme).\n"); + exit(1); +} +$isTheme = $type === 'theme'; +$library = $isTheme ? 'wawp-theme-updater' : 'wawp-updater'; + +$dir = rtrim($args[0] ?? '', '/\\'); +$slug = $args[1] ?? ''; +$version = $args[2] ?? ''; $errors = []; $read = static function (string $path, int $limit = 0): ?string { @@ -45,37 +62,83 @@ $errors[] = "Version invalide : « $version » (format attendu X.Y.Z)."; } -$required = ['wawp-updater/load.php', 'wawp-updater/bootstrap.php']; -foreach (['config', 'validator', 'signature', 'client', 'updater', 'package'] as $class) { - $required[] = "wawp-updater/src/class-$class.php"; -} -foreach ($required as $file) { - if (!is_file("$dir/$file")) { - $errors[] = "$file absent du paquet."; +if ($isTheme) { + $required = ['wawp-theme-updater/load.php', 'wawp-theme-updater/bootstrap.php']; + foreach (['config', 'validator', 'theme-validator', 'signature', 'client', 'package', 'updater'] as $class) { + $required[] = "wawp-theme-updater/src/class-$class.php"; + } + foreach ($required as $file) { + if (!is_file("$dir/$file")) { + $errors[] = "$file absent du paquet."; + } } -} -$main = $read("$dir/$slug.php"); -if ($main === null) { - $errors[] = "Fichier principal $slug.php absent du paquet."; -} else { - if (strpos($main, 'wawp-updater/load.php') === false) { - $errors[] = "$slug.php ne charge pas wawp-updater/load.php."; + $functions = $read("$dir/functions.php"); + if ($functions === null) { + $errors[] = 'functions.php absent du paquet.'; + } else { + if (strpos($functions, 'wawp-theme-updater/load.php') === false) { + $errors[] = 'functions.php ne charge pas wawp-theme-updater/load.php.'; + } + if (strpos($functions, '\WAWP\ThemeUpdater\register(') === false) { + $errors[] = "functions.php n'appelle pas \\WAWP\\ThemeUpdater\\register(."; + } } - if (strpos($main, '\WAWP\Updater\register(') === false) { - $errors[] = "$slug.php n'appelle pas \\WAWP\\Updater\\register(."; + + $style = $read("$dir/style.css", 8192); + if ($style === null) { + $errors[] = 'style.css absent du paquet.'; + } else { + $declared = $header($style, 'Version'); + if ($declared !== $version) { + $errors[] = 'En-tête Version de style.css (' . ($declared ?? 'absent') . ") différent de la version publiée ($version)."; + } + if ($header($style, 'Update URI') !== 'https://updates.wearewp.pro') { + $errors[] = 'En-tête Update URI de style.css absent ou différent de https://updates.wearewp.pro.'; + } + if ($header($style, 'Text Domain') !== $slug) { + $errors[] = "En-tête Text Domain absent ou différent du slug ($slug) : le client s'en sert pour vérifier le dossier du thème."; + } + if ($header($style, 'Template') !== null) { + $errors[] = 'En-tête Template présent : les thèmes enfants ne sont pas pris en charge.'; + } } - $head = substr($main, 0, 8192); - $declared = $header($head, 'Version'); - if ($declared !== $version) { - $errors[] = 'En-tête Version (' . ($declared ?? 'absent') . ") différent de la version publiée ($version)."; + if (is_file("$dir/$slug.php")) { + $errors[] = "$slug.php présent dans le paquet d'un thème : le serveur le refuserait."; } - if ($header($head, 'Update URI') !== 'https://updates.wearewp.pro') { - $errors[] = 'En-tête Update URI absent ou différent de https://updates.wearewp.pro.'; +} else { + $required = ['wawp-updater/load.php', 'wawp-updater/bootstrap.php']; + foreach (['config', 'validator', 'signature', 'client', 'updater', 'package'] as $class) { + $required[] = "wawp-updater/src/class-$class.php"; + } + foreach ($required as $file) { + if (!is_file("$dir/$file")) { + $errors[] = "$file absent du paquet."; + } + } + + $main = $read("$dir/$slug.php"); + if ($main === null) { + $errors[] = "Fichier principal $slug.php absent du paquet."; + } else { + if (strpos($main, 'wawp-updater/load.php') === false) { + $errors[] = "$slug.php ne charge pas wawp-updater/load.php."; + } + if (strpos($main, '\WAWP\Updater\register(') === false) { + $errors[] = "$slug.php n'appelle pas \\WAWP\\Updater\\register(."; + } + $head = substr($main, 0, 8192); + $declared = $header($head, 'Version'); + if ($declared !== $version) { + $errors[] = 'En-tête Version (' . ($declared ?? 'absent') . ") différent de la version publiée ($version)."; + } + if ($header($head, 'Update URI') !== 'https://updates.wearewp.pro') { + $errors[] = 'En-tête Update URI absent ou différent de https://updates.wearewp.pro.'; + } } } -$config = $read("$dir/wawp-updater/src/class-config.php"); +$config = $read("$dir/$library/src/class-config.php"); $keys = []; if ($config !== null && preg_match('/const PUBLIC_KEYS = array\(([^)]*)\);/', $config, $m) === 1) { preg_match_all("/'([A-Za-z0-9+\\/=]+)'/", $m[1], $found); @@ -87,7 +150,7 @@ return $raw !== false && strlen($raw) === 32; }); if (count($keys) !== 2 || count($validKeys) !== 2 || count(array_unique($validKeys)) !== 2) { - $errors[] = 'PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes (wawp-updater/src/class-config.php).'; + $errors[] = "PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes ($library/src/class-config.php)."; } $entries = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST); diff --git a/templates/theme-release.yml b/templates/theme-release.yml new file mode 100644 index 0000000..1bdf1be --- /dev/null +++ b/templates/theme-release.yml @@ -0,0 +1,14 @@ +name: Publication + +on: + push: + tags: ['v*.*.*'] + +permissions: {} + +jobs: + release: + permissions: + contents: write + uses: wearewp/release-tools/.github/workflows/theme-release.yml@v1.1 + secrets: inherit diff --git a/tests/Support/Http.php b/tests/Support/Http.php index d9a8391..24af99a 100644 --- a/tests/Support/Http.php +++ b/tests/Support/Http.php @@ -31,4 +31,24 @@ public static function offer(string $version, string $signature, string $token = 'signature' => $signature, ]; } + + /** @param array> $offers */ + public static function theme_body(array $offers): string + { + return (string) json_encode(['themes' => (object) $offers], JSON_UNESCAPED_SLASHES); + } + + /** @return array Offre de thème au format de /v1/check (spec thèmes 5.2). */ + public static function theme_offer(string $slug, string $version, string $signature, string $token = 'abc.def'): array + { + return [ + 'theme' => $slug, + 'new_version' => $version, + 'requires' => '6.1', + 'requires_php' => '7.4', + 'url' => 'https://www.wearewp.pro/', + 'package' => 'https://updates.wearewp.pro/v1/download/' . $token, + 'signature' => $signature, + ]; + } } diff --git a/tests/Unit/CheckHeadersTest.php b/tests/Unit/CheckHeadersTest.php index a05272f..f8b0530 100644 --- a/tests/Unit/CheckHeadersTest.php +++ b/tests/Unit/CheckHeadersTest.php @@ -89,4 +89,49 @@ public function testEachDefectIsReported(): void self::assertStringContainsString($expected, $out, $label); } } + + public function testFullErrorReportIsUnchanged(): void + { + unlink($this->dir . '/readme.txt'); + unlink($this->dir . '/.distignore'); + $this->write('wawp-demo.php', "dir, 'wawp-demo', 'v1.4.0', '1.0.0']); + self::assertSame(1, $code); + self::assertSame('', $out); + self::assertSame( + "Contrôles en échec : +- En-tête Version (1.3.9) différent du tag (1.4.0). +- En-tête Update URI absent ou différent de https://updates.wearewp.pro. +- readme.txt absent. +- Fichier .distignore absent. +", + $err + ); + } + + public function testExplicitPluginTypeIsTheDefault(): void + { + $script = Cli::php('scripts/check-headers.php'); + self::assertSame( + Cli::run([PHP_BINARY, $script, $this->dir, 'wawp-demo', 'v1.4.0', '1.0.0']), + Cli::run([PHP_BINARY, $script, '--type=plugin', $this->dir, 'wawp-demo', 'v1.4.0', '1.0.0']) + ); + $this->write('readme.txt', "Stable tag: 1.4.1 +"); + self::assertSame( + Cli::run([PHP_BINARY, $script, $this->dir, 'wawp-demo', 'v1.4.0', '1.0.0']), + Cli::run([PHP_BINARY, $script, $this->dir, 'wawp-demo', 'v1.4.0', '1.0.0', '--type=plugin']) + ); + } + + public function testUnknownTypeIsRefused(): void + { + [$code, , $err] = Cli::run([PHP_BINARY, Cli::php('scripts/check-headers.php'), '--type=child', $this->dir, 'wawp-demo', 'v1.4.0', '1.0.0']); + self::assertSame(1, $code); + self::assertStringContainsString('Type invalide : « child » (plugin ou theme).', $err); + } } diff --git a/tests/Unit/CheckPackageTest.php b/tests/Unit/CheckPackageTest.php index 12de841..7dd6770 100644 --- a/tests/Unit/CheckPackageTest.php +++ b/tests/Unit/CheckPackageTest.php @@ -103,4 +103,31 @@ public function testEachDefectIsReported(): void self::assertStringContainsString($expected, $out, $label); } } + + public function testFullErrorReportIsUnchanged(): void + { + unlink($this->dir . '/wawp-updater/bootstrap.php'); + $this->write('wawp-demo.php', self::main(version: '1.3.9', register: '')); + [$code, $out, $err] = Cli::run([PHP_BINARY, Cli::php('scripts/check-package.php'), $this->dir, 'wawp-demo', '1.4.0']); + self::assertSame(1, $code); + self::assertSame('', $out); + self::assertSame( + "Paquet non conforme :\n- wawp-updater/bootstrap.php absent du paquet.\n- wawp-demo.php n'appelle pas \\WAWP\\Updater\\register(.\n- En-tête Version (1.3.9) différent de la version publiée (1.4.0).\n", + $err + ); + } + + public function testExplicitPluginTypeIsTheDefault(): void + { + $script = Cli::php('scripts/check-package.php'); + self::assertSame( + Cli::run([PHP_BINARY, $script, $this->dir, 'wawp-demo', '1.4.0']), + Cli::run([PHP_BINARY, $script, '--type=plugin', $this->dir, 'wawp-demo', '1.4.0']) + ); + unlink($this->dir . '/wawp-updater/load.php'); + self::assertSame( + Cli::run([PHP_BINARY, $script, $this->dir, 'wawp-demo', '1.4.0']), + Cli::run([PHP_BINARY, $script, '--type=plugin', $this->dir, 'wawp-demo', '1.4.0']) + ); + } } diff --git a/tests/Unit/SetupRepoTest.php b/tests/Unit/SetupRepoTest.php index 9973e22..f5f312e 100644 --- a/tests/Unit/SetupRepoTest.php +++ b/tests/Unit/SetupRepoTest.php @@ -56,4 +56,23 @@ public function testRefusesBadArguments(): void self::assertSame(1, $code); self::assertStringContainsString('wearewp/', $err); } + + public function testThemesAreDetectedAndGetTheThemeWorkflow(): void + { + $script = self::script(); + foreach ([ + 'if [ -f "$path/style.css" ] && grep -q \'Theme Name:\' "$path/style.css" && [ ! -f "$path/$slug.php" ]; then', + 'template="theme-release.yml"', + 'cp "$tools/templates/$template" "$path/.github/workflows/release.yml"', + 'echo "Type détecté : $kind."', + "\"\$path/style.css\" 2>/dev/null", + 'wawp-theme-updater/load.php', + ] as $needle) { + self::assertStringContainsString($needle, $script, $needle); + } + // Contrôles d'origine des plugins conservés. + foreach (['template="release.yml"', '"$path/$slug.php" 2>/dev/null', '"$path/wawp-updater/load.php"'] as $needle) { + self::assertStringContainsString($needle, $script, $needle); + } + } } diff --git a/tests/Unit/SharedCodeTest.php b/tests/Unit/SharedCodeTest.php new file mode 100644 index 0000000..cbc1960 --- /dev/null +++ b/tests/Unit/SharedCodeTest.php @@ -0,0 +1,86 @@ + */ + public static function sharedFiles(): array + { + return [ + 'signature' => ['src/class-signature.php'], + 'validateur' => ['src/class-validator.php'], + ]; + } + + #[DataProvider('sharedFiles')] + public function testThemeCopyIsIdenticalToThePluginOneExceptTheNamespace(string $file): void + { + $root = dirname(__DIR__, 2); + $plugin = (string) file_get_contents("$root/wawp-updater/$file"); + $theme = (string) file_get_contents("$root/wawp-theme-updater/$file"); + self::assertStringContainsString("\nnamespace WAWP\ThemeUpdater;\n", $theme); + self::assertStringNotContainsString('WAWP\Updater', $theme); + self::assertSame($plugin, str_replace('WAWP\ThemeUpdater', 'WAWP\Updater', $theme)); + } + + /** @return array */ + public static function sharedMethods(): array + { + return [ + 'read' => ['read'], + 'call' => ['call'], + 'header' => ['header'], + ]; + } + + /** Corps d'une méthode : de sa signature à l'accolade fermante de même indentation. */ + private static function methodBody(string $source, string $method): string + { + $found = preg_match('/^\tprivate static function ' . $method . '\(.*?^\t}$/ms', $source, $match); + self::assertSame(1, $found, "méthode $method introuvable"); + + return $match[0]; + } + + /** Troisième copie partagée : les méthodes read(), call() et header() de Package. */ + #[DataProvider('sharedMethods')] + public function testPackageHelperMethodBodiesAreIdenticalInBothUpdaters(string $method): void + { + $root = dirname(__DIR__, 2); + $plugin = self::methodBody((string) file_get_contents("$root/wawp-updater/src/class-package.php"), $method); + $theme = self::methodBody((string) file_get_contents("$root/wawp-theme-updater/src/class-package.php"), $method); + self::assertSame($plugin, $theme); + } + + public function testSameServerAndSameSigningKeys(): void + { + self::assertSame(\WAWP\Updater\Config::PUBLIC_KEYS, \WAWP\ThemeUpdater\Config::PUBLIC_KEYS); + self::assertSame(\WAWP\Updater\Config::ENDPOINT, \WAWP\ThemeUpdater\Config::ENDPOINT); + self::assertSame(\WAWP\Updater\Config::HOST, \WAWP\ThemeUpdater\Config::HOST); + } + + /** Le contrôle de Theme URI de check-headers.php doit refuser exactement ce que le client et le serveur refusent. */ + public function testThemeUriPatternOfCheckHeadersMatchesTheClientOne(): void + { + $source = (string) file_get_contents(dirname(__DIR__, 2) . '/scripts/check-headers.php'); + self::assertSame(1, preg_match("/preg_match\\('([^']+)', \\\$themeUri\\)/", $source, $match), 'motif de Theme URI introuvable dans check-headers.php'); + // Le motif est un littéral PHP entre apostrophes : les doubles barres obliques inverses y valent une seule. + self::assertSame(\WAWP\ThemeUpdater\Theme_Validator::URL, strtr($match[1], ['\\\\' => '\\'])); + } + + public function testThemeMarkerIsOutsideThePrefixInterceptedByThePluginClient(): void + { + self::assertStringStartsNotWith(\WAWP\Updater\Config::ENDPOINT . '/v1/download/', \WAWP\ThemeUpdater\Config::PACKAGE_MARKER); + self::assertStringStartsWith(\WAWP\ThemeUpdater\Config::ENDPOINT . '/', \WAWP\ThemeUpdater\Config::PACKAGE_MARKER); + } +} diff --git a/tests/Unit/ThemeBootstrapTest.php b/tests/Unit/ThemeBootstrapTest.php new file mode 100644 index 0000000..11cd65c --- /dev/null +++ b/tests/Unit/ThemeBootstrapTest.php @@ -0,0 +1,70 @@ +dir = sys_get_temp_dir() . '/wawp-theme-bootstrap-' . bin2hex(random_bytes(4)); + $source = dirname(__DIR__, 2) . '/wawp-theme-updater'; + mkdir("{$this->dir}/src", 0777, true); + copy("$source/bootstrap.php", "{$this->dir}/bootstrap.php"); + foreach (self::CLASSES as $class) { + copy("$source/src/class-$class.php", "{$this->dir}/src/class-$class.php"); + } + } + + protected function tearDown(): void + { + foreach (glob("{$this->dir}/src/*") ?: [] as $file) { + unlink($file); + } + @unlink("{$this->dir}/bootstrap.php"); + @rmdir("{$this->dir}/src"); + @rmdir($this->dir); + } + + private function start(): string + { + $code = 'require ' . var_export(dirname(__DIR__) . '/wp-stubs.php', true) . ';' + . '$start = require ' . var_export("{$this->dir}/bootstrap.php", true) . ';' + . '$start("wawp-demo-theme", "/var/www/wp-content/themes/wawp-demo-theme");' + . 'echo "hooks:" . count(WawpTestState::$hooks);'; + $process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return $out; + } + + public function testCompleteCopyRegistersHooks(): void + { + self::assertSame('hooks:6', $this->start()); + } + + public function testMissingSourceFileRegistersNothingAndNeverFails(): void + { + foreach (self::CLASSES as $class) { + $this->tearDown(); + $this->setUp(); + unlink("{$this->dir}/src/class-$class.php"); + self::assertSame('hooks:0', $this->start(), "class-$class.php absent"); + } + } +} diff --git a/tests/Unit/ThemeCheckHeadersTest.php b/tests/Unit/ThemeCheckHeadersTest.php new file mode 100644 index 0000000..58043c0 --- /dev/null +++ b/tests/Unit/ThemeCheckHeadersTest.php @@ -0,0 +1,155 @@ +dir = sys_get_temp_dir() . '/wawp-check-theme-' . bin2hex(random_bytes(4)) . '/wawp-demo-theme'; + mkdir($this->dir . '/wawp-theme-updater/src', 0777, true); + $this->write('style.css', self::style()); + $this->write('functions.php', "write('.distignore', "/.git\n/tests\n"); + $this->write('wawp-theme-updater/load.php', "config([Keys::pair()['public'], Keys::pair()['public']]); + } + + protected function tearDown(): void + { + $root = dirname($this->dir); + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); + } + + /** @param array $headers null retire l'en-tête */ + private static function style(array $headers = []): string + { + $headers += [ + 'Theme Name' => 'WAWP Démo', + 'Version' => '1.4.0', + 'Requires at least' => '6.1', + 'Requires PHP' => '7.4', + 'Text Domain' => 'wawp-demo-theme', + 'Update URI' => 'https://updates.wearewp.pro', + ]; + $lines = ''; + foreach ($headers as $name => $value) { + if ($value !== null) { + $lines .= "$name: $value\n"; + } + } + + return "/*\n$lines*/\n"; + } + + private function write(string $path, string $content): void + { + file_put_contents($this->dir . '/' . $path, $content); + } + + /** @param list $keys */ + private function config(array $keys, string $version = '1.0.0'): void + { + $list = implode(', ', array_map(static fn (string $key): string => "'$key'", $keys)); + $this->write('wawp-theme-updater/src/class-config.php', "dir, $slug, $tag, $min]); + + return [$code, $out . $err]; + } + + public function testValidThemePasses(): void + { + [$code, $out] = $this->check(); + self::assertSame(0, $code, $out); + self::assertSame("Contrôles réussis : wawp-demo-theme 1.4.0\n", $out); + } + + public function testThemeUriIsOptionalButValidatedWhenPresent(): void + { + $this->write('style.css', self::style(['Theme URI' => 'https://example.com/theme'])); + [$code, $out] = $this->check(); + self::assertSame(0, $code, $out); + } + + public function testTypeMayComeLast(): void + { + [$code, $out, $err] = Cli::run([PHP_BINARY, Cli::php('scripts/check-headers.php'), $this->dir, 'wawp-demo-theme', 'v1.4.0', '1.0.0', '--type=theme']); + self::assertSame(0, $code, $out . $err); + } + + public function testAThemeIsNotAPlugin(): void + { + [$code, $out] = $this->check(type: '--type=plugin'); + self::assertSame(1, $code); + self::assertStringContainsString('Fichier principal wawp-demo-theme.php absent.', $out); + } + + public function testMinimumThemeUpdaterVersionIsPublished(): void + { + self::assertSame(\WAWP\ThemeUpdater\Config::VERSION, trim((string) file_get_contents(dirname(__DIR__, 2) . '/config/min-theme-updater-version.txt'))); + } + + public function testMalformedMinimumVersionOrMissingDirectory(): void + { + [$code, $out] = $this->check(min: '1.0'); + self::assertSame(1, $code); + self::assertStringContainsString('Version minimale de wawp-theme-updater invalide : « 1.0 » (format attendu X.Y.Z, config/min-theme-updater-version.txt).', $out); + + [$code, , $err] = Cli::run([PHP_BINARY, Cli::php('scripts/check-headers.php'), '--type=theme', '', 'wawp-demo-theme', 'v1.4.0', '1.0.0']); + self::assertSame(1, $code); + self::assertStringContainsString('Dossier du thème introuvable', $err); + } + + public function testEachDefectIsReported(): void + { + $cases = [ + 'style.css absent' => [fn () => unlink($this->dir . '/style.css'), null, 'style.css absent.'], + 'Theme Name' => [fn () => $this->write('style.css', self::style(['Theme Name' => null])), null, 'En-tête Theme Name'], + 'version' => [fn () => $this->write('style.css', self::style(['Version' => '1.3.9'])), null, 'En-tête Version (1.3.9) différent du tag (1.4.0).'], + 'update uri' => [fn () => $this->write('style.css', self::style(['Update URI' => 'https://updates.wearewp.pro/'])), null, 'Update URI'], + 'theme uri invalide' => [fn () => $this->write('style.css', self::style(['Theme URI' => 'ftp://example.com/theme'])), null, 'Theme URI'], + 'theme uri sans hôte' => [fn () => $this->write('style.css', self::style(['Theme URI' => 'not a url'])), null, 'Theme URI'], + 'requires at least trop bas' => [fn () => $this->write('style.css', self::style(['Requires at least' => '6.0'])), null, 'Requires at least'], + 'requires at least absent' => [fn () => $this->write('style.css', self::style(['Requires at least' => null])), null, 'Requires at least'], + 'requires at least sans point (le serveur exige au moins deux nombres)' => [fn () => $this->write('style.css', self::style(['Requires at least' => '7'])), null, 'Requires at least'], + 'requires php sans point (le serveur exige au moins deux nombres)' => [fn () => $this->write('style.css', self::style(['Requires PHP' => '8'])), null, 'En-tête Requires PHP absent ou invalide.'], + 'requires php à quatre points' => [fn () => $this->write('style.css', self::style(['Requires PHP' => '7.4.1.2.3'])), null, 'En-tête Requires PHP absent ou invalide.'], + 'requires php absent' => [fn () => $this->write('style.css', self::style(['Requires PHP' => null])), null, 'Requires PHP'], + 'text domain' => [fn () => $this->write('style.css', self::style(['Text Domain' => 'autre'])), null, 'Text Domain'], + 'thème enfant' => [fn () => $this->write('style.css', self::style(['Template' => 'twentytwentyfive'])), null, 'En-tête Template présent'], + 'fichier de plugin' => [fn () => $this->write('wawp-demo-theme.php', " [fn () => unlink($this->dir . '/functions.php'), null, 'functions.php absent.'], + 'distignore' => [fn () => unlink($this->dir . '/.distignore'), null, 'Fichier .distignore absent.'], + 'bibliothèque absente' => [fn () => unlink($this->dir . '/wawp-theme-updater/src/class-config.php'), null, 'wawp-theme-updater absent ou version illisible'], + 'bibliothèque ancienne' => [fn () => null, ['wawp-demo-theme', 'v1.4.0', '1.1.0'], 'wawp-theme-updater 1.0.0 trop ancien (minimum 1.1.0).'], + 'une seule clé' => [fn () => $this->config([Keys::pair()['public']]), null, 'PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes (wawp-theme-updater/src/class-config.php).'], + 'tag' => [fn () => null, ['wawp-demo-theme', 'v1.4', '1.0.0'], 'Tag invalide'], + 'slug' => [fn () => null, ['Wawp_Demo', 'v1.4.0', '1.0.0'], 'slug du thème'], + ]; + foreach ($cases as $label => [$mutate, $args, $expected]) { + $this->tearDown(); + $this->setUp(); + $mutate(); + [$code, $out] = $args === null ? $this->check() : $this->check(...$args); + self::assertSame(1, $code, "$label : $out"); + self::assertStringContainsString($expected, $out, $label); + } + } +} diff --git a/tests/Unit/ThemeCheckPackageTest.php b/tests/Unit/ThemeCheckPackageTest.php new file mode 100644 index 0000000..64aa3b9 --- /dev/null +++ b/tests/Unit/ThemeCheckPackageTest.php @@ -0,0 +1,121 @@ +dir = sys_get_temp_dir() . '/wawp-theme-pkg-' . bin2hex(random_bytes(4)) . '/build/wawp-demo-theme'; + mkdir($this->dir . '/wawp-theme-updater/src', 0777, true); + $this->keys = [Keys::pair()['public'], Keys::pair()['public']]; + $this->write('style.css', self::style()); + $this->write('functions.php', self::functions()); + $this->write('wawp-theme-updater/load.php', "write('wawp-theme-updater/bootstrap.php', "write("wawp-theme-updater/src/class-$class.php", "config($this->keys); + } + + protected function tearDown(): void + { + $root = dirname($this->dir, 2); + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($root); + } + + private static function style(string $version = '1.4.0', string $uri = 'https://updates.wearewp.pro', string $textDomain = 'wawp-demo-theme', string $extra = ''): string + { + return "/*\nTheme Name: WAWP Démo\nVersion: $version\nRequires at least: 6.1\nRequires PHP: 7.4\nText Domain: $textDomain\nUpdate URI: $uri\n$extra*/\n"; + } + + private static function functions(string $load = "require __DIR__ . '/wawp-theme-updater/load.php';", string $register = '\WAWP\ThemeUpdater\register( __DIR__ );'): string + { + return "dir . '/' . $path, $content); + } + + /** @param list $keys */ + private function config(array $keys): void + { + $list = implode(', ', array_map(static fn (string $key): string => "'$key'", $keys)); + $this->write('wawp-theme-updater/src/class-config.php', "dir, $slug, $version]); + + return [$code, $out . $err]; + } + + public function testValidThemePackagePasses(): void + { + [$code, $out] = $this->check(); + self::assertSame(0, $code, $out); + self::assertSame("Paquet conforme : wawp-demo-theme 1.4.0\n", $out); + } + + public function testAThemePackageIsNotAPluginPackage(): void + { + [$code, $out] = $this->check(type: '--type=plugin'); + self::assertSame(1, $code); + self::assertStringContainsString('Fichier principal wawp-demo-theme.php absent du paquet.', $out); + } + + public function testEachDefectIsReported(): void + { + $cases = [ + 'load.php absent' => [fn () => unlink($this->dir . '/wawp-theme-updater/load.php'), null, 'wawp-theme-updater/load.php absent du paquet.'], + 'bootstrap.php absent' => [fn () => unlink($this->dir . '/wawp-theme-updater/bootstrap.php'), null, 'wawp-theme-updater/bootstrap.php absent du paquet.'], + 'functions.php absent' => [fn () => unlink($this->dir . '/functions.php'), null, 'functions.php absent du paquet.'], + 'chargement absent' => [fn () => $this->write('functions.php', self::functions(load: '')), null, 'functions.php ne charge pas wawp-theme-updater/load.php.'], + 'inscription absente' => [fn () => $this->write('functions.php', self::functions(register: '')), null, 'functions.php n\'appelle pas \\WAWP\\ThemeUpdater\\register(.'], + 'style.css absent' => [fn () => unlink($this->dir . '/style.css'), null, 'style.css absent du paquet.'], + 'version' => [fn () => $this->write('style.css', self::style(version: '1.3.9')), null, 'En-tête Version de style.css (1.3.9) différent de la version publiée (1.4.0).'], + 'update uri' => [fn () => $this->write('style.css', self::style(uri: 'https://updates.wearewp.pro/')), null, 'Update URI'], + 'text domain différent du slug' => [fn () => $this->write('style.css', self::style(textDomain: 'autre')), null, 'En-tête Text Domain absent ou différent du slug (wawp-demo-theme) : le client s\'en sert pour vérifier le dossier du thème.'], + 'thème enfant' => [fn () => $this->write('style.css', self::style(extra: "Template: twentytwentyfive\n")), null, 'En-tête Template présent : les thèmes enfants ne sont pas pris en charge.'], + 'fichier de plugin' => [fn () => $this->write('wawp-demo-theme.php', " [fn () => $this->config([$this->keys[0]]), null, 'PUBLIC_KEYS doit contenir exactement deux clés publiques Ed25519 distinctes (wawp-theme-updater/src/class-config.php).'], + 'clés identiques' => [fn () => $this->config([$this->keys[0], $this->keys[0]]), null, 'PUBLIC_KEYS'], + 'dossier .git' => [fn () => mkdir($this->dir . '/vendor/lib/.git', 0777, true), null, '.git'], + 'slug invalide' => [fn () => null, [null, 'Wawp_Demo', '1.4.0'], 'Slug invalide'], + 'version invalide' => [fn () => null, [null, 'wawp-demo-theme', '1.4'], 'Version invalide'], + 'dossier vide' => [fn () => null, ['', 'wawp-demo-theme', '1.4.0'], 'Dossier'], + ]; + foreach (self::CLASSES as $class) { + $cases["class-$class.php absent"] = [fn () => unlink($this->dir . "/wawp-theme-updater/src/class-$class.php"), null, "wawp-theme-updater/src/class-$class.php absent du paquet."]; + } + foreach ($cases as $label => [$mutate, $args, $expected]) { + $this->tearDown(); + $this->setUp(); + $mutate(); + [$code, $out] = $args === null ? $this->check() : $this->check(...$args); + self::assertSame(1, $code, "$label : $out"); + self::assertStringContainsString($expected, $out, $label); + } + } +} diff --git a/tests/Unit/ThemeClientTest.php b/tests/Unit/ThemeClientTest.php new file mode 100644 index 0000000..97354e6 --- /dev/null +++ b/tests/Unit/ThemeClientTest.php @@ -0,0 +1,65 @@ + Http::response(200, Http::theme_body(['wawp-demo-theme' => Http::theme_offer('wawp-demo-theme', '1.4.0', $signature)])); + + $result = (new Client())->check(self::KEY, 'https://client.fr', ['wawp-demo-theme' => '1.3.0']); + + self::assertSame(200, $result['status']); + self::assertNotNull($result['offers']); + self::assertSame('1.4.0', $result['offers']['wawp-demo-theme']['version']); + self::assertSame('https://www.wearewp.pro/', $result['offers']['wawp-demo-theme']['url']); + [$method, $url, $args] = WawpTestState::$requests[0]; + self::assertSame('POST', $method); + self::assertSame('https://updates.wearewp.pro/v1/check', $url); + self::assertSame('Bearer ' . self::KEY, $args['headers']['Authorization']); + self::assertSame('application/json', $args['headers']['Content-Type']); + self::assertSame(10, $args['timeout']); + self::assertSame(0, $args['redirection']); + self::assertTrue($args['sslverify']); + self::assertSame( + ['site' => 'https://client.fr', 'themes' => ['wawp-demo-theme' => '1.3.0'], 'wp' => '6.8.1', 'php' => PHP_VERSION, 'client' => '1.0.0'], + json_decode($args['body'], true) + ); + self::assertStringStartsWith('wawp-theme-updater/1.0.0; ', $args['user-agent']); + self::assertStringNotContainsString(self::KEY, $args['user-agent']); + } + + public function testStatusIsReportedAndOffersAreNullOnFailure(): void + { + $cases = [ + 'réseau' => [static fn () => new WP_Error('http_request_failed', 'timeout'), 0], + 'licence refusée' => [static fn (): array => Http::response(401, '{"status":"unauthorized"}'), 401], + 'serveur indisponible' => [static fn (): array => Http::response(503, '{"status":"unavailable"}'), 503], + 'réponse invalide' => [static fn (): array => Http::response(200, 'pas du JSON'), 200], + 'réponse de plugins' => [static fn (): array => Http::response(200, '{"plugins":{}}'), 200], + ]; + foreach ($cases as $label => [$http, $status]) { + WawpTestState::$http = $http; + $result = (new Client())->check(self::KEY, 'https://client.fr', ['wawp-demo-theme' => '1.3.0']); + self::assertSame($status, $result['status'], $label); + self::assertNull($result['offers'], $label); + } + } +} diff --git a/tests/Unit/ThemeLoaderTest.php b/tests/Unit/ThemeLoaderTest.php new file mode 100644 index 0000000..c00d386 --- /dev/null +++ b/tests/Unit/ThemeLoaderTest.php @@ -0,0 +1,116 @@ +dir = sys_get_temp_dir() . '/wawp-theme-loader-' . bin2hex(random_bytes(4)); + $this->theme = "{$this->dir}/themes/wawp-demo-theme"; + $this->parent = "{$this->dir}/themes/theme-parent"; + foreach ([$this->theme, $this->parent] as $root) { + mkdir("$root/wawp-theme-updater", 0777, true); + copy(dirname(__DIR__, 2) . '/wawp-theme-updater/load.php', "$root/wawp-theme-updater/load.php"); + file_put_contents("$root/wawp-theme-updater/bootstrap.php", "dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->dir); + } + + private function runPhp(string $calls): string + { + $code = 'define("ABSPATH", "/");' + . 'function get_stylesheet() { return "wawp-demo-theme"; }' + . 'function get_stylesheet_directory() { return ' . var_export($this->theme, true) . '; }' + . $calls; + $process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return $out; + } + + private static function load(string $root): string + { + return 'require ' . var_export("$root/wawp-theme-updater/load.php", true) . ';'; + } + + private static function register(mixed $dir): string + { + return '\WAWP\ThemeUpdater\register(' . var_export($dir, true) . ');'; + } + + public function testActiveThemeStartsTheClientOnce(): void + { + $out = $this->runPhp(self::load($this->theme) . self::register($this->theme) . self::register($this->theme . '/')); + self::assertSame('start:wawp-demo-theme:wawp-demo-theme;', $out); + } + + public function testParentThemeForeignDirectoryOrBadArgumentIsIgnored(): void + { + $out = $this->runPhp( + self::load($this->parent) . self::load($this->theme) + . self::register($this->parent) + . self::register($this->dir . '/absent') + . self::register(42) + . self::register(null) + . 'echo "fin";' + ); + self::assertSame('fin', $out, 'deux copies chargées sans « Cannot redeclare », aucun démarrage'); + } + + public function testMissingBootstrapIsSilent(): void + { + unlink("{$this->theme}/wawp-theme-updater/bootstrap.php"); + self::assertSame('fin', $this->runPhp(self::load($this->theme) . self::register($this->theme) . 'echo "fin";')); + } + + public function testThrowingBootstrapNeverBreaksTheSite(): void + { + file_put_contents("{$this->theme}/wawp-theme-updater/bootstrap.php", "runPhp(self::load($this->theme) . self::register($this->theme) . 'echo "fin";')); + } + + public function testLoaderLeavesNoVariableInGlobalScope(): void + { + $out = $this->runPhp( + self::load($this->theme) . self::register($this->theme) + . 'echo count(array_filter(array_keys(get_defined_vars()), static fn ($name) => str_starts_with((string) $name, "wawp")));' + ); + self::assertSame('start:wawp-demo-theme:wawp-demo-theme;0', $out, 'chargement et register() ne laissent aucune variable globale'); + } + + public function testEmptyDirectoryIsIgnored(): void + { + $cwd = (string) getcwd(); + chdir($this->theme); + try { + $out = $this->runPhp(self::load($this->theme) . self::register('') . 'echo "fin";'); + } finally { + chdir($cwd); + } + self::assertSame('fin', $out, "'' ne doit pas se résoudre au dossier courant"); + } +} diff --git a/tests/Unit/ThemePackageTest.php b/tests/Unit/ThemePackageTest.php new file mode 100644 index 0000000..fc25c6d --- /dev/null +++ b/tests/Unit/ThemePackageTest.php @@ -0,0 +1,143 @@ +dir = sys_get_temp_dir() . '/wawp-theme-package-' . bin2hex(random_bytes(4)); + mkdir($this->dir, 0777, true); + } + + protected function tearDown(): void + { + $it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->dir); + } + + public static function style(string $version, string $slug = self::SLUG, string $eol = "\n"): string + { + return str_replace("\n", $eol, "/*\nTheme Name: WAWP Démo\nVersion: $version\nRequires at least: 6.1\nRequires PHP: 7.4\nText Domain: $slug\nUpdate URI: https://updates.wearewp.pro\n*/\n"); + } + + /** @return array */ + public static function entries(string $slug, string $version): array + { + return [ + "$slug/" => '', + "$slug/style.css" => self::style($version, $slug), + "$slug/functions.php" => " "\n", + "$slug/wawp-theme-updater/load.php" => 'dir}/a.zip", self::entries(self::SLUG, '1.4.0')), self::SLUG, '1.4.0')); + self::assertTrue(Package::matches(PackageTest::zip("{$this->dir}/b.zip", [self::SLUG . '/style.css' => self::style('1.4.0', self::SLUG, "\r\n")]), self::SLUG, '1.4.0'), 'fin de ligne CRLF'); + } + + public function testEachMismatchIsRefused(): void + { + $valid = self::entries(self::SLUG, '1.4.0'); + $cases = [ + 'autre version' => [self::entries(self::SLUG, '1.3.5'), self::SLUG, '1.4.0'], + 'autre slug' => [$valid, 'theme-autre', '1.4.0'], + 'entrée hors du dossier' => [$valid + ['autre/x.php' => ' [$valid + ['wawp-demo-themex/x.php' => ' [$valid + ['wawp-demo-theme/../x.php' => ' [$valid + ['wawp-demo-theme/./x.php' => ' [$valid + ['wawp-demo-theme\\x.php' => ' [$valid + ['wawp-demo-theme/sub\\x.php' => ' [[self::SLUG . '/style.css' => self::style('1.3.5'), self::SLUG . '/sub/style.css' => self::style('1.4.0')], self::SLUG, '1.4.0'], + 'casse en double' => [$valid + ['wawp-demo-theme/Functions.php' => ' [[self::SLUG . '/functions.php' => ' [[self::SLUG . '/style.css' => "body { color: red; }\n"], self::SLUG, '1.4.0'], + 'en-tête au-delà de 8 Ko' => [[self::SLUG . '/style.css' => '/*' . str_repeat("remplissage\n", 700) . '*/' . self::style('1.4.0')], self::SLUG, '1.4.0'], + 'slug invalide' => [$valid, '../wawp-demo-theme', '1.4.0'], + 'version vide' => [$valid, self::SLUG, ''], + ]; + $i = 0; + foreach ($cases as $label => [$entries, $slug, $version]) { + $zip = PackageTest::zip("{$this->dir}/c" . $i++ . '.zip', $entries); + self::assertFalse(Package::matches($zip, $slug, $version), $label); + } + } + + public function testDuplicateEntryIsRefused(): void + { + // Deux entrées « style.css » : à l'extraction, la seconde (9.9.9) écraserait celle qui a été contrôlée. + $zip = PackageTest::zip("{$this->dir}/d.zip", self::entries(self::SLUG, '1.4.0') + [self::SLUG . '/style.csz' => self::style('9.9.9')]); + file_put_contents($zip, str_replace(self::SLUG . '/style.csz', self::SLUG . '/style.css', (string) file_get_contents($zip))); + $archive = new \ZipArchive(); + self::assertTrue($archive->open($zip), 'archive toujours lisible'); + $archive->close(); + + self::assertFalse(Package::matches($zip, self::SLUG, '1.4.0')); + } + + public function testUnreadableArchiveIsRefused(): void + { + file_put_contents("{$this->dir}/faux.zip", random_bytes(512)); + self::assertFalse(Package::matches("{$this->dir}/faux.zip", self::SLUG, '1.4.0')); + self::assertFalse(Package::matches("{$this->dir}/absent.zip", self::SLUG, '1.4.0')); + } + + /** Sous-processus sans php.ini (php -n) : ZipArchive indisponible. */ + private function withoutZipArchive(string $abspath, string $zip, string $version): string + { + $src = dirname(__DIR__, 2) . '/wawp-theme-updater/src'; + $code = 'define("ABSPATH", ' . var_export($abspath, true) . ');' + . 'require ' . var_export("$src/class-config.php", true) . ';' + . 'require ' . var_export("$src/class-validator.php", true) . ';' + . 'require ' . var_export("$src/class-package.php", true) . ';' + . 'echo class_exists("ZipArchive", false) ? "zip:" : "", var_export(\WAWP\ThemeUpdater\Package::matches(' . var_export($zip, true) . ', "wawp-demo-theme", ' . var_export($version, true) . '), true);'; + $process = proc_open([PHP_BINARY, '-n', '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + $out = (string) stream_get_contents($pipes[1]) . (string) stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + return $out; + } + + public function testWithoutZipArchiveNorPclZipThePackageIsRefused(): void + { + $zip = PackageTest::zip("{$this->dir}/a.zip", self::entries(self::SLUG, '1.4.0')); + self::assertSame('false', $this->withoutZipArchive($this->dir . '/', $zip, '1.4.0')); + } + + public function testPclZipFromCoreIsUsedWhenZipArchiveIsMissing(): void + { + $wordpress = dirname(__DIR__) . '/e2e/cache/wordpress-' . json_decode((string) file_get_contents(dirname(__DIR__) . '/e2e/versions.json'), true)['wordpress']['version'] . '.zip'; + if (!is_file($wordpress)) { + self::markTestSkipped('Archive WordPress absente du cache e2e (lancer composer e2e une fois).'); + } + $core = new \ZipArchive(); + $core->open($wordpress); + mkdir("{$this->dir}/wp/wp-admin/includes", 0777, true); + file_put_contents("{$this->dir}/wp/wp-admin/includes/class-pclzip.php", (string) $core->getFromName('wordpress/wp-admin/includes/class-pclzip.php')); + $core->close(); + + $zip = PackageTest::zip("{$this->dir}/a.zip", self::entries(self::SLUG, '1.4.0')); + self::assertSame('true', $this->withoutZipArchive("{$this->dir}/wp/", $zip, '1.4.0')); + self::assertSame('false', $this->withoutZipArchive("{$this->dir}/wp/", $zip, '1.5.0')); + $foreign = PackageTest::zip("{$this->dir}/b.zip", self::entries(self::SLUG, '1.4.0') + ['autre/x.php' => 'withoutZipArchive("{$this->dir}/wp/", $foreign, '1.4.0')); + } +} diff --git a/tests/Unit/ThemeUpdaterKeyTest.php b/tests/Unit/ThemeUpdaterKeyTest.php new file mode 100644 index 0000000..026dd9c --- /dev/null +++ b/tests/Unit/ThemeUpdaterKeyTest.php @@ -0,0 +1,62 @@ + */ + public static function invalidKeys(): array + { + return [ + 'absente' => [null], + 'espaces' => [' wawp_' . str_repeat('A', 43) . ' '], + 'entier' => [12345], + 'tronquée' => ['wawp_' . str_repeat('A', 40)], + ]; + } + + #[DataProvider('invalidKeys')] + public function testInvalidKeyMeansNoRequestAndNotice(mixed $key): void + { + if ($key !== null) { + define('WAWP_LICENSE_KEY', $key); + } + WawpTestState::reset(); + WawpTestState::$versions[self::DIR . '/style.css'] = '1.3.0'; + WawpTestState::$headers[self::DIR . '/style.css'] = ['Text Domain' => 'wawp-demo-theme']; + WawpTestState::$http = static function (): array { + throw new \LogicException('aucune requête attendue'); + }; + $updater = new Updater('wawp-demo-theme', self::DIR, new Client(), []); + + self::assertFalse($updater->filter_update(false, [], 'wawp-demo-theme', [])); + self::assertSame([], WawpTestState::$requests); + $result = $updater->filter_pre_download(false, 'https://updates.wearewp.pro/v1/theme-update/wawp-demo-theme', null, ['theme' => 'wawp-demo-theme']); + self::assertSame('wawp_theme_token_unavailable', $result->get_error_code()); + self::assertSame([], WawpTestState::$requests); + + WawpTestState::$screen = (object) ['id' => 'themes']; + ob_start(); + $updater->admin_notice(); + $notice = (string) ob_get_clean(); + self::assertStringContainsString('WAWP_LICENSE_KEY', $notice); + self::assertStringContainsString('notice-warning', $notice); + + WawpTestState::$can = false; + ob_start(); + $updater->admin_notice(); + self::assertSame('', ob_get_clean(), 'rien pour un utilisateur sans droit de mise à jour des thèmes'); + } +} diff --git a/tests/Unit/ThemeUpdaterTest.php b/tests/Unit/ThemeUpdaterTest.php new file mode 100644 index 0000000..60bc91b --- /dev/null +++ b/tests/Unit/ThemeUpdaterTest.php @@ -0,0 +1,360 @@ + self::SLUG]; + $this->pair = Keys::pair(); + $this->zip = sys_get_temp_dir() . '/wawp-theme-upd-' . bin2hex(random_bytes(4)) . '.zip'; + $this->serve('1.4.0', ThemePackageTest::entries(self::SLUG, '1.4.0')); + $zip = $this->zip; + WawpTestState::$download = static function () use ($zip): string { + copy($zip, $zip . '.dl'); + + return $zip . '.dl'; + }; + } + + /** + * Le serveur propose $offer ; le paquet signé contient $entries. + * + * @param array $entries + */ + private function serve(string $offer, array $entries): void + { + if (is_file($this->zip)) { + unlink($this->zip); + } + PackageTest::zip($this->zip, $entries); + $signature = Keys::sign_file($this->zip, $this->pair['secret']); + WawpTestState::$http = function () use ($signature, $offer): array { + $this->token++; + + return Http::response(200, Http::theme_body([self::SLUG => Http::theme_offer(self::SLUG, $offer, $signature, 'jeton' . $this->token)])); + }; + } + + protected function tearDown(): void + { + foreach ([$this->zip, $this->zip . '.dl'] as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + /** @param list|null $keys */ + private function updater(?array $keys = null): Updater + { + return new Updater(self::SLUG, self::DIR, new Client(), $keys ?? [$this->pair['public']]); + } + + private function notice(Updater $updater, string $screen): string + { + WawpTestState::$screen = (object) ['id' => $screen]; + ob_start(); + $updater->admin_notice(); + + return (string) ob_get_clean(); + } + + public function testHooksAreRegistered(): void + { + $this->updater()->hooks(); + foreach (['update_themes_updates.wearewp.pro', 'upgrader_pre_download', 'upgrader_process_complete', 'load-update-core.php', 'admin_notices', 'network_admin_notices'] as $hook) { + self::assertArrayHasKey($hook, WawpTestState::$hooks, $hook); + } + self::assertSame([1], WawpTestState::$priorities['upgrader_pre_download']); + self::assertSame([9], WawpTestState::$priorities['load-update-core.php']); + self::assertArrayNotHasKey('update_plugins_updates.wearewp.pro', WawpTestState::$hooks); + } + + public function testUpdateIsOfferedFromOneCachedRequestWithoutTokenInWordPressCache(): void + { + $updater = $this->updater(); + $update = $updater->filter_update(false, [], self::SLUG, []); + self::assertSame( + [ + 'theme' => self::SLUG, + 'version' => '1.4.0', + 'url' => 'https://www.wearewp.pro/', + 'package' => self::MARKER, + 'requires' => '6.1', + 'requires_php' => '7.4', + ], + $update + ); + + $updater->filter_update(false, [], self::SLUG, []); + self::assertCount(1, WawpTestState::$requests, 'la seconde lecture vient du cache'); + $cache = WawpTestState::$transients['wawp_theme_updater_check']; + self::assertSame(43200, $cache['ttl']); + self::assertFalse($cache['value']['denied']); + self::assertStringNotContainsString(self::KEY, (string) json_encode(WawpTestState::$transients)); + + [, $url, $args] = WawpTestState::$requests[0]; + self::assertSame('https://updates.wearewp.pro/v1/check', $url); + $body = json_decode($args['body'], true); + self::assertSame([self::SLUG => '1.3.0'], $body['themes']); + self::assertArrayNotHasKey('plugins', $body); + self::assertSame('https://client.fr', $body['site']); + } + + public function testOtherThemesAreLeftAlone(): void + { + self::assertSame('intact', $this->updater()->filter_update('intact', [], 'twentytwentyfive', [])); + self::assertSame([], WawpTestState::$requests); + } + + public function testUpToDateThemeIsReportedAsNoUpdate(): void + { + WawpTestState::$http = static fn () => Http::response(200, Http::theme_body([])); + self::assertSame( + ['theme' => self::SLUG, 'version' => '1.3.0', 'url' => '', 'package' => ''], + $this->updater()->filter_update(false, [], self::SLUG, []) + ); + } + + public function testServerFailureIsCachedOneHourAndBreaksNothing(): void + { + WawpTestState::$http = static fn () => new WP_Error('http_request_failed', 'timeout'); + $updater = $this->updater(); + $update = $updater->filter_update(false, [], self::SLUG, []); + self::assertSame('', $update['package'], 'aucun paquet proposé'); + self::assertSame(3600, WawpTestState::$transients['wawp_theme_updater_check']['ttl']); + self::assertFalse(WawpTestState::$transients['wawp_theme_updater_check']['value']['denied']); + self::assertSame('', $this->notice($updater, 'themes'), 'panne réseau : aucune notice de licence'); + } + + public function testRefusedLicenceIsCachedAndShownOnTheThemesScreen(): void + { + WawpTestState::$http = static fn () => Http::response(401, '{"status":"unauthorized"}'); + $updater = $this->updater(); + $updater->filter_update(false, [], self::SLUG, []); + self::assertTrue(WawpTestState::$transients['wawp_theme_updater_check']['value']['denied']); + self::assertSame(3600, WawpTestState::$transients['wawp_theme_updater_check']['ttl']); + + self::assertStringContainsString('a refusé la licence', $this->notice($updater, 'themes')); + self::assertStringContainsString('a refusé la licence', $this->notice($updater, 'themes-network')); + self::assertSame('', $this->notice($updater, 'plugins'), 'uniquement sur l\'écran Thèmes'); + WawpTestState::$can = false; + self::assertSame('', $this->notice($updater, 'themes'), 'rien sans le droit update_themes'); + } + + public function testFolderNotMatchingTheTextDomainDisablesUpdatesAndShowsNotice(): void + { + WawpTestState::$headers[self::STYLE] = ['Text Domain' => 'theme-client-x']; + $updater = $this->updater(); + self::assertFalse($updater->filter_update(false, [], self::SLUG, [])); + self::assertSame([], WawpTestState::$requests, 'aucune requête avec un slug faux'); + self::assertStringContainsString('dossier du thème actif', $this->notice($updater, 'themes')); + } + + public function testInvalidFolderNameIsIgnored(): void + { + $updater = new Updater('Wawp Demo Theme-main', self::DIR, new Client(), [$this->pair['public']]); + self::assertFalse($updater->filter_update(false, [], 'Wawp Demo Theme-main', [])); + self::assertSame([], WawpTestState::$requests); + self::assertStringContainsString('dossier du thème actif', $this->notice($updater, 'themes')); + } + + public function testMultisiteSendsNetworkUrl(): void + { + WawpTestState::$multisite = true; + $this->updater()->filter_update(false, [], self::SLUG, []); + self::assertSame('https://reseau-client.fr', json_decode(WawpTestState::$requests[0][2]['body'], true)['site']); + } + + public function testExceptionInsideTheHttpLayerNeverEscapes(): void + { + WawpTestState::$http = static function (): array { + throw new \RuntimeException('panne'); + }; + $updater = $this->updater(); + self::assertFalse($updater->filter_update(false, [], self::SLUG, [])); + $result = $updater->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_theme_updater_error', $result->get_error_code()); + } + + public function testPreDownloadUsesFreshTokenAndVerifiesSignature(): void + { + $updater = $this->updater(); + $package = $updater->filter_update(false, [], self::SLUG, [])['package']; + + $urls = []; + $zip = $this->zip; + WawpTestState::$download = static function (string $url) use ($zip, &$urls): string { + $urls[] = $url; + copy($zip, $zip . '.dl'); + + return $zip . '.dl'; + }; + $path = $updater->filter_pre_download(false, $package, null, ['theme' => self::SLUG, 'type' => 'theme', 'action' => 'update']); + + self::assertSame($this->zip . '.dl', $path); + self::assertCount(2, WawpTestState::$requests, 'un /v1/check frais au téléchargement'); + self::assertSame(['https://updates.wearewp.pro/v1/download/jeton2'], $urls); + } + + public function testExceptionAfterTheDownloadDeletesTheTemporaryFile(): void + { + $zip = $this->zip; + WawpTestState::$download = static function () use ($zip): string { + copy($zip, $zip . '.dl'); + // La lecture de style.css échoue une fois le fichier téléchargé et signé. + WawpTestState::$versions[self::STYLE] = new \RuntimeException('panne'); + + return $zip . '.dl'; + }; + $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_theme_updater_error', $result->get_error_code()); + self::assertFileDoesNotExist($this->zip . '.dl', 'le fichier temporaire ne doit pas rester sur le disque'); + } + + public function testPreDownloadWorksWithBulkUpgradeContext(): void + { + // bulk_upgrade() (écran Mises à jour, AJAX, WP-CLI) ne passe que 'theme' et 'temp_backup'. + $path = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG, 'temp_backup' => ['slug' => self::SLUG]]); + self::assertSame($this->zip . '.dl', $path); + } + + public function testPreDownloadIgnoresPackagesThatAreNotThemeMarkers(): void + { + $updater = $this->updater(); + self::assertFalse($updater->filter_pre_download(false, 'https://downloads.wordpress.org/theme/x.zip', null, ['theme' => 'x'])); + self::assertSame('deja', $updater->filter_pre_download('deja', 'https://downloads.wordpress.org/theme/x.zip', null, [])); + self::assertFalse($updater->filter_pre_download(false, 'https://updates.wearewp.pro/v1/download/abc.def', null, ['plugin' => 'wawp-demo/wawp-demo.php']), 'paquet de plugin laissé à wawp-updater'); + self::assertFalse($updater->filter_pre_download(false, '/tmp/televerse.zip', null, ['type' => 'theme', 'action' => 'install']), 'téléversement manuel'); + self::assertSame([], WawpTestState::$requests); + } + + public function testPreDownloadRefusesUnknownOrHijackedMarkers(): void + { + $updater = $this->updater(); + self::assertSame('wawp_theme_unknown_package', $updater->filter_pre_download(false, self::MARKER, null, ['theme' => 'autre-theme'])->get_error_code()); + self::assertSame('wawp_theme_unknown_package', $updater->filter_pre_download(false, 'https://updates.wearewp.pro/v1/theme-update/autre-theme', null, ['theme' => self::SLUG])->get_error_code()); + self::assertSame('wawp_theme_unknown_package', $updater->filter_pre_download(false, self::MARKER, null, null)->get_error_code()); + self::assertSame('wawp_theme_package_hijacked', $updater->filter_pre_download('/tmp/autre.zip', self::MARKER, null, ['theme' => self::SLUG])->get_error_code()); + self::assertSame([], WawpTestState::$requests); + } + + public function testFrozenPluginClientLeavesTheThemeMarkerAlone(): void + { + $file = WP_PLUGIN_DIR . '/wawp-demo/wawp-demo.php'; + WawpTestState::$versions[$file] = '1.0.0'; + (new \WAWP\Updater\Updater([$file], new \WAWP\Updater\Client(), []))->hooks(); + $this->updater()->hooks(); + + // Ordre réel sur un site : wawp-updater s'inscrit d'abord (plugins_loaded), à la même priorité 1. + self::assertCount(2, WawpTestState::$hooks['upgrader_pre_download'], 'wawp-updater et wawp-theme-updater sont tous deux inscrits'); + $reply = false; + foreach (WawpTestState::$hooks['upgrader_pre_download'] as $callback) { + $reply = $callback($reply, self::MARKER, null, ['theme' => self::SLUG, 'type' => 'theme', 'action' => 'update']); + } + self::assertSame($this->zip . '.dl', $reply, 'wawp-updater laisse passer le marqueur, wawp-theme-updater fournit le paquet vérifié'); + } + + public function testPreDownloadRefusesBadSignatureAndDeletesTheFile(): void + { + $result = $this->updater([Keys::pair()['public']])->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_theme_signature_invalid', $result->get_error_code()); + self::assertSame('Signature du thème WeAre[WP] invalide : mise à jour annulée.', $result->get_error_message()); + self::assertFileDoesNotExist($this->zip . '.dl'); + + $empty = $this->updater([])->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + self::assertSame('wawp_theme_signature_invalid', $empty->get_error_code(), 'aucune clé configurée : refus'); + } + + /** @return array, string}> */ + public static function mismatches(): array + { + return [ + 'autre version signée (retour arrière)' => ['1.4.0', ThemePackageTest::entries(self::SLUG, '1.2.0'), '1.3.0'], + 'autre slug signé (substitution)' => ['1.4.0', ThemePackageTest::entries('theme-autre', '1.4.0'), '1.3.0'], + 'version égale à l\'installée' => ['1.4.0', ThemePackageTest::entries(self::SLUG, '1.4.0'), '1.4.0'], + 'version inférieure à l\'installée' => ['1.4.0', ThemePackageTest::entries(self::SLUG, '1.4.0'), '1.5.0'], + 'entrée hors du dossier du thème' => ['1.4.0', ThemePackageTest::entries(self::SLUG, '1.4.0') + ['autre/x.php' => ' $entries */ + #[DataProvider('mismatches')] + public function testPreDownloadRefusesSignedPackageThatDoesNotMatchTheOffer(string $offer, array $entries, string $installed): void + { + WawpTestState::$versions[self::STYLE] = $installed; + $this->serve($offer, $entries); + $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('wawp_theme_package_mismatch', $result->get_error_code()); + self::assertFileDoesNotExist($this->zip . '.dl'); + } + + public function testPreDownloadReportsFailuresWithoutLeakingTheToken(): void + { + WawpTestState::$download = static fn () => new WP_Error('http_404', 'https://updates.wearewp.pro/v1/download/secret'); + $result = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + self::assertSame('wawp_theme_download_failed', $result->get_error_code()); + self::assertStringNotContainsString('download/', $result->get_error_message()); + + WawpTestState::$http = static fn () => Http::response(401, '{"status":"unauthorized"}'); + $refused = $this->updater()->filter_pre_download(false, self::MARKER, null, ['theme' => self::SLUG]); + self::assertSame('wawp_theme_token_unavailable', $refused->get_error_code()); + } + + public function testCacheIsFlushedAfterUpgradesAndOnForceCheck(): void + { + $updater = $this->updater(); + $updater->filter_update(false, [], self::SLUG, []); + $updater->flush_cache(); + self::assertArrayNotHasKey('wawp_theme_updater_check', WawpTestState::$transients); + + $updater->filter_update(false, [], self::SLUG, []); + $updater->maybe_flush_on_force_check(); + self::assertArrayHasKey('wawp_theme_updater_check', WawpTestState::$transients, 'sans force-check, cache conservé'); + $_GET['force-check'] = '1'; + $updater->maybe_flush_on_force_check(); + unset($_GET['force-check']); + self::assertArrayNotHasKey('wawp_theme_updater_check', WawpTestState::$transients); + } + + public function testNoNoticeWhenEverythingIsFine(): void + { + $updater = $this->updater(); + $updater->filter_update(false, [], self::SLUG, []); + self::assertSame('', $this->notice($updater, 'themes')); + } +} diff --git a/tests/Unit/ThemeValidatorTest.php b/tests/Unit/ThemeValidatorTest.php new file mode 100644 index 0000000..db96732 --- /dev/null +++ b/tests/Unit/ThemeValidatorTest.php @@ -0,0 +1,99 @@ + $changes */ + private static function body(array $changes = []): string + { + return Http::theme_body([self::SLUG => $changes + Http::theme_offer(self::SLUG, '1.4.0', self::signature())]); + } + + public function testConfigValues(): void + { + self::assertSame('1.0.0', Config::VERSION); + self::assertSame('updates.wearewp.pro', Config::HOST); + self::assertSame('https://updates.wearewp.pro', Config::ENDPOINT); + self::assertSame('https://updates.wearewp.pro/v1/theme-update/', Config::PACKAGE_MARKER); + self::assertSame('wawp_theme_updater_check', Config::CACHE_KEY); + self::assertNotSame(\WAWP\Updater\Config::CACHE_KEY, Config::CACHE_KEY); + self::assertSame(43200, Config::CACHE_TTL); + self::assertSame(3600, Config::ERROR_TTL); + self::assertSame(10, Config::TIMEOUT); + self::assertSame(300, Config::DOWNLOAD_TIMEOUT); + } + + public function testValidCheckResponse(): void + { + self::assertSame( + [self::SLUG => [ + 'version' => '1.4.0', + 'requires' => '6.1', + 'requires_php' => '7.4', + 'url' => 'https://www.wearewp.pro/', + 'package' => 'https://updates.wearewp.pro/v1/download/abc.def', + 'signature' => self::signature(), + ]], + Theme_Validator::check_response(self::body(), [self::SLUG]) + ); + } + + public function testEmptyListsEmptyUrlAndNullRequirementsAreAccepted(): void + { + self::assertSame([], Theme_Validator::check_response('{"themes":{}}', [self::SLUG])); + self::assertSame([], Theme_Validator::check_response('{"themes":[]}', [self::SLUG])); + $offers = Theme_Validator::check_response(self::body(['url' => '', 'requires' => null, 'requires_php' => null]), [self::SLUG]); + self::assertNotNull($offers); + self::assertSame('', $offers[self::SLUG]['url']); + self::assertNull($offers[self::SLUG]['requires']); + self::assertNull($offers[self::SLUG]['requires_php']); + } + + public function testAnyAnomalyRejectsTheWholeResponse(): void + { + $good = Http::theme_offer(self::SLUG, '1.4.0', self::signature()); + $cases = [ + 'pas du JSON' => 'nope', + 'réponse de plugins' => '{"plugins":{}}', + 'thème non demandé' => Http::theme_body(['theme-intrus' => ['theme' => 'theme-intrus'] + $good]), + 'champ theme différent de la clé' => self::body(['theme' => 'autre-theme']), + 'champ theme absent' => Http::theme_body([self::SLUG => array_diff_key($good, ['theme' => true])]), + 'version invalide' => self::body(['new_version' => '1.4.0-beta']), + 'version absente' => Http::theme_body([self::SLUG => array_diff_key($good, ['new_version' => true])]), + 'version numérique' => self::body(['new_version' => 1.4]), + 'hôte de paquet étranger' => self::body(['package' => 'https://evil.example/v1/download/abc']), + 'préfixe trompeur' => self::body(['package' => 'https://updates.wearewp.pro.evil.example/v1/download/abc']), + 'chemin de paquet détourné' => self::body(['package' => 'https://updates.wearewp.pro/v1/download/../x']), + 'marqueur au lieu d\'un jeton' => self::body(['package' => 'https://updates.wearewp.pro/v1/theme-update/wawp-demo-theme']), + 'signature trop courte' => self::body(['signature' => base64_encode('court')]), + 'requires invalide' => self::body(['requires' => 'latest']), + 'url javascript' => self::body(['url' => 'javascript:alert(1)']), + 'url avec espace' => self::body(['url' => 'https://www.wearewp.pro/a b']), + 'url avec chevron' => self::body(['url' => 'https://www.wearewp.pro/