diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..80de2de
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,147 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ static:
+ name: Lint & coding standards
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '7.4'
+ coverage: none
+ tools: composer
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Syntax check every file
+ run: composer run lint
+
+ - name: WordPress coding standards
+ run: composer run phpcs
+
+ test:
+ name: PHP ${{ matrix.php }} / WP ${{ matrix.wp }}
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ # The plugin header claims PHP 7.4+; both ends of that range are
+ # tested against current WordPress, plus two older WordPress versions.
+ #
+ # WordPress 5.9 rather than the declared 5.8 floor: WordPress's own
+ # test library only gained PHPUnit 9 support in 5.9, and 5.8 hard-fails
+ # with "compatible with PHPUnit up to 7.x". 5.8 support is therefore
+ # verified by the PHPCompatibility/manual checks, not by this suite.
+ php: ['7.4', '8.2', '8.4']
+ wp: ['latest']
+ include:
+ - php: '7.4'
+ wp: '5.9.*'
+ - php: '8.3'
+ wp: '6.5.*'
+
+ services:
+ mysql:
+ image: mariadb:10.11
+ env:
+ MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
+ MARIADB_DATABASE: wordpress_test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="healthcheck.sh --connect --innodb_initialized"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=6
+
+ env:
+ WP_TESTS_DB_HOST: 127.0.0.1:3306
+ WP_TESTS_DB_NAME: wordpress_test
+ WP_TESTS_DB_USER: root
+ WP_TESTS_DB_PASSWORD: ''
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: mysqli
+ coverage: none
+ tools: composer
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ # WordPress core and the test library are separate packages that share
+ # WordPress's version numbers — they must be pinned together or the
+ # library boots against a core it does not match.
+ - name: Pin WordPress to ${{ matrix.wp }}
+ if: matrix.wp != 'latest'
+ run: >-
+ composer require --dev --update-with-dependencies --no-progress
+ "roots/wordpress-no-content:${{ matrix.wp }}"
+ "wp-phpunit/wp-phpunit:${{ matrix.wp }}"
+
+ - name: Run the test suite
+ run: composer run test:core
+
+ - name: Run the test suite with the WooCommerce store rules active
+ run: composer run test:woocommerce
+
+ test-woocommerce:
+ name: WooCommerce integration
+ runs-on: ubuntu-latest
+
+ services:
+ mysql:
+ image: mariadb:10.11
+ env:
+ MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1
+ MARIADB_DATABASE: wordpress_test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="healthcheck.sh --connect --innodb_initialized"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=6
+
+ env:
+ WP_TESTS_DB_HOST: 127.0.0.1:3306
+ WP_TESTS_DB_NAME: wordpress_test
+ WP_TESTS_DB_USER: root
+ WP_TESTS_DB_PASSWORD: ''
+ CITECUE_WITH_WOOCOMMERCE: '1'
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.2'
+ extensions: mysqli
+ coverage: none
+ tools: composer
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Install WooCommerce
+ run: composer require --dev --no-progress wpackagist-plugin/woocommerce
+
+ - name: Run the product-push tests against a real WooCommerce
+ run: composer run test:core
diff --git a/.gitignore b/.gitignore
index 17b1de0..4aaf3f4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,7 @@
.DS_Store
node_modules/
vendor/
+composer.lock
+.phpunit.result.cache
+phpunit.xml
+.phpcs.xml
diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist
new file mode 100644
index 0000000..bd754e9
--- /dev/null
+++ b/.phpcs.xml.dist
@@ -0,0 +1,95 @@
+
+
+ WordPress coding standards for the CiteCue plugin.
+
+ .
+ /vendor/*
+ /node_modules/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /includes/class-citecue-proxy\.php
+ /includes/class-citecue-llms-txt\.php
+
+
+
+
+ /includes/class-citecue-llms-txt\.php
+
+
+
+
+ /includes/index\.php
+
+
+
+
+ /tests/*
+
+
+ /tests/*
+
+
+ /tests/*
+
+
+ /tests/*
+
+
+ /tests/*
+
+
+ /tests/*
+
+
+
+ /tests/*
+
+
+
+ /tests/bootstrap\.php
+
+
diff --git a/README.md b/README.md
index 8e253bc..1e03fa0 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ AI crawler (GPTBot, ClaudeBot, …) Human visitor
- **One request serves and reports.** The v2 delivery endpoint records the crawler hit server-side (`served` for 200/304, `passthrough` for a miss), so CiteCue's Agent Traffic dashboard stays accurate with no extra beacon.
- **Conditional revalidation.** Optimized bodies are cached locally with their ETag; revalidation is a cheap 304 round-trip. Misses are negative-cached for 60 s (mirroring the API's `max-age=60` miss sentinel).
- **Circuit breaker.** A timeout or 5xx opens a 60 s circuit (10 min on a rejected key): no API calls, stale cache served when available, plain pass-through otherwise. A CiteCue outage never slows human traffic — the API is only ever called for AI-crawler requests in the first place.
-- **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget (default 120, filterable) — a spoofed crawler UA spraying unique URLs cannot force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately.
+- **Abuse-bounded.** Cache keys use CiteCue-compatible URL normalization (tracking params, `www.`, trailing slashes deduped), and outbound lookups are capped by a per-minute budget shared across the crawler and llms.txt paths (default 120, filterable) — neither a spoofed crawler UA spraying unique URLs nor a flood on `/llms.txt` can force unbounded API calls. When CiteCue reports a page is no longer optimized, its cached copy is evicted immediately.
- **Crawler registry.** A bundled token list ships with the plugin and refreshes daily from the public `GET /api/delivery/v1/crawlers` feed, so newly added crawlers are served without a plugin update.
- **Verification-compatible.** Served pages carry `X-Citecue: served` and llms.txt carries `X-Citecue: llms-txt` — the headers CiteCue's *Verify installation* button probes for.
@@ -123,11 +123,26 @@ With WooCommerce active:
| `citecue_matched_crawler` | filter | Override per-request crawler matching |
| `citecue_should_serve` | filter | Veto serving for a specific request |
| `citecue_serve_timeout` | filter | Delivery API timeout on the serving path (default 3 s) |
-| `citecue_lookup_budget` | filter | Max delivery API lookups per minute (default 120); beyond it, crawler requests pass through |
+| `citecue_lookup_budget` | filter | Max delivery API lookups per minute across the crawler and llms.txt paths combined (default 120); beyond it, requests are answered from cache or passed through |
| `citecue_ingest_postarr` | filter | Adjust the post array before insert/update |
| `citecue_ingest_rate_limit` | filter | Ingest requests allowed per hour (default 120) |
| `citecue_output_meta_description` | filter | Control the meta-description tag for pushed content |
+## Performance
+
+The delivery API is only ever called for an AI-crawler request or for `/llms.txt`. A human page view does no HTTP, no extra database query and no cache lookup — the middleware returns as soon as the User-Agent fails to match, having done nothing but a substring scan over the crawler tokens (which travel in an autoloaded option WordPress has already read).
+
+For a crawler request the cost is one API call, with a 3 s timeout, and only when the local cache cannot answer: optimized bodies are cached for 24 h and revalidated with an ETag, misses are negative-cached for 60 s, and llms.txt is treated as fresh for 5 minutes. A repeat crawl of a cached page is a 304, not a re-download.
+
+- **A persistent object cache is recommended.** Cached bodies are transients. With Redis or Memcached they never touch the database. Without one they are rows in `wp_options` — full HTML documents, one per crawled URL, for up to 24 h. They are not autoloaded, so they cost nothing per request, but a heavily crawled site can hold tens of megabytes there until WordPress's twice-daily transient cleanup runs.
+- **The outbound-call ceiling is per site, not per path.** The 120/minute budget covers crawler lookups and llms.txt together, so no mix of traffic can exceed it.
+
+## What happens if CiteCue is unavailable
+
+Nothing that a visitor can see. The first failing request waits at most 3 s and then opens the circuit for 60 s (10 minutes if the API key was rejected). While it is open the plugin makes no API calls at all: it serves the cached body if it has one — marked `X-Citecue-Cache: stale` — and otherwise falls straight through to normal theme output. Because bodies are kept for 24 h, crawlers keep receiving optimized pages through a short outage.
+
+The worst case for an AI crawler is one 3 s wait per minute. For a human visitor there is no worst case: the API is never consulted on their behalf.
+
## Notes & caveats
- **Full-page caches / CDNs:** a page cache that serves HTML before WordPress loads will answer AI crawlers with the cached human version. Exclude the AI-crawler user agents from your page cache, or rely on CiteCue's Cloudflare Worker install instead of this plugin when your cache sits in front of PHP. Responses served by this plugin set `DONOTCACHEPAGE` and `Cache-Control: private, no-store` so they are never stored for humans.
@@ -137,4 +152,46 @@ With WooCommerce active:
## Development
-Plain PHP ≥ 7.4, no build step. Repo root is the plugin root. `php -l` every file; WordPress coding standards style.
+Plain PHP ≥ 7.4, no build step. Repo root is the plugin root, so the checkout can be symlinked straight into `wp-content/plugins/`.
+
+### Tests
+
+The suite is WordPress integration tests: real options, transients, REST requests and query conditionals, with the CiteCue API faked at the `wp_remote_get` layer (`tests/includes/class-citecue-http-mock.php`) so no test ever touches the network. WordPress core and its test library both come from Composer — there is nothing to download by hand.
+
+```bash
+composer install
+mysqladmin create wordpress_test -uroot # any empty database will do
+composer test
+```
+
+Point it at a different database with `WP_TESTS_DB_NAME`, `WP_TESTS_DB_USER`, `WP_TESTS_DB_PASSWORD`, `WP_TESTS_DB_HOST` (see `tests/wp-tests-config.php`).
+
+`composer test` runs the suite twice, because whether WooCommerce exists is a process-wide fact rather than something a single test can toggle:
+
+| Command | Covers |
+|---|---|
+| `composer test:core` | Everything, with no WooCommerce present |
+| `composer test:woocommerce` | Adds a minimal WooCommerce stand-in so the store-page exclusion rules are exercised |
+| `CITECUE_WITH_WOOCOMMERCE=1 composer test:core` | Product pushes against a real WooCommerce — needs `composer require --dev wpackagist-plugin/woocommerce` |
+
+Other commands:
+
+```bash
+composer lint # php -l over every file
+composer phpcs # WordPress coding standards + PHP 7.4 compatibility
+composer phpcbf # auto-fix what phpcs can
+```
+
+CI runs the static checks plus the suite on PHP 7.4/8.2/8.4 against current WordPress, on PHP 7.4/8.3 against WordPress 5.9/6.5, and a separate job against a real WooCommerce. WordPress 5.9 is the oldest version the automated suite can cover — WordPress's own test library only supports PHPUnit 9 from 5.9 onwards — so the declared 5.8 floor rests on the PHPCompatibility checks and manual verification.
+
+### Testing an install by hand
+
+```bash
+curl -si -A GPTBot https://your-site.com/llms.txt # expect: x-citecue: llms-txt
+curl -si -A GPTBot https://your-site.com/optimized-page/ # expect: x-citecue: served
+curl -s https://your-site.com/wp-json/citecue/v1/health # plugin/version/delivery/ingest/woocommerce
+```
+
+### Structure notes
+
+`Citecue_Proxy` and `Citecue_Llms_Txt` each split into a `decide()` that returns what should happen and a `serve()` that emits headers and calls `exit`. All the branching lives in `decide()`, which is what the tests drive; `serve()` stays deliberately trivial because nothing can assert against a request that has already ended.
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..80a7f5e
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,52 @@
+{
+ "name": "citecue/wordpress-plugin",
+ "description": "CiteCue AI Auto-Fix — WordPress plugin",
+ "type": "wordpress-plugin",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6",
+ "yoast/phpunit-polyfills": "^2.0",
+ "squizlabs/php_codesniffer": "^3.9",
+ "wp-coding-standards/wpcs": "^3.1",
+ "phpcompatibility/phpcompatibility-wp": "^2.1",
+ "wp-phpunit/wp-phpunit": "^7.0",
+ "roots/wordpress-no-content": "^6.8",
+ "composer/installers": "^2.2"
+ },
+ "repositories": [
+ {
+ "type": "composer",
+ "url": "https://wpackagist.org",
+ "only": [
+ "wpackagist-plugin/*"
+ ]
+ }
+ ],
+ "extra": {
+ "installer-paths": {
+ "vendor/wpackagist-plugin/{$name}/": [
+ "type:wordpress-plugin"
+ ]
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "composer/installers": true
+ }
+ },
+ "scripts": {
+ "lint": "find citecue.php uninstall.php includes tests -name '*.php' -print0 | xargs -0 -n1 -P4 php -l > /dev/null",
+ "phpcs": "phpcs",
+ "phpcbf": "phpcbf",
+ "test": [
+ "@test:core",
+ "@test:woocommerce"
+ ],
+ "test:core": "phpunit",
+ "test:woocommerce": "CITECUE_STUB_WOOCOMMERCE=1 phpunit"
+ }
+}
diff --git a/includes/class-citecue-cache.php b/includes/class-citecue-cache.php
index 890b278..d57511d 100644
--- a/includes/class-citecue-cache.php
+++ b/includes/class-citecue-cache.php
@@ -175,6 +175,31 @@ public function set_miss( $url ) {
set_transient( 'citecue_ms_' . md5( $this->salt() . '|' . self::normalize_url( $url ) ), 1, self::MISS_TTL );
}
+ /**
+ * Consumes one unit of the per-minute outbound-lookup budget, shared by
+ * every path that calls the delivery API. Bounds the requests an anonymous
+ * visitor can trigger — by spoofing a crawler User-Agent across unique
+ * URLs, or by hammering llms.txt — to a known ceiling per site. Beyond it
+ * the plugin degrades exactly as it does with an open circuit.
+ *
+ * @return bool Whether an API lookup may be made.
+ */
+ public function consume_lookup_budget() {
+ /**
+ * Filters the maximum delivery API lookups per minute.
+ *
+ * @param int $limit Default 120.
+ */
+ $limit = max( 1, (int) apply_filters( 'citecue_lookup_budget', 120 ) );
+ $key = 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS );
+ $count = (int) get_transient( $key );
+ if ( $count >= $limit ) {
+ return false;
+ }
+ set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS );
+ return true;
+ }
+
/**
* Cached llms.txt, or null.
*
@@ -194,6 +219,27 @@ public function delete_llms_txt() {
delete_transient( 'citecue_llms_' . $this->salt() );
}
+ /**
+ * Whether CiteCue recently reported that this project publishes no
+ * llms.txt. Unlike the page path, /llms.txt is served to every visitor, so
+ * without this a project with llms.txt switched off would turn each hit on
+ * the URL into an outbound API call.
+ *
+ * @return bool
+ */
+ public function is_recent_llms_txt_miss() {
+ return (bool) get_transient( 'citecue_llms_ms_' . $this->salt() );
+ }
+
+ /**
+ * Records that CiteCue has no llms.txt for this project.
+ *
+ * @return void
+ */
+ public function set_llms_txt_miss() {
+ set_transient( 'citecue_llms_ms_' . $this->salt(), 1, self::MISS_TTL );
+ }
+
/**
* Stores the llms.txt body.
*
diff --git a/includes/class-citecue-llms-txt.php b/includes/class-citecue-llms-txt.php
index 7fad657..4b68d09 100644
--- a/includes/class-citecue-llms-txt.php
+++ b/includes/class-citecue-llms-txt.php
@@ -53,13 +53,27 @@ public function register() {
* @return void
*/
public function maybe_serve() {
+ $decision = $this->decide();
+
+ if ( $decision['serve'] ) {
+ $this->serve( $decision['body'] );
+ }
+ }
+
+ /**
+ * Decides what this request should get, without emitting anything — the
+ * testable counterpart of {@see self::serve()}, which ends the request.
+ *
+ * @return array{serve:bool,body:string,reason:string}
+ */
+ public function decide() {
if ( ! $this->is_llms_txt_request() ) {
- return;
+ return self::pass( 'not-llms-txt' );
}
$settings = $this->plugin->settings;
if ( ! $settings->get( 'llms_txt_enabled' ) || ! $settings->is_delivery_configured() ) {
- return;
+ return self::pass( 'not-configured' );
}
$cache = $this->plugin->cache;
@@ -67,51 +81,92 @@ public function maybe_serve() {
// Fresh cached copy or open circuit: serve locally, no API call.
if ( $cached && ( $cache->is_fresh( $cached, self::FRESH_SECONDS ) || $cache->is_circuit_open() ) ) {
- $this->serve( $cached['body'] );
+ return self::body( $cached['body'], 'cached' );
}
if ( $cache->is_circuit_open() ) {
- return;
+ return self::pass( 'circuit-open' );
+ }
+
+ // CiteCue recently said this project has no llms.txt: skip the API for
+ // a minute. This endpoint answers every visitor, not just crawlers, so
+ // without the negative cache a project with llms.txt switched off would
+ // make one outbound call per hit on the URL.
+ if ( $cache->is_recent_llms_txt_miss() ) {
+ return self::pass( 'recent-miss' );
+ }
+
+ // Shared with the crawler path, so the site's total outbound calls stay
+ // bounded however the traffic is distributed between the two.
+ if ( ! $cache->consume_lookup_budget() ) {
+ return $cached ? self::body( $cached['body'], 'budget-exhausted' ) : self::pass( 'budget-exhausted' );
}
$response = $this->plugin->api->get_llms_txt( $cached ? $cached['etag'] : '' );
if ( is_wp_error( $response ) ) {
$cache->trip_circuit();
- if ( $cached ) {
- $this->serve( $cached['body'] );
- }
- return;
+ return $cached ? self::body( $cached['body'], 'transport-error' ) : self::pass( 'transport-error' );
}
switch ( $response['status'] ) {
case 200:
$cache->set_llms_txt( $response['body'], $response['etag'] );
- $this->serve( $response['body'] );
- return;
+ return self::body( $response['body'], 'fresh' );
case 304:
- if ( $cached ) {
- $cache->set_llms_txt( $cached['body'], $cached['etag'] );
- $this->serve( $cached['body'] );
+ if ( ! $cached ) {
+ return self::pass( 'revalidated-without-cache' );
}
- return;
+ $cache->set_llms_txt( $cached['body'], $cached['etag'] );
+ return self::body( $cached['body'], 'revalidated' );
case 401:
update_option( 'citecue_auth_failed', time(), false );
$cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL );
- return;
+ return self::pass( 'unauthorized' );
case 404:
// llms.txt serving disabled on CiteCue: evict the cached copy
- // (so it cannot resurface stale) and fall through to WordPress.
+ // (so it cannot resurface stale), remember the miss so the next
+ // visitors are answered locally, and fall through to WordPress.
$cache->delete_llms_txt();
- return;
+ $cache->set_llms_txt_miss();
+ return self::pass( 'disabled-upstream' );
default:
- return;
+ return self::pass( 'server-error' );
}
}
+ /**
+ * A "leave this request to WordPress" decision.
+ *
+ * @param string $reason Why nothing is served (diagnostic only).
+ * @return array{serve:bool,body:string,reason:string}
+ */
+ private static function pass( $reason ) {
+ return array(
+ 'serve' => false,
+ 'body' => '',
+ 'reason' => $reason,
+ );
+ }
+
+ /**
+ * A "serve this llms.txt body" decision.
+ *
+ * @param string $body llms.txt content.
+ * @param string $reason Where the body came from (diagnostic only).
+ * @return array{serve:bool,body:string,reason:string}
+ */
+ private static function body( $body, $reason ) {
+ return array(
+ 'serve' => true,
+ 'body' => $body,
+ 'reason' => $reason,
+ );
+ }
+
/**
* Whether the current request targets llms.txt at the site root
* (or the WordPress subdirectory root for subdirectory installs).
diff --git a/includes/class-citecue-proxy.php b/includes/class-citecue-proxy.php
index deaae75..cec994b 100644
--- a/includes/class-citecue-proxy.php
+++ b/includes/class-citecue-proxy.php
@@ -52,24 +52,39 @@ public function register() {
* @return void
*/
public function maybe_serve() {
+ $decision = $this->decide();
+
+ if ( $decision['serve'] ) {
+ $this->serve( $decision['body'], $decision['mode'], $decision['stale'] );
+ }
+ }
+
+ /**
+ * Decides what this request should get, without emitting anything. All of
+ * the serving logic lives here so it can be exercised (and tested) without
+ * the headers-and-exit of {@see self::serve()}.
+ *
+ * @return array{serve:bool,body:string,mode:string,stale:bool,reason:string}
+ */
+ public function decide() {
if ( ! $this->is_eligible_request() ) {
- return;
+ return self::pass( 'not-eligible' );
}
$settings = $this->plugin->settings;
if ( ! $settings->get( 'serve_enabled' ) || ! $settings->is_delivery_configured() ) {
- return;
+ return self::pass( 'not-configured' );
}
$user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
$crawler = $this->plugin->crawlers->match( $user_agent );
if ( null === $crawler ) {
- return;
+ return self::pass( 'not-a-crawler' );
}
$url = $this->current_url();
if ( '' === $url ) {
- return;
+ return self::pass( 'no-url' );
}
/**
@@ -80,7 +95,7 @@ public function maybe_serve() {
* @param string $url Absolute request URL.
*/
if ( ! apply_filters( 'citecue_should_serve', true, $crawler, $url ) ) {
- return;
+ return self::pass( 'vetoed' );
}
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
@@ -89,7 +104,7 @@ public function maybe_serve() {
// Recent miss for this URL: skip the API for a minute (mirrors the
// API's own max-age=60 on the miss sentinel).
if ( $cache->is_recent_miss( $url ) ) {
- return;
+ return self::pass( 'recent-miss' );
}
$cached = $cache->get_page( $url );
@@ -97,20 +112,18 @@ public function maybe_serve() {
// Circuit open (recent timeout/auth failure): no API calls. Serve the
// stale cached copy when we have one, otherwise pass through.
if ( $cache->is_circuit_open() ) {
- if ( $cached ) {
- $this->serve( $cached['body'], $cached['mode'], true );
- }
- return;
+ return $cached
+ ? self::serve_stale( $cached, 'circuit-open' )
+ : self::pass( 'circuit-open' );
}
// Global lookup budget: a spoofed crawler UA spraying unique URLs
// cannot force unbounded outbound API calls. Exhausted budget degrades
// exactly like an open circuit.
- if ( ! $this->consume_lookup_budget() ) {
- if ( $cached ) {
- $this->serve( $cached['body'], $cached['mode'], true );
- }
- return;
+ if ( ! $cache->consume_lookup_budget() ) {
+ return $cached
+ ? self::serve_stale( $cached, 'budget-exhausted' )
+ : self::pass( 'budget-exhausted' );
}
$response = $this->plugin->api->get_page( $url, $crawler, $cached ? $cached['etag'] : '' );
@@ -119,34 +132,44 @@ public function maybe_serve() {
// Timeout / connection failure: open the circuit and degrade.
$cache->trip_circuit();
$this->plugin->activity->record( $crawler, $path, $cached ? 'served-stale' : 'error' );
- if ( $cached ) {
- $this->serve( $cached['body'], $cached['mode'], true );
- }
- return;
+ return $cached
+ ? self::serve_stale( $cached, 'transport-error' )
+ : self::pass( 'transport-error' );
}
switch ( $response['status'] ) {
case 200:
$cache->set_page( $url, $response['body'], $response['etag'], $response['mode'] );
$this->plugin->activity->record( $crawler, $path, 'served' );
- $this->serve( $response['body'], $response['mode'], false );
- return; // Unreachable (serve exits); defensive.
+ return array(
+ 'serve' => true,
+ 'body' => $response['body'],
+ 'mode' => $response['mode'],
+ 'stale' => false,
+ 'reason' => 'fresh',
+ );
case 304:
// Our cached body is current; CiteCue already counted this as served.
- if ( $cached ) {
- $cache->touch_page( $url );
- $this->plugin->activity->record( $crawler, $path, 'served' );
- $this->serve( $cached['body'], $cached['mode'], false );
+ if ( ! $cached ) {
+ return self::pass( 'revalidated-without-cache' );
}
- return;
+ $cache->touch_page( $url );
+ $this->plugin->activity->record( $crawler, $path, 'served' );
+ return array(
+ 'serve' => true,
+ 'body' => $cached['body'],
+ 'mode' => $cached['mode'],
+ 'stale' => false,
+ 'reason' => 'revalidated',
+ );
case 401:
// Bad/revoked API key: remember it for the admin notice and
// back off for a while — retrying immediately cannot help.
update_option( 'citecue_auth_failed', time(), false );
$cache->trip_circuit( Citecue_Cache::AUTH_CIRCUIT_TTL );
- return;
+ return self::pass( 'unauthorized' );
case 404:
// Miss sentinel: CiteCue recorded the passthrough hit
@@ -157,19 +180,51 @@ public function maybe_serve() {
$cache->delete_page( $url );
$cache->set_miss( $url );
$this->plugin->activity->record( $crawler, $path, 'passthrough' );
- return;
+ return self::pass( 'miss' );
default:
// Unexpected server state: brief back-off, degrade gracefully.
$cache->trip_circuit();
$this->plugin->activity->record( $crawler, $path, $cached ? 'served-stale' : 'error' );
- if ( $cached ) {
- $this->serve( $cached['body'], $cached['mode'], true );
- }
- return;
+ return $cached
+ ? self::serve_stale( $cached, 'server-error' )
+ : self::pass( 'server-error' );
}
}
+ /**
+ * A "leave this request to WordPress" decision.
+ *
+ * @param string $reason Why nothing is served (diagnostic only).
+ * @return array{serve:bool,body:string,mode:string,stale:bool,reason:string}
+ */
+ private static function pass( $reason ) {
+ return array(
+ 'serve' => false,
+ 'body' => '',
+ 'mode' => '',
+ 'stale' => false,
+ 'reason' => $reason,
+ );
+ }
+
+ /**
+ * A "serve the cached body past its revalidation window" decision.
+ *
+ * @param array $cached Cache entry.
+ * @param string $reason Why the cached copy is being used (diagnostic only).
+ * @return array{serve:bool,body:string,mode:string,stale:bool,reason:string}
+ */
+ private static function serve_stale( array $cached, $reason ) {
+ return array(
+ 'serve' => true,
+ 'body' => $cached['body'],
+ 'mode' => $cached['mode'],
+ 'stale' => true,
+ 'reason' => $reason,
+ );
+ }
+
/**
* Whether this request is one the proxy may intercept: a plain frontend
* GET from an anonymous visitor. Everything else belongs to WordPress.
@@ -216,30 +271,6 @@ private function is_eligible_request() {
return true;
}
- /**
- * Consumes one unit of the per-minute outbound-lookup budget. Bounds the
- * delivery API calls an anonymous visitor can trigger by spoofing a
- * crawler User-Agent across unique URLs; real crawl bursts beyond the
- * budget just fall through to the normal page for the rest of the minute.
- *
- * @return bool Whether an API lookup may be made.
- */
- private function consume_lookup_budget() {
- /**
- * Filters the maximum delivery API lookups per minute.
- *
- * @param int $limit Default 120.
- */
- $limit = max( 1, (int) apply_filters( 'citecue_lookup_budget', 120 ) );
- $key = 'citecue_budget_' . (int) floor( time() / MINUTE_IN_SECONDS );
- $count = (int) get_transient( $key );
- if ( $count >= $limit ) {
- return false;
- }
- set_transient( $key, $count + 1, 2 * MINUTE_IN_SECONDS );
- return true;
- }
-
/**
* WooCommerce requests the proxy must never touch: cart, checkout (incl.
* order-pay / order-received), account pages and every other WC endpoint
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..8b30acf
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,21 @@
+
+
+
+
+ ./tests/cases
+
+
+
+
+ ./includes
+
+
+
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..a76afc6
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,76 @@
+crawlers = new Citecue_Crawlers();
+ }
+
+ /**
+ * @dataProvider provide_crawler_agents
+ *
+ * @param string $user_agent User agent header.
+ * @param string $expected Expected matched token.
+ * @return void
+ */
+ public function test_matches_known_crawlers( $user_agent, $expected ) {
+ $this->assertSame( $expected, $this->crawlers->match( $user_agent ) );
+ }
+
+ /**
+ * Real-world user agents for the crawlers the plugin serves.
+ *
+ * @return array
+ */
+ public function provide_crawler_agents() {
+ return array(
+ 'GPTBot' => array( 'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.2; +https://openai.com/gptbot', 'GPTBot' ),
+ 'ChatGPT-User' => array( 'Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)', 'ChatGPT-User' ),
+ 'OAI-SearchBot' => array( 'Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)', 'OAI-SearchBot' ),
+ 'ClaudeBot' => array( 'Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)', 'ClaudeBot' ),
+ 'Claude-User' => array( 'Mozilla/5.0 (compatible; Claude-User/1.0; +Claude-User@anthropic.com)', 'Claude-User' ),
+ 'PerplexityBot' => array( 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)', 'PerplexityBot' ),
+ 'CCBot' => array( 'CCBot/2.0 (https://commoncrawl.org/faq/)', 'CCBot' ),
+ 'Amazonbot' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Amazonbot/0.1', 'Amazonbot' ),
+ 'case insensitive' => array( 'mozilla/5.0 (compatible; gptbot/1.2)', 'GPTBot' ),
+ );
+ }
+
+ /**
+ * @dataProvider provide_non_crawler_agents
+ *
+ * @param string $user_agent User agent header.
+ * @return void
+ */
+ public function test_ignores_everything_else( $user_agent ) {
+ $this->assertNull( $this->crawlers->match( $user_agent ) );
+ }
+
+ /**
+ * User agents that must never be served optimized content.
+ *
+ * @return array
+ */
+ public function provide_non_crawler_agents() {
+ return array(
+ 'Chrome' => array( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36' ),
+ 'Safari on iOS' => array( 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1' ),
+ 'Googlebot' => array( 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' ),
+ 'Bingbot' => array( 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)' ),
+ 'empty' => array( '' ),
+ );
+ }
+
+ /**
+ * Google-Extended and Applebot-Extended are robots.txt directives, not
+ * fetchers — matching them would serve optimized content to nothing.
+ *
+ * @return void
+ */
+ public function test_robots_txt_only_tokens_are_not_served() {
+ $this->assertNotContains( 'Google-Extended', Citecue_Crawlers::bundled_tokens() );
+ $this->assertNotContains( 'Applebot-Extended', Citecue_Crawlers::bundled_tokens() );
+ }
+
+ /**
+ * When two tokens both appear in a UA the longer one must win, otherwise
+ * CiteCue is told the wrong bot fetched the page.
+ *
+ * @return void
+ */
+ public function test_longest_token_wins() {
+ add_filter(
+ 'citecue_crawler_tokens',
+ static function () {
+ return array( 'Claude', 'Claude-SearchBot' );
+ }
+ );
+
+ $this->assertSame(
+ 'Claude-SearchBot',
+ $this->crawlers->match( 'Mozilla/5.0 (compatible; Claude-SearchBot/1.0)' )
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_tokens_can_be_filtered() {
+ add_filter(
+ 'citecue_crawler_tokens',
+ static function ( $tokens ) {
+ $tokens[] = 'MyCustomAgent';
+ return $tokens;
+ }
+ );
+
+ $this->assertSame( 'MyCustomAgent', $this->crawlers->match( 'MyCustomAgent/1.0' ) );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_match_can_be_overridden_per_request() {
+ add_filter( 'citecue_matched_crawler', '__return_null' );
+
+ $this->assertNull( $this->crawlers->match( 'GPTBot/1.2' ) );
+ }
+
+ /**
+ * A newer registry replaces the bundled list.
+ *
+ * @return void
+ */
+ public function test_refresh_stores_a_newer_registry() {
+ $this->http->queue(
+ 'crawlers',
+ 200,
+ wp_json_encode(
+ array(
+ 'version' => 2,
+ 'tokens' => array( 'BrandNewBot' ),
+ )
+ )
+ );
+
+ $this->assertTrue( $this->crawlers->refresh( $this->plugin->api ) );
+ $this->assertContains( 'BrandNewBot', $this->crawlers->get_tokens() );
+ $this->assertSame( 2, $this->crawlers->registry_info()['version'] );
+ }
+
+ /**
+ * A truncated or downgraded feed must never be able to stop the plugin
+ * serving a crawler it already knows about.
+ *
+ * @return void
+ */
+ public function test_refresh_keeps_the_bundled_tokens_as_a_floor() {
+ $this->http->queue(
+ 'crawlers',
+ 200,
+ wp_json_encode(
+ array(
+ 'version' => 2,
+ 'tokens' => array( 'BrandNewBot' ),
+ )
+ )
+ );
+ $this->crawlers->refresh( $this->plugin->api );
+
+ foreach ( Citecue_Crawlers::bundled_tokens() as $token ) {
+ $this->assertContains( $token, $this->crawlers->get_tokens(), "Bundled token {$token} was dropped by a refresh." );
+ }
+ $this->assertSame( 'GPTBot', $this->crawlers->match( 'GPTBot/1.2' ) );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_refresh_rejects_an_older_registry_version() {
+ $this->http->queue(
+ 'crawlers',
+ 200,
+ wp_json_encode(
+ array(
+ 'version' => 0,
+ 'tokens' => array( 'StaleBot' ),
+ )
+ )
+ );
+
+ $this->assertFalse( $this->crawlers->refresh( $this->plugin->api ) );
+ $this->assertNotContains( 'StaleBot', $this->crawlers->get_tokens() );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_refresh_dedupes_case_insensitively() {
+ $this->http->queue(
+ 'crawlers',
+ 200,
+ wp_json_encode(
+ array(
+ 'version' => 2,
+ 'tokens' => array( 'gptbot', 'GPTBot' ),
+ )
+ )
+ );
+ $this->crawlers->refresh( $this->plugin->api );
+
+ $lowercased = array_map( 'strtolower', $this->crawlers->get_tokens() );
+ $this->assertSame( count( $lowercased ), count( array_unique( $lowercased ) ) );
+ }
+
+ /**
+ * A failing feed leaves the current list untouched.
+ *
+ * @return void
+ */
+ public function test_refresh_survives_a_transport_error() {
+ $this->http->queue_error( 'crawlers' );
+
+ $this->assertFalse( $this->crawlers->refresh( $this->plugin->api ) );
+ $this->assertSame( 'GPTBot', $this->crawlers->match( 'GPTBot/1.2' ) );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_refresh_rejects_a_junk_payload() {
+ $this->http->queue( 'crawlers', 200, 'not json' );
+
+ $this->assertFalse( $this->crawlers->refresh( $this->plugin->api ) );
+ }
+
+ /**
+ * The registry feed is public; sending the API key to it would leak the
+ * credential to an endpoint that does not need it.
+ *
+ * @return void
+ */
+ public function test_registry_feed_is_requested_without_credentials() {
+ $this->configure_delivery();
+ $this->http->queue(
+ 'crawlers',
+ 200,
+ wp_json_encode(
+ array(
+ 'version' => 2,
+ 'tokens' => array( 'BrandNewBot' ),
+ )
+ )
+ );
+ $this->crawlers->refresh( $this->plugin->api );
+
+ $request = $this->http->last( 'crawlers' );
+ $this->assertArrayNotHasKey( 'Authorization', $request['args']['headers'] );
+ }
+}
diff --git a/tests/cases/test-ingest-auth.php b/tests/cases/test-ingest-auth.php
new file mode 100644
index 0000000..6a946bf
--- /dev/null
+++ b/tests/cases/test-ingest-auth.php
@@ -0,0 +1,320 @@
+plugin->settings->update(
+ array(
+ 'ingest_enabled' => true,
+ 'ingest_secret' => self::SECRET,
+ )
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_a_correctly_signed_push_is_accepted() {
+ $response = rest_do_request( $this->signed_push() );
+
+ $this->assertSame( 201, $response->get_status() );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_ingest_is_off_by_default() {
+ $this->plugin->settings->update( array( 'ingest_enabled' => false ) );
+
+ $this->assertPushRejected( 403, 'citecue_ingest_disabled', $this->signed_push() );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_a_missing_secret_is_rejected() {
+ $this->plugin->settings->update( array( 'ingest_secret' => '' ) );
+
+ $this->assertPushRejected( 403, 'citecue_no_secret', $this->signed_push() );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_an_unsigned_push_is_rejected() {
+ $request = $this->signed_push();
+ $request->set_header( 'x-citecue-signature', '' );
+
+ $this->assertPushRejected( 401, 'citecue_bad_signature', $request );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_a_signature_without_the_algorithm_prefix_is_rejected() {
+ $request = $this->signed_push();
+ $request->set_header( 'x-citecue-signature', hash_hmac( 'sha256', 'anything', self::SECRET ) );
+
+ $this->assertPushRejected( 401, 'citecue_bad_signature', $request );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_a_signature_from_the_wrong_secret_is_rejected() {
+ $request = $this->signed_push( array(), array( 'secret' => 'cws_the_wrong_secret' ) );
+
+ $this->assertPushRejected( 401, 'citecue_bad_signature', $request );
+ }
+
+ /**
+ * The signature covers the body, so an attacker cannot swap the payload of
+ * an otherwise valid request.
+ *
+ * @return void
+ */
+ public function test_a_tampered_body_is_rejected() {
+ $request = $this->signed_push();
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'external_id' => 'evil',
+ 'title' => 'Evil',
+ 'content' => 'evil
',
+ )
+ )
+ );
+
+ $this->assertPushRejected( 401, 'citecue_bad_signature', $request );
+ }
+
+ /**
+ * The signature also covers the timestamp, so a captured request cannot be
+ * re-dated to make it fresh again.
+ *
+ * @return void
+ */
+ public function test_a_re_dated_signature_is_rejected() {
+ $request = $this->signed_push();
+ $request->set_header( 'x-citecue-timestamp', (string) ( time() + 1 ) );
+
+ $this->assertPushRejected( 401, 'citecue_bad_signature', $request );
+ }
+
+ /**
+ * @dataProvider provide_stale_timestamps
+ *
+ * @param int $offset Seconds away from now.
+ * @return void
+ */
+ public function test_timestamps_outside_the_window_are_rejected( $offset ) {
+ $request = $this->signed_push( array(), array( 'timestamp' => time() + $offset ) );
+
+ $this->assertPushRejected( 401, 'citecue_stale_timestamp', $request );
+ }
+
+ /**
+ * Timestamps that must fail the ±300s window.
+ *
+ * @return array
+ */
+ public function provide_stale_timestamps() {
+ return array(
+ 'far in the past' => array( -3600 ),
+ 'just too old' => array( -301 ),
+ 'just too far off' => array( 301 ),
+ 'far in the future' => array( 3600 ),
+ 'missing entirely' => array( -( time() ) ),
+ );
+ }
+
+ /**
+ * @dataProvider provide_fresh_timestamps
+ *
+ * @param int $offset Seconds away from now.
+ * @return void
+ */
+ public function test_timestamps_inside_the_window_are_accepted( $offset ) {
+ $request = $this->signed_push(
+ array( 'external_id' => 'window-' . $offset ),
+ array( 'timestamp' => time() + $offset )
+ );
+
+ $this->assertSame( 201, rest_do_request( $request )->get_status() );
+ }
+
+ /**
+ * Clock skew inside the tolerated window.
+ *
+ * @return array
+ */
+ public function provide_fresh_timestamps() {
+ return array(
+ 'now' => array( 0 ),
+ 'slightly behind' => array( -299 ),
+ 'slightly ahead' => array( 299 ),
+ );
+ }
+
+ /**
+ * A captured request replayed inside the freshness window must not be able
+ * to re-apply its effects — a replayed `force: true` push would silently
+ * overwrite an edit the site owner had just made.
+ *
+ * @return void
+ */
+ public function test_a_replayed_signature_is_rejected() {
+ $request = $this->signed_push();
+ $this->assertSame( 201, rest_do_request( $request )->get_status() );
+
+ $this->assertPushRejected( 401, 'citecue_replayed', $this->replay_of( $request ) );
+ }
+
+ /**
+ * Legitimate retries recompute the timestamp, so they keep working.
+ *
+ * @return void
+ */
+ public function test_a_freshly_signed_retry_is_accepted() {
+ $this->assertSame( 201, rest_do_request( $this->signed_push() )->get_status() );
+
+ // Same payload, signed again a second later.
+ $retry = $this->signed_push( array(), array( 'timestamp' => time() + 1 ) );
+ $this->assertSame( 200, rest_do_request( $retry )->get_status() );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_pushes_are_rate_limited() {
+ add_filter( 'citecue_ingest_rate_limit', static fn() => 2 );
+
+ $this->assertSame( 201, rest_do_request( $this->signed_push( array( 'external_id' => 'one' ) ) )->get_status() );
+ $this->assertSame( 201, rest_do_request( $this->signed_push( array( 'external_id' => 'two' ) ) )->get_status() );
+
+ $this->assertPushRejected( 429, 'citecue_rate_limited', $this->signed_push( array( 'external_id' => 'three' ) ) );
+ }
+
+ /**
+ * The rate limit is checked after authentication on purpose: otherwise
+ * anyone could spend the budget with junk requests and lock CiteCue out.
+ *
+ * @return void
+ */
+ public function test_unsigned_traffic_cannot_exhaust_the_rate_limit() {
+ add_filter( 'citecue_ingest_rate_limit', static fn() => 1 );
+
+ for ( $i = 0; $i < 20; $i++ ) {
+ $junk = $this->signed_push( array( 'external_id' => "junk-{$i}" ), array( 'secret' => 'wrong' ) );
+ $this->assertSame( 401, rest_do_request( $junk )->get_status() );
+ }
+
+ $this->assertSame( 201, rest_do_request( $this->signed_push() )->get_status() );
+ }
+
+ /**
+ * The health endpoint is the install handshake, so it stays public — and
+ * must therefore never expose the shared secret or the API key.
+ *
+ * @return void
+ */
+ public function test_health_is_public_and_leaks_nothing() {
+ $this->configure_delivery();
+
+ $response = rest_do_request( new WP_REST_Request( 'GET', '/citecue/v1/health' ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'citecue', $data['plugin'] );
+ $this->assertTrue( $data['ingest'] );
+ $this->assertTrue( $data['delivery'] );
+
+ $encoded = wp_json_encode( $data );
+ $this->assertStringNotContainsString( self::SECRET, $encoded );
+ $this->assertStringNotContainsString( self::API_KEY, $encoded );
+ }
+
+ /**
+ * Builds a signed push request.
+ *
+ * @param array $payload Body fields merged over a valid default.
+ * @param array $signing Optional 'secret' and 'timestamp' overrides.
+ * @return WP_REST_Request
+ */
+ private function signed_push( array $payload = array(), array $signing = array() ) {
+ $body = wp_json_encode(
+ array_merge(
+ array(
+ 'external_id' => 'faq-pack-1',
+ 'title' => 'Acme FAQ',
+ 'content' => 'What is Acme?
A company.
',
+ ),
+ $payload
+ )
+ );
+
+ $secret = isset( $signing['secret'] ) ? $signing['secret'] : self::SECRET;
+ $timestamp = isset( $signing['timestamp'] ) ? $signing['timestamp'] : time();
+
+ $request = new WP_REST_Request( 'POST', '/citecue/v1/content' );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_header( 'x-citecue-timestamp', (string) $timestamp );
+ $request->set_header( 'x-citecue-signature', 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $body, $secret ) );
+ $request->set_body( $body );
+
+ return $request;
+ }
+
+ /**
+ * An identical copy of a request, as a captured replay would be.
+ *
+ * @param WP_REST_Request $request Original request.
+ * @return WP_REST_Request
+ */
+ private function replay_of( WP_REST_Request $request ) {
+ $replay = new WP_REST_Request( 'POST', '/citecue/v1/content' );
+ $replay->set_headers( $request->get_headers() );
+ $replay->set_body( $request->get_body() );
+
+ return $replay;
+ }
+
+ /**
+ * Asserts a push was refused with a specific status and error code.
+ *
+ * @param int $status Expected HTTP status.
+ * @param string $code Expected WP_Error code.
+ * @param WP_REST_Request $request Request to send.
+ * @return void
+ */
+ private function assertPushRejected( $status, $code, WP_REST_Request $request ) {
+ $response = rest_do_request( $request );
+
+ $this->assertSame( $status, $response->get_status() );
+ $this->assertSame( $code, $response->get_data()['code'] );
+ }
+}
diff --git a/tests/cases/test-ingest-content.php b/tests/cases/test-ingest-content.php
new file mode 100644
index 0000000..5f7d831
--- /dev/null
+++ b/tests/cases/test-ingest-content.php
@@ -0,0 +1,627 @@
+plugin->settings->update(
+ array(
+ 'ingest_enabled' => true,
+ 'ingest_secret' => self::SECRET,
+ )
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_a_push_creates_a_draft_post() {
+ $response = $this->push(
+ array(
+ 'title' => 'Acme FAQ',
+ 'content' => 'What is Acme?
A company.
',
+ )
+ );
+
+ $this->assertSame( 201, $response->get_status() );
+ $this->assertTrue( $response->get_data()['created'] );
+
+ $post = get_post( $response->get_data()['post_id'] );
+ $this->assertSame( 'Acme FAQ', $post->post_title );
+ $this->assertSame( 'draft', $post->post_status );
+ $this->assertSame( 'post', $post->post_type );
+ $this->assertStringContainsString( 'What is Acme?', $post->post_content );
+ }
+
+ /**
+ * Nothing CiteCue pushes is visible until a human says so — that is the
+ * default the plugin promises.
+ *
+ * @return void
+ */
+ public function test_content_is_a_draft_even_when_publish_is_requested() {
+ $response = $this->push( array( 'status' => 'publish' ) );
+
+ $this->assertSame( 'draft', get_post_status( $response->get_data()['post_id'] ) );
+ }
+
+ /**
+ * @dataProvider provide_status_caps
+ *
+ * @param string $cap Configured maximum status.
+ * @param string $requested Requested status.
+ * @param string $expected Effective status.
+ * @return void
+ */
+ public function test_status_is_capped_by_the_setting( $cap, $requested, $expected ) {
+ $this->plugin->settings->update( array( 'ingest_post_status' => $cap ) );
+
+ $response = $this->push( array( 'status' => $requested ) );
+
+ $this->assertSame( $expected, get_post_status( $response->get_data()['post_id'] ) );
+ }
+
+ /**
+ * Cap/request/result combinations.
+ *
+ * @return array
+ */
+ public function provide_status_caps() {
+ return array(
+ 'draft cap holds publish' => array( 'draft', 'publish', 'draft' ),
+ 'draft cap holds pending' => array( 'draft', 'pending', 'draft' ),
+ 'pending cap allows pending' => array( 'pending', 'pending', 'pending' ),
+ 'pending cap holds publish' => array( 'pending', 'publish', 'pending' ),
+ 'pending cap allows draft' => array( 'pending', 'draft', 'draft' ),
+ 'publish cap allows publish' => array( 'publish', 'publish', 'publish' ),
+ 'publish cap allows draft' => array( 'publish', 'draft', 'draft' ),
+ 'unknown request falls to cap' => array( 'pending', 'nonsense', 'pending' ),
+ 'no request falls to cap' => array( 'pending', '', 'pending' ),
+ );
+ }
+
+ /**
+ * @return void
+ */
+ public function test_a_second_push_updates_the_same_post() {
+ $first = $this->push( array( 'title' => 'First' ) );
+ $id = $first->get_data()['post_id'];
+
+ $second = $this->push( array( 'title' => 'Second' ), array( 'timestamp' => time() + 1 ) );
+
+ $this->assertSame( 200, $second->get_status() );
+ $this->assertTrue( $second->get_data()['updated'] );
+ $this->assertSame( $id, $second->get_data()['post_id'] );
+ $this->assertSame( 'Second', get_post( $id )->post_title );
+ $this->assertCount(
+ 1,
+ get_posts(
+ array(
+ 'post_status' => 'draft',
+ 'fields' => 'ids',
+ )
+ )
+ );
+ }
+
+ /**
+ * A different external_id is a different piece of content.
+ *
+ * @return void
+ */
+ public function test_distinct_external_ids_create_distinct_posts() {
+ $one = $this->push( array( 'external_id' => 'one' ) );
+ $two = $this->push( array( 'external_id' => 'two' ), array( 'timestamp' => time() + 1 ) );
+
+ $this->assertNotSame( $one->get_data()['post_id'], $two->get_data()['post_id'] );
+ }
+
+ /**
+ * Pushed HTML is untrusted input: it is stored, so it must be sanitized
+ * exactly like content from an editor without unfiltered_html.
+ *
+ * @return void
+ */
+ public function test_dangerous_markup_is_stripped() {
+ $response = $this->push(
+ array(
+ 'content' => 'Hello
',
+ )
+ );
+
+ $content = get_post( $response->get_data()['post_id'] )->post_content;
+
+ $this->assertStringNotContainsString( ' FAQ' ) );
+
+ $this->assertStringNotContainsString( '