lumnik. / scénarios Tous les scénarios · English

WordPress annonce 46 articles et en sert 31: le run échoue

Objectif Lire tous les articles d'un site WordPress, et refuser une lecture partielle quand un plugin en cache sans changer le compte.

L'API REST de WordPress pagine par l'en-tête Link et publie son compte dans X-WP-Total. lumnik suit l'un et vérifie l'autre:

manifests/lab-docker-compose.yml
# A company's WordPress site: its articles are read through the REST API.name: lumnik-wordpress-siteservices:  db:    image: mariadb:11.4    environment:      MARIADB_DATABASE: wordpress      MARIADB_USER: wordpress      MARIADB_PASSWORD: wordpress-lab        # the lab's own, on this machine only      MARIADB_RANDOM_ROOT_PASSWORD: "1"    tmpfs: ["/var/lib/mysql"]  wordpress:    image: wordpress:6.6-apache    environment:      WORDPRESS_DB_HOST: db      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: wordpress-lab      WORDPRESS_DB_NAME: wordpress      # Application passwords over plain http: WordPress allows them on a "local" site only.      WORDPRESS_CONFIG_EXTRA: "define('WP_ENVIRONMENT_TYPE', 'local');"    ports: ["127.0.0.1:${WORDPRESS_PORT:-9250}:80"]   # this machine only: the admin password is the lab's    volumes: ["site:/var/www/html", "./lab-mu-plugins:/var/www/html/wp-content/mu-plugins:ro"]    depends_on: [db]  cli:    image: wordpress:cli-2.11-php8.3    user: "33:33"    environment:      WORDPRESS_DB_HOST: db      WORDPRESS_DB_USER: wordpress      WORDPRESS_DB_PASSWORD: wordpress-lab      WORDPRESS_DB_NAME: wordpress    volumes: ["site:/var/www/html"]    depends_on: [db, wordpress]    profiles: [cli]volumes:  site:
manifests/lab-mu-plugins/members-only.php
<?php/* * Lab stand-in for a "members only" plugin: once the option lab_members_only is on, it hides the * articles of the "atelier" category from anonymous readers — after the query, through the_posts, * as such plugins do. WordPress has already counted them: X-WP-Total does not move. */add_filter('the_posts', function ($posts, $query) {    if (!get_option('lab_members_only') || is_user_logged_in() || !defined('REST_REQUEST')) {        return $posts;    }    return array_values(array_filter($posts, fn($p) => !has_category('atelier', $p)));}, 10, 2);
manifests/10-articles.yaml
apiVersion: connectors.lumnik.io/v1kind: Connectormetadata:  name: scenario-site-a  connector_type: rest-generic  scopes: [marketing]spec:  base_url: http://host.docker.internal:9250/wp-json  endpoints:    - id: articles      path: /wp/v2/posts?per_page=10      method: GET      pagination:        kind: link-header        response_path: $        total_header: X-WP-Total          # WordPress's own count of the articles      target:        kind: hybrid        table: ext.scenario_site_a        unique_key: $.id        promote:          - { name: slug, path: $.slug, type: text }          - { name: title, path: $.title.rendered, type: text }          - { name: published_at, path: $.date_gmt, type: timestamptz }
$ lm connector run scenario-site-a
run scheduled: 1afd408e-2f16-4511-9071-bba955852783

$ lm run get 1afd408e-2f16-4511-9071-bba955852783
ID        CONNECTOR        ENDPOINT  STATUS  IN  OUT  SKIPPED  DEAD-LETTERS  STARTED           ENDED             EVALUATED
1afd408e  scenario-site-a  articles  Failed  0   0    0        0             2026-10-30 20:48  2026-10-30 20:48  yes
✖ run failed: Endpoint 'articles': the source declared 46 rows but this run returned 31 — refusing to report a partial extract as a complete one
manifests/20-articles-member.yaml
apiVersion: connectors.lumnik.io/v1kind: Connectormetadata:  name: scenario-site-a  connector_type: rest-generic  scopes: [marketing]spec:  base_url: http://host.docker.internal:9250/wp-json  auth: { kind: basic, username: lumnik-reader, password_env: SCENARIO_SITE_APP_PASSWORD }   # a member's application password  endpoints:    - id: articles      path: /wp/v2/posts?per_page=10      method: GET      pagination:        kind: link-header        response_path: $        total_header: X-WP-Total          # WordPress's own count of the articles      target:        kind: hybrid        table: ext.scenario_site_a        unique_key: $.id        promote:          - { name: slug, path: $.slug, type: text }          - { name: title, path: $.title.rendered, type: text }          - { name: published_at, path: $.date_gmt, type: timestamptz }
hub
46 articles
49
article-45 · Article 45
48
article-44 · Article 44
47
article-43 · Article 43

preuves: replay.log, page-1-headers.txt, pages-members-only.txt, count.txt, newest.txt · transform v1

Limites

  • Le témoin vaut ce que vaut le compte: un plugin qui corrigerait aussi X-WP-Total cacherait ses articles sans que rien ne se voie.
  • Le hub lit ce que son compte voit: un article réservé à un autre rôle n'est pas lu.
  • Sur un vrai site, le mot de passe d'application passe par https.
  • Site WordPress de démonstration, articles synthétiques; le plugin « réservé aux membres » est un mu-plugin du lab.

Site web · articles

Le site WordPress annonce 46 articles.

Un plugin en cache 15 aux visiteurs.

Site WordPress de démonstration

Le problème

Le compte ne bouge pas.

  • Le plugin retire les articles après la requête.
  • WordPress annonce toujours 46.
  • Un import qui suit les pages lit 31 et se dit complet.

Ce qui n'est pas modifié

WordPress reste tel quel.

  • L'API REST standard, sans extension.
  • Un compte abonné et son mot de passe d'application.

Ce qu'il a fallu écrire

pagination: { kind: link-header,
  total_header: X-WP-Total }

Le compte de WordPress sert de témoin.

Un connecteur de 27 lignes.

Le rouge

46 annoncés, 31 lus: le run échoue.

  • Il donne les deux chiffres.
  • Aucune liste partielle annoncée complète.

Le vert

Lu en tant que membre: 46 sur 46.

  • Un mot de passe d'application, rangé par lm secret.
  • Les articles réservés sont lus aussi.

La limite

Le témoin vaut ce que vaut le compte.

  • Si le plugin corrigeait aussi le compte, rien ne se verrait.
  • Le hub lit ce que son compte a le droit de voir.

Essayer

Le scénario se rejoue en local.

Un vrai WordPress, chaque étape publiée avec sa sortie.

Ce scénario tourne sur lumnik Open, gratuit et open source.

Essayer lumnik Open →