diff --git a/digitizer-site-worker/includes/class-aura-worker-api.php b/digitizer-site-worker/includes/class-aura-worker-api.php index 7ddd6d4a..d0123a93 100644 --- a/digitizer-site-worker/includes/class-aura-worker-api.php +++ b/digitizer-site-worker/includes/class-aura-worker-api.php @@ -663,6 +663,10 @@ public function get_status( $request ) { ); } + // Whether this site's elementor-mcp can say a write carried CSS (2.20.0, + // Aura spec 2026-09-24 §4.2). An OBJECT on the wire, like `unbound`. + $status['css_rules'] = (object) array( 'fork' => Aura_Worker_Rules::fork_css_state() ); + return rest_ensure_response( $status ); } diff --git a/digitizer-site-worker/includes/class-aura-worker-rules.php b/digitizer-site-worker/includes/class-aura-worker-rules.php index 2faf5ba3..8c894567 100644 --- a/digitizer-site-worker/includes/class-aura-worker-rules.php +++ b/digitizer-site-worker/includes/class-aura-worker-rules.php @@ -230,8 +230,19 @@ public static function count_24h( $prefix, $now = null ) { return $sum; } - /** The only resource types a rule may name. Anything else never matches. */ - const TYPES = array( 'site', 'page', 'post', 'plugin', 'design_system', 'page_create' ); + /** The only resource types a rule may name. Anything else never matches. `custom_css` since 2.20.0. */ + const TYPES = array( 'site', 'page', 'post', 'plugin', 'design_system', 'page_create', 'custom_css' ); + + /** + * Normalised-set key prefix for a `custom_css` touch that carries BOTH + * evidence fields as literal `true` on a concrete (digits) id — the only + * touch an `allow custom_css` rule may match (spec 2026-09-24 §3). Not a + * type: an operator can never name it, and nothing outside this class + * reads it. + * + * @since 2.20.0 + */ + private const CSS_EXACT_PREFIX = 'custom_css!exact:'; /** Target types that carry no id — a rule on them names the whole category. */ const ID_LESS_TYPES = array( 'site', 'design_system', 'page_create' ); @@ -411,7 +422,8 @@ public static function is_expired( array $rule, $now ) { * @return array Set of "type:id". */ private static function normalize_touches( array $touches ) { - $set = array(); + $set = array(); + $inexact = array(); // custom_css ids with at least one touch lacking evidence. foreach ( $touches as $t ) { if ( ! is_array( $t ) || ! isset( $t['type'], $t['id'] ) ) { continue; @@ -438,6 +450,25 @@ private static function normalize_touches( array $touches ) { continue; } $set[ $type . ':' . $id ] = true; + // Evidence fields (2.20.0): on a custom_css touch only, each only + // as the literal true, and only on a concrete id. Anything else is + // read as absent — the conservative reading (spec §3). + if ( 'custom_css' === $type ) { + if ( ctype_digit( $id ) + && isset( $t['precise'], $t['css_only'] ) + && true === $t['precise'] + && true === $t['css_only'] ) { + $set[ self::CSS_EXACT_PREFIX . $id ] = true; + } else { + $inexact[ $id ] = true; + } + } + } + // Exactness is per id and needs EVERY touch on that id (Codex r1 on + // #135): one conservative touch on 42 beside an exact one must not be + // erased by it, or `allow custom_css:42` would admit the call. + foreach ( $inexact as $id => $unused ) { + unset( $set[ self::CSS_EXACT_PREFIX . $id ] ); } if ( empty( $set ) ) { // A declaration that survives normalisation as nothing — `[]`, @@ -462,6 +493,9 @@ private static function rule_touches( array $rule, array $touched ) { if ( ! in_array( $type, self::TYPES, true ) ) { return false; } + if ( 'custom_css' === $type ) { + return self::css_rule_touches( $rule, $touched ); + } if ( isset( $touched[ self::UNKNOWN . ':*' ] ) ) { return true; // Undeclared: every live rule applies. } @@ -486,6 +520,161 @@ private static function rule_touches( array $rule, array $touched ) { return isset( $touched[ $type . ':' . $id ] ); } + /** + * The custom_css arm (spec 2026-09-24 §3). Effect-aware, because + * conservative matching exists to over-BLOCK: an `allow` that matched a + * wildcard, a conservative declaration or `unknown:*` would over-PERMIT. + * + * @since 2.20.0 + * + * @param array $rule Rule (type already known to be custom_css). + * @param array $touched Normalised set. + * @return bool + */ + private static function css_rule_touches( array $rule, array $touched ) { + $target = isset( $rule['target'] ) && is_array( $rule['target'] ) ? $rule['target'] : array(); + $raw = array_key_exists( 'id', $target ) ? $target['id'] : null; + $any = ( null === $raw || '*' === $raw ); + $id = $any ? '' : (string) $raw; + if ( ! $any && '' === $id ) { + return false; // an empty id names nothing — never site-wide + } + $effect = isset( $rule['effect'] ) ? (string) $rule['effect'] : ''; + + if ( 'allow' === $effect ) { + // Fail closed at the SET level (review #1): an allow admits a call + // only when EVERY custom_css touch it declared is precise. One + // exact touch riding alongside a conservative, create-time or + // unknown declaration must not buy the whole call an allow. + if ( isset( $touched[ self::UNKNOWN . ':*' ] ) || isset( $touched['custom_css:*'] ) ) { + return false; + } + foreach ( $touched as $key => $unused ) { + if ( 0 === strpos( $key, 'custom_css:' ) + && ! isset( $touched[ self::CSS_EXACT_PREFIX . substr( $key, strlen( 'custom_css:' ) ) ] ) ) { + return false; + } + } + if ( ! $any ) { + return isset( $touched[ self::CSS_EXACT_PREFIX . $id ] ); + } + foreach ( $touched as $key => $unused ) { + if ( 0 === strpos( $key, self::CSS_EXACT_PREFIX ) ) { + return true; + } + } + return false; + } + + // block / warn: evidence fields do not matter; silence over-blocks. + if ( isset( $touched[ self::UNKNOWN . ':*' ] ) ) { + return true; + } + if ( $any ) { + foreach ( $touched as $key => $unused ) { + if ( 0 === strpos( $key, 'custom_css:' ) ) { + return true; + } + } + return false; + } + return isset( $touched[ 'custom_css:' . $id ] ) || isset( $touched['custom_css:*'] ); + } + + /** + * Test seam: null = read the real constant; false = fork absent; string = that version. + * + * @since 2.20.0 + * @var null|false|string + */ + private static $fork_version_for_tests = null; + + /** + * Test seam: null = ask the loaded code (method_exists); bool = pretend + * Elementor_MCP_Rules::css_touches() is (true) or is not (false) there. + * + * @since 2.20.0 + * @var null|bool + */ + private static $fork_css_touches_for_tests = null; + + /** + * @since 2.20.0 + * @param null|false|string $version See the property. + * @param null|bool $has_css_touches See $fork_css_touches_for_tests. + */ + public static function _set_fork_version_for_tests( $version, $has_css_touches = null ) { + self::$fork_version_for_tests = $version; + self::$fork_css_touches_for_tests = $has_css_touches; + } + + /** + * Can the loaded elementor-mcp say whether a write carries CSS? + * `precise` — 1.37.0 or newer AND it ships Elementor_MCP_Rules::css_touches(); + * `widened` — the fork is loaded but fails either test, so its page + * writes count as possible CSS; `absent` — no fork. Reported on /status + * as css_rules.fork. + * + * Why both: a version number is not a capability (final review I1). The + * 1.37.0 floor assumes that release is the one Plan B ships with the + * public static Elementor_MCP_Rules::css_touches(); if any other change + * went out as 1.37.0 first, a version-only check would stop widening a + * fork that declares no custom_css touches, and every `block custom_css` + * would silently stop matching its writes. Requiring the method too + * fails toward over-blocking, never under. + * + * @since 2.20.0 + * @return string + */ + public static function fork_css_state() { + $v = self::$fork_version_for_tests; + if ( null === $v ) { + $v = defined( 'ELEMENTOR_MCP_VERSION' ) ? (string) ELEMENTOR_MCP_VERSION : false; + } + if ( false === $v ) { + return 'absent'; + } + if ( ! preg_match( '/^\d+\.\d+\.\d+$/', (string) $v ) ) { + return 'widened'; + } + if ( ! version_compare( (string) $v, '1.37.0', '>=' ) ) { + return 'widened'; + } + $capable = self::$fork_css_touches_for_tests; + if ( null === $capable ) { + $capable = class_exists( 'Elementor_MCP_Rules' ) && method_exists( 'Elementor_MCP_Rules', 'css_touches' ); + } + return true === $capable ? 'precise' : 'widened'; + } + + /** + * An old fork's page writes, read as possible CSS (spec §4.2). Only the + * fork's own abilities; never evidence fields, so no allow can use them. + * + * @since 2.20.0 + * @param array $touches Declared touches. + * @param string $tool_name Calling tool. + * @return array + */ + private static function widen_for_old_fork( array $touches, $tool_name ) { + if ( 0 !== strpos( (string) $tool_name, 'elementor-mcp/' ) || 'widened' !== self::fork_css_state() ) { + return $touches; + } + $extra = array(); + foreach ( $touches as $t ) { + if ( ! is_array( $t ) || ! isset( $t['type'], $t['id'] ) ) { + continue; + } + $type = (string) $t['type']; + if ( 'page' === $type || 'post' === $type ) { + $extra[ (string) $t['id'] ] = array( 'type' => 'custom_css', 'id' => (string) $t['id'] ); + } elseif ( 'site' === $type ) { + $extra['*'] = array( 'type' => 'custom_css', 'id' => '*' ); + } + } + return array_merge( $touches, array_values( $extra ) ); + } + /* ------------------------------------------------------------------ */ /* The store — option-backed, signed, monotonic */ /* ------------------------------------------------------------------ */ @@ -2342,7 +2531,8 @@ public static function enforce( array $touches, $tool_name, $now = null ) { // the preview path asks the same question of the same record, so the // two can never disagree). The fork inherits this through enforce(), // so its governance wrapper needs no change of its own. - $rule = self::enforceable_match( $touches, self::rules(), $now, self::site_ref() ); + $touches = self::widen_for_old_fork( $touches, $tool_name ); + $rule = self::enforceable_match( $touches, self::rules(), $now, self::site_ref() ); if ( null === $rule ) { return array( 'effect' => null ); } diff --git a/digitizer-site-worker/includes/class-aura-worker-tools.php b/digitizer-site-worker/includes/class-aura-worker-tools.php index 9c9d5cb9..ce5ed17e 100644 --- a/digitizer-site-worker/includes/class-aura-worker-tools.php +++ b/digitizer-site-worker/includes/class-aura-worker-tools.php @@ -220,6 +220,15 @@ public function execute_tool( $name, $params ) { * verdict, planned command, file diff, SQL) at approval time. Tools that do * not declare supports_preview return `supported: false` with a null preview. * + * Old-fork CSS widening (2.20.0) is NOT applied here: it lives in + * Aura_Worker_Rules::enforce(), which widens an `elementor-mcp/*` + * ability's page/site touches into custom_css ones before matching, + * while this preview asks enforceable_match() directly. That is safe + * today because only SiteAgent's own tools reach preview_tool() and none + * is named `elementor-mcp/…`, so widening would be a no-op here. If a + * fork ability ever reaches this path, widen here too, or the preview + * and enforce() disagree about the same call. + * * @param string $name Tool name. * @param array $params Parameters to preview. * @return array { success: bool, supported?: bool, preview?: mixed, error?: string, errors?: string[] } diff --git a/digitizer-site-worker/includes/class-elementor-door-governor.php b/digitizer-site-worker/includes/class-elementor-door-governor.php index 2134dad6..68251af2 100644 --- a/digitizer-site-worker/includes/class-elementor-door-governor.php +++ b/digitizer-site-worker/includes/class-elementor-door-governor.php @@ -202,6 +202,7 @@ public static function reset_for_tests() { self::$request = null; self::$active = null; self::$seq_lease = null; + self::$schema_reader = null; Aura_Worker_Door_Log::forget_live_identity(); // $GLOBALS['_sa_force_door'] — active()'s test override, standing in // for the module class this suite cannot define — is reset by @@ -2897,12 +2898,384 @@ public static function actor() { ); } + /** + * How each WRITE_TABLE slug relates to custom CSS (spec 2026-09-24 §4.2, + * plan rulings R1/R2). `precise` — CSS is read from named arguments; + * `conservative` — CSS may be inside content this class does not parse. + * Every WRITE_TABLE slug is in exactly one of CSS_PRODUCERS / NO_CSS + * (ElementorDoorCssClassificationTest). + * + * @since 2.20.0 + */ + const CSS_PRODUCERS = array( + 'elementor/update-page-settings' => 'precise', + 'elementor/manage-elements' => 'precise', + 'elementor/build-composition' => 'conservative', + 'elementor/manage-component' => 'conservative', + ); + + /** + * Writes that carry no custom CSS, each with the reason. A `css`-named + * property listed under `exempt` is design-system CSS (global classes, + * tag defaults) — plan ruling R1 — and is the ONLY CSS-capable property + * the schema guard tolerates for that slug. + * + * @since 2.20.0 + */ + const NO_CSS = array( + 'elementor/publish-document' => array( 'reason' => 'promotes an already-judged autosave', 'exempt' => array() ), + 'elementor/create-preview-link' => array( 'reason' => 'mints a preview URL; writes no content', 'exempt' => array() ), + 'elementor/create-page' => array( 'reason' => 'creates an empty document', 'exempt' => array() ), + 'elementor/manage-classes' => array( 'reason' => 'global-class CSS — design_system (R1)', 'exempt' => array( 'operations[].css' ) ), + 'elementor/manage-default-styles' => array( 'reason' => 'tag default styles — design_system (R1)', 'exempt' => array( 'operations[].css' ) ), + 'elementor/reorder-classes' => array( 'reason' => 'reorders class ids; no style content', 'exempt' => array() ), + 'elementor/manage-global-variable' => array( 'reason' => 'a variable value, not a stylesheet', 'exempt' => array() ), + ); + + /** CSS-capable property names (spec §4.1 guard). @since 2.20.0 */ + const CSS_PROPERTY_NAMES = array( 'custom_css', 'css', 'style', 'settings', 'page_settings', 'elements', 'structure', 'xml_structure', 'element_config' ); + + /** Forwarded opaque strings — markup or code that may embed a stylesheet (Codex r3). @since 2.20.0 */ + const OPAQUE_PROPERTY_PATTERN = '/css|style|content|html|markup|template|code/i'; // also CSS-named variants: extra_css, inline_style (Codex r5) + + /** + * The input paths each precise producer's handler actually reads for CSS + * (Codex r3). A live schema that grows any OTHER CSS-capable path makes the + * producer conservative until the handler learns it. + * + * @since 2.20.0 + */ + const PRODUCER_HANDLED_PATHS = array( + 'elementor/update-page-settings' => array( 'settings' ), + 'elementor/manage-elements' => array( 'operations[].settings', 'operations[].style', 'operations[].style_apply_mode' ), // style_apply_mode is the patch|replace merge mode, matched by the name net's "style" substring — never CSS text (controller ruling). + ); + + /** + * A key inside an open `settings` container (update-page-settings, and + * each manage-elements op) whose name matches this is CSS of unknown + * shape, unless it is `custom_css` itself (read precisely) or one of the + * exceptions below (Codex r2 on #135). The 4.3 schema declares page + * `settings` with additionalProperties: true, so Elementor could start + * honouring e.g. `extra_css` there with no schema change the drift check + * would see. `style` is deliberately NOT matched: element settings are + * full of `*_style` controls, and matching them would over-block. + * + * @since 2.20.0 + */ + const SETTINGS_CSS_KEY_PATTERN = '/css/i'; + + /** + * CSS-named settings keys that are ordinary controls, not stylesheets: + * `_css_classes` holds class names. They only make a call mixed. + * + * @since 2.20.0 + */ + const SETTINGS_CSS_KEY_EXCEPTIONS = array( '_css_classes' ); + + /** + * Prefix of Elementor's CSS-filter control group (`css_filters_blur`, + * …): structured controls, not custom CSS. + * + * @since 2.20.0 + */ + const SETTINGS_CSS_KEY_EXCEPTION_PREFIX = 'css_filters'; + + /** + * Suffix for a node whose shape the walker does not resolve (combinator, + * patternProperties, tuple items). No handled or exempt list names it, so + * such a node always fails the drift check — even at a path that is + * itself handled or exempt (Codex r1 on #135). + * + * @since 2.20.0 + */ + const UNRESOLVED_SUFFIX = '{unresolved}'; + + /** @var callable|null test seam: fn( string $slug ): ?array @since 2.20.0 */ + private static $schema_reader = null; + + /** @since 2.20.0 */ + public static function _set_schema_reader_for_tests( $fn ) { + self::$schema_reader = $fn; + } + + /** + * Paths through which a JSON schema can carry CSS: a property with a + * CSS-capable name, or an object open to any property. Arrays recurse + * into `items` as `name[].child`. + * + * @since 2.20.0 + * @param array $schema Schema. + * @param string $prefix Path so far. + * @return string[] + */ + public static function css_capable_paths( array $schema, $prefix = '' ) { + return array_values( array_unique( self::css_capable_paths_raw( $schema, $prefix ) ) ); + } + + /** @since 2.20.0 @param array $schema Schema. @param string $prefix Path so far. @return string[] (may repeat — an open named container is reached twice) */ + private static function css_capable_paths_raw( array $schema, $prefix ) { + $out = array(); + $here = '' === $prefix ? '(root)' : $prefix; + // The node ITSELF may be open (Codex r1 on the plan): a root, or an + // array item, that accepts any property can carry CSS under any name. + // Explicit `true` or a schema-valued `additionalProperties` counts; + // an absent key does not (WP schemas omit it everywhere), so a bare + // {type: object} stays closed (controller ruling, final review M2). + if ( isset( $schema['additionalProperties'] ) && ( true === $schema['additionalProperties'] || is_array( $schema['additionalProperties'] ) ) ) { + $out[] = $here; + } + // Shapes this walker does not read into are CSS-capable as a whole — + // fail closed rather than silently pass (final review, Task 4 minor): + // keys matched by pattern, and combinators whose branches may differ. + // They surface under a distinct marker, never the plain path, so a + // handled or exempt entry for that path cannot absorb them (Codex r1 + // on #135). Branches are not descended: the marker is the answer. + if ( ! empty( $schema['patternProperties'] ) ) { + $out[] = $here . self::UNRESOLVED_SUFFIX; + } + foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $combinator ) { + if ( isset( $schema[ $combinator ] ) ) { + $out[] = $here . self::UNRESOLVED_SUFFIX; + } + } + // Arrays: a single item schema is descended as `name[].child` (nested + // arrays as `name[][]…`); a tuple — a list of item schemas — is flagged. + if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { + $items = $schema['items']; + if ( array() !== $items && array_keys( $items ) === range( 0, count( $items ) - 1 ) ) { + $out[] = $here . self::UNRESOLVED_SUFFIX; + } else { + $out = array_merge( $out, self::css_capable_paths_raw( $items, ( '' === $prefix ? '' : $prefix ) . '[]' ) ); + } + } + $props = isset( $schema['properties'] ) && is_array( $schema['properties'] ) ? $schema['properties'] : array(); + foreach ( $props as $name => $sub ) { + $path = '' === $prefix ? (string) $name : $prefix . '.' . $name; + $sub = is_array( $sub ) ? $sub : array(); + if ( in_array( (string) $name, self::CSS_PROPERTY_NAMES, true ) || preg_match( self::OPAQUE_PROPERTY_PATTERN, (string) $name ) ) { + $out[] = $path; + // Keep descending (Codex r4): a container already on a handled + // list can still grow a CSS child (`settings.css`) the handler + // does not read — that child must surface as its own path. + } + $out = array_merge( $out, self::css_capable_paths_raw( $sub, $path ) ); + } + return $out; + } + + /** + * The ability's live input schema, or null when unreadable. + * + * @since 2.20.0 + * @param string $slug Ability. + * @return array|null + */ + private static function live_input_schema( $slug ) { + if ( null !== self::$schema_reader ) { + return call_user_func( self::$schema_reader, $slug ); + } + if ( ! function_exists( 'wp_get_ability' ) ) { + return null; + } + $ability = wp_get_ability( $slug ); + if ( ! $ability || ! method_exists( $ability, 'get_input_schema' ) ) { + return null; + } + $schema = $ability->get_input_schema(); + return is_array( $schema ) ? $schema : null; + } + + /** + * CSS value classification. `null` / whitespace string / empty array = + * clearing (not CSS); a non-empty string = CSS read from the argument; + * every other value — false, 0, 0.0, true, numbers, non-empty arrays, + * objects — is CSS of unknown shape (final review: one rule, fail closed). + * + * @since 2.20.0 + * @param mixed $v Value. + * @return string 'none'|'css'|'unknown' + */ + private static function css_value( $v ) { + if ( null === $v ) { + return 'none'; + } + if ( is_string( $v ) ) { + return '' === trim( $v ) ? 'none' : 'css'; + } + return array() === $v ? 'none' : 'unknown'; + } + + /** + * 'unknown' when a settings container carries a non-empty CSS-named key + * other than `custom_css` (see SETTINGS_CSS_KEY_PATTERN); else 'none'. + * The value is read like custom_css: null / whitespace / empty array + * clears and declares nothing. + * + * @since 2.20.0 + * @param array $settings Settings container. + * @return string 'none'|'unknown' + */ + private static function other_css_keys( array $settings ) { + foreach ( $settings as $key => $value ) { + $key = (string) $key; + if ( 'custom_css' === $key + || in_array( $key, self::SETTINGS_CSS_KEY_EXCEPTIONS, true ) + || 0 === strpos( $key, self::SETTINGS_CSS_KEY_EXCEPTION_PREFIX ) + || ! preg_match( self::SETTINGS_CSS_KEY_PATTERN, $key ) ) { + continue; + } + if ( 'none' !== self::css_value( $value ) ) { + return 'unknown'; + } + } + return 'none'; + } + + /** + * The custom_css touches a door write declares, in addition to its + * page/post/design_system ones. Pure. + * + * @since 2.20.0 + * @param string $slug Ability. + * @param array $input Input. + * @param string $id Resolved post id, or '*'. + * @return array + */ + public static function css_touches_for( $slug, array $input, $id ) { + $id = (string) $id; + if ( isset( self::NO_CSS[ $slug ] ) ) { + // A NO_CSS entry is checked, not trusted: if the LIVE schema grew a + // CSS-capable property beyond the recorded exemptions (an Elementor + // release after 4.3.0-beta3), declare conservatively rather than + // let a custom_css block rule be bypassed. + $schema = self::live_input_schema( $slug ); + if ( is_array( $schema ) && array() !== array_diff( self::css_capable_paths( $schema ), self::NO_CSS[ $slug ]['exempt'] ) ) { + return array( array( 'type' => 'custom_css', 'id' => $id ) ); + } + return array(); + } + $kind = isset( self::CSS_PRODUCERS[ $slug ] ) ? self::CSS_PRODUCERS[ $slug ] : null; + if ( null === $kind ) { + return array(); + } + if ( 'conservative' === $kind ) { + return array( array( 'type' => 'custom_css', 'id' => $id ) ); + } + // A precise producer whose LIVE schema grew a CSS-capable path its + // handler does not read is conservative until it does (Codex r3). + $live = self::live_input_schema( $slug ); + if ( is_array( $live ) && array() !== array_diff( self::css_capable_paths( $live ), isset( self::PRODUCER_HANDLED_PATHS[ $slug ] ) ? self::PRODUCER_HANDLED_PATHS[ $slug ] : array() ) ) { + return array( array( 'type' => 'custom_css', 'id' => $id ) ); + } + $found = 'none'; // none | css | unknown + $css_only = true; + if ( 'elementor/update-page-settings' === $slug ) { + if ( isset( $input['settings'] ) && ! is_array( $input['settings'] ) ) { + // An object or scalar `settings` (in-process PHP callers; JSON + // decodes to arrays) is CSS of unknown shape, never "no CSS" + // (final review M1). + $found = 'unknown'; + $css_only = false; + } else { + $settings = isset( $input['settings'] ) ? $input['settings'] : array(); + $found = array_key_exists( 'custom_css', $settings ) ? self::css_value( $settings['custom_css'] ) : 'none'; + // The WHOLE input, not just `settings` (Codex r4): any other + // top-level field is an effect this allow never looked at. + $css_only = array( 'custom_css' ) === array_keys( $settings ) + && array() === array_diff( array_keys( $input ), array( 'post_id', 'settings' ) ); + // Another CSS-named key: CSS this handler does not read (Codex r2). + if ( 'unknown' === self::other_css_keys( $settings ) ) { + $found = 'unknown'; + $css_only = false; + } + } + } elseif ( 'elementor/manage-elements' === $slug ) { + $ops = isset( $input['operations'] ) ? $input['operations'] : null; + if ( array() !== array_diff( array_keys( $input ), array( 'post_id', 'operations' ) ) ) { + $css_only = false; // an unknown top-level field is an unreviewed effect (Codex r4) + } + if ( ! is_array( $ops ) ) { + return array( array( 'type' => 'custom_css', 'id' => $id ) ); + } + foreach ( $ops as $op ) { + if ( ! is_array( $op ) ) { + $found = 'unknown'; + $css_only = false; + continue; + } + $op_css = 'none'; + if ( array_key_exists( 'style', $op ) ) { + $op_css = self::css_value( $op['style'] ); + } + $settings = isset( $op['settings'] ) && is_array( $op['settings'] ) ? $op['settings'] : array(); + if ( isset( $op['settings'] ) && ! is_array( $op['settings'] ) ) { + // Not an array: CSS of unknown shape (final review M1). + $op_css = 'unknown'; + $css_only = false; + } elseif ( array_key_exists( 'custom_css', $settings ) ) { + $s = self::css_value( $settings['custom_css'] ); + $op_css = 'unknown' === $s || 'unknown' === $op_css ? 'unknown' : ( 'css' === $s ? 'css' : $op_css ); + } + if ( 'unknown' === self::other_css_keys( $settings ) ) { + // Another CSS-named key: CSS this handler does not read (Codex r2). + $op_css = 'unknown'; + $css_only = false; + } + if ( 'unknown' === $op_css || ( 'css' === $op_css && 'unknown' !== $found ) ) { + $found = 'unknown' === $op_css ? 'unknown' : 'css'; + } + $only_css_keys = ( isset( $op['action'] ) && 'update' === $op['action'] ) + && array() === array_diff( array_keys( $op ), array( 'action', 'element_id', 'style', 'style_apply_mode', 'settings' ) ) + && array() === array_diff( array_keys( $settings ), array( 'custom_css' ) ) + && 'none' !== $op_css; + if ( ! $only_css_keys ) { + $css_only = false; + } + } + } + if ( 'none' === $found ) { + return array(); + } + $touch = array( 'type' => 'custom_css', 'id' => $id ); + if ( 'css' === $found && ctype_digit( $id ) ) { + $touch['precise'] = true; + if ( $css_only ) { + $touch['css_only'] = true; + } + } + return array( $touch ); + } + /** * @param string $slug Ability. * @param array $input Input. * @return array|WP_Error touches, or aura_target_unattributed. */ public static function touches_for( $slug, array $input ) { + $base = self::base_touches_for( $slug, $input ); + if ( is_wp_error( $base ) ) { + return $base; + } + // The CSS declaration rides EVERY kind (spec 2026-09-24 §4.2): a + // `page`-kind write names its post (the FIRST touch base_touches_for() + // returns for that kind); every other kind is site-wide — including a + // class deletion, whose extra page touches are collateral, not the + // write's own target. + $kind = isset( self::WRITE_TABLE[ $slug ] ) ? self::WRITE_TABLE[ $slug ] : null; + $id = ( 'page' === $kind && isset( $base[0]['id'] ) ) ? (string) $base[0]['id'] : '*'; + return array_merge( $base, self::css_touches_for( $slug, $input, $id ) ); + } + + /** + * The target/collateral touches for a governed write, before the CSS + * declaration `touches_for()` appends (spec 2026-09-24 §4.2). + * + * @since 2.20.0 + * @param string $slug Ability. + * @param array $input Input. + * @return array|WP_Error touches, or aura_target_unattributed. + */ + private static function base_touches_for( $slug, array $input ) { $kind = isset( self::WRITE_TABLE[ $slug ] ) ? self::WRITE_TABLE[ $slug ] : null; switch ( $kind ) { case 'page': diff --git a/tests/bootstrap.php b/tests/bootstrap.php index a9e6bd6b..2509dd51 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -4714,14 +4714,19 @@ class WP_Ability { protected $execute_callback; protected $permission_callback; protected $meta = array(); + /** Set via $args['input_schema']; null (unreadable) when not given, so existing tests see no schema. */ + protected $input_schema = null; public function __construct( string $name, array $args ) { $this->name = $name; $this->execute_callback = $args['execute_callback'] ?? null; $this->permission_callback = $args['permission_callback'] ?? null; $this->meta = is_array( $args['meta'] ?? null ) ? $args['meta'] : array(); + $this->input_schema = $args['input_schema'] ?? null; } public function get_name(): string { return $this->name; } public function get_meta(): array { return $this->meta; } + /** Core's getter (class-wp-ability.php), reduced: the door's live CSS schema guard reads it. */ + public function get_input_schema() { return $this->input_schema; } public function execute( $input = null ) { if ( ! is_callable( $this->execute_callback ) ) { return new WP_Error( 'ability_invalid_execute_callback', 'no callback' ); @@ -5392,6 +5397,7 @@ function sa_reset_state(): void { // than failing the test that actually left it set. Aura_Worker_Rules::$rest_request_override = null; Aura_Worker_Rules::$cookie_auth_override = null; + Aura_Worker_Rules::_set_fork_version_for_tests( null ); } if ( class_exists( 'Aura_Worker_Door_Log' ) ) { // Ruling S7's test seam: a test that fakes a 32-bit build and forgets diff --git a/tests/fixtures/elementor-4.3-write-schemas.json b/tests/fixtures/elementor-4.3-write-schemas.json new file mode 100644 index 00000000..1823ed8a --- /dev/null +++ b/tests/fixtures/elementor-4.3-write-schemas.json @@ -0,0 +1,195 @@ +{ + "source": "Elementor 4.3.0-beta3 (Digitizers/references elementor-core-mcp), input schemas", + "files": { + "elementor/publish-document": "publish-document-ability.php", + "elementor/manage-elements": "manage-elements-ability.php", + "elementor/update-page-settings": "update-settings-ability.php", + "elementor/build-composition": "build-composition-ability.php", + "elementor/create-preview-link": "create-preview-link-ability.php", + "elementor/manage-component": "manage-component-ability.php", + "elementor/manage-default-styles": "manage-default-styles-ability.php", + "elementor/manage-classes": "manage-classes-ability.php", + "elementor/reorder-classes": "reorder-classes-ability.php", + "elementor/manage-global-variable": "manage-variable-ability.php", + "elementor/create-page": "create-page-ability.php" + }, + "schemas": { + "elementor/publish-document": { + "type": "object", + "required": ["post_id"], + "properties": { + "post_id": { "type": "integer" } + } + }, + "elementor/manage-elements": { + "type": "object", + "required": ["post_id", "operations"], + "properties": { + "post_id": { "type": "integer" }, + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action", "element_id"], + "properties": { + "action": { "type": "string", "enum": ["update", "delete", "move", "duplicate"] }, + "element_id": { "type": "string" }, + "settings": { "type": "object" }, + "style": { "type": "string" }, + "style_apply_mode": { "type": "string", "enum": ["patch", "replace"] }, + "classes": { "type": "array", "items": { "type": "string" } }, + "interactions": { "type": "array", "items": { "type": "object" } }, + "new_parent_id": { "type": "string" }, + "index": { "type": ["integer", "null"] } + } + } + } + } + }, + "elementor/update-page-settings": { + "type": "object", + "required": ["post_id", "settings"], + "properties": { + "post_id": { "type": "integer" }, + "settings": { "type": "object", "additionalProperties": true } + } + }, + "elementor/build-composition": { + "type": "object", + "required": ["post_id", "xml_structure"], + "properties": { + "post_id": { "type": "integer" }, + "xml_structure": { "type": "string" }, + "element_config": { "type": "object" }, + "style": { "type": "object", "additionalProperties": { "type": "string" } }, + "classes": { + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "string" } } + }, + "interactions": { + "type": "object", + "additionalProperties": { "type": "array", "items": { "type": "object" } } + }, + "parent_id": { "type": "string" }, + "dry_run": { "type": "boolean" }, + "mode": { "type": "string", "enum": ["append", "replace_children"] } + } + }, + "elementor/create-preview-link": { + "type": "object", + "required": ["post_id"], + "properties": { + "post_id": { "type": "integer" } + } + }, + "elementor/manage-component": { + "type": "object", + "required": ["action"], + "properties": { + "action": { "type": "string", "enum": ["create", "update", "rename", "archive", "publish"] }, + "title": { "type": "string" }, + "source_post_id": { "type": "integer" }, + "element_id": { "type": "string" }, + "xml_structure": { "type": "string" }, + "element_config": { "type": "object" }, + "classes": { "type": "object" }, + "style": { "type": "object" }, + "interactions": { "type": "object" }, + "overridable_props": { "type": "object" }, + "publish_status": { "type": "string", "enum": ["publish", "draft"] }, + "component_id": { "type": "integer" }, + "component_ids": { "type": "array", "items": { "type": "integer" } } + } + }, + "elementor/manage-default-styles": { + "type": "object", + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action", "tag"], + "properties": { + "action": { "type": "string", "enum": ["update", "delete"] }, + "tag": { "type": "string" }, + "css": { "type": "string" }, + "mode": { "type": "string", "enum": ["patch", "replace"] } + } + } + } + } + }, + "elementor/manage-classes": { + "type": "object", + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action"], + "properties": { + "action": { "type": "string", "enum": ["create", "update", "delete"] }, + "id": { "type": "string" }, + "label": { "type": "string" }, + "css": { "type": "string" }, + "mode": { "type": "string", "enum": ["patch", "replace"] } + } + } + } + } + }, + "elementor/reorder-classes": { + "type": "object", + "properties": { + "moves": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "position"], + "properties": { + "id": { "type": "string" }, + "position": { "type": "string", "enum": ["before", "after", "start", "end"] }, + "ref": { "type": "string" } + } + } + }, + "order": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "elementor/manage-global-variable": { + "type": "object", + "required": ["operations"], + "properties": { + "operations": { + "type": "array", + "items": { + "type": "object", + "required": ["action"], + "properties": { + "action": { "type": "string", "enum": ["create", "update", "delete"] }, + "id": { "type": "string" }, + "type": { + "type": "string", + "enum": ["global-color-variable", "global-font-variable", "global-size-variable", "global-custom-size-variable"] + }, + "label": { "type": "string" }, + "value": { "type": "string" } + } + } + } + } + }, + "elementor/create-page": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "post_type": { "type": "string" } + } + } + } +} diff --git a/tests/unit/ElementorDoorCssClassificationTest.php b/tests/unit/ElementorDoorCssClassificationTest.php new file mode 100644 index 00000000..1294bbe6 --- /dev/null +++ b/tests/unit/ElementorDoorCssClassificationTest.php @@ -0,0 +1,283 @@ +assertSame( 1, $in, "{$slug} must be classified exactly once" ); + } + $this->assertSame( array(), array_diff( array_keys( Aura_Worker_Elementor_Door::CSS_PRODUCERS ), array_keys( Aura_Worker_Elementor_Door::WRITE_TABLE ) ) ); + $this->assertSame( array(), array_diff( array_keys( Aura_Worker_Elementor_Door::NO_CSS ), array_keys( Aura_Worker_Elementor_Door::WRITE_TABLE ) ) ); + } + + public function test_the_fixture_covers_every_write(): void { + $this->assertEqualsCanonicalizing( array_keys( Aura_Worker_Elementor_Door::WRITE_TABLE ), array_keys( $this->schemas() ) ); + } + + public function test_a_no_css_entry_cannot_carry_css_beyond_its_exemptions(): void { + foreach ( Aura_Worker_Elementor_Door::NO_CSS as $slug => $entry ) { + $paths = Aura_Worker_Elementor_Door::css_capable_paths( $this->schemas()[ $slug ] ); + $this->assertSame( array(), array_values( array_diff( $paths, $entry['exempt'] ) ), "{$slug} can carry CSS — move it to CSS_PRODUCERS" ); + } + } + + public function test_css_capable_paths_finds_named_and_open_properties(): void { + $schema = array( 'type' => 'object', 'properties' => array( + 'post_id' => array( 'type' => 'integer' ), + 'settings' => array( 'type' => 'object' ), + 'operations' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'style' => array( 'type' => 'string' ), 'label' => array( 'type' => 'string' ) ) ) ), + 'meta' => array( 'type' => 'object', 'additionalProperties' => true ), + ) ); + $this->assertEqualsCanonicalizing( array( 'settings', 'operations[].style', 'meta' ), Aura_Worker_Elementor_Door::css_capable_paths( $schema ) ); + } + + public function test_an_open_root_or_open_array_item_is_css_capable(): void { + $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'additionalProperties' => true ) ) ); + $this->assertSame( array( '(root)' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'additionalProperties' => array( 'type' => 'string' ) ) ) ); + $this->assertSame( + array( 'ops[]' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'ops' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'additionalProperties' => true ) ) ) ) ) + ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'id' => array( 'type' => 'integer' ) ) ) ) ); + } + + public function test_a_live_schema_open_at_the_root_makes_a_no_css_write_conservative(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () { + return array( 'type' => 'object', 'additionalProperties' => true ); + } ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/publish-document', array(), '42' ) ); + } + + public function test_a_live_schema_that_grew_css_makes_a_no_css_write_conservative(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) { + return 'elementor/publish-document' === $slug + ? array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer' ), 'custom_css' => array( 'type' => 'string' ) ) ) + : null; + } ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/publish-document', array( 'post_id' => 42 ), '42' ) ); + } + + public function test_a_css_child_under_a_named_container_surfaces(): void { + $this->assertEqualsCanonicalizing( + array( 'settings', 'settings.css' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'settings' => array( 'type' => 'object', 'properties' => array( 'css' => array( 'type' => 'string' ), 'title' => array( 'type' => 'string' ) ) ) ) ) ) + ); + } + + public function test_opaque_forwarded_strings_are_css_capable(): void { + $this->assertEqualsCanonicalizing( + array( 'content', 'ops[].markup' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( + 'content' => array( 'type' => 'string' ), + 'ops' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'markup' => array( 'type' => 'string' ), 'id' => array( 'type' => 'string' ) ) ) ), + ) ) ) + ); + } + + public function test_a_precise_producer_whose_schema_grew_an_unhandled_path_turns_conservative(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) { + return 'elementor/update-page-settings' === $slug + ? array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer' ), 'settings' => array( 'type' => 'object' ), 'extra_css' => array( 'type' => 'string' ) ) ) + : null; + } ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => 'a{}' ) ), '42' ) + ); + } + + public function test_the_handled_paths_match_the_fixture_schemas(): void { + foreach ( Aura_Worker_Elementor_Door::PRODUCER_HANDLED_PATHS as $slug => $paths ) { + $this->assertEqualsCanonicalizing( $paths, Aura_Worker_Elementor_Door::css_capable_paths( $this->schemas()[ $slug ] ), "{$slug}: handled paths drifted from the 4.3 schema" ); + } + } + + public function test_a_live_schema_with_only_exempt_css_stays_no_css(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) { + return array( 'type' => 'object', 'properties' => array( 'operations' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'css' => array( 'type' => 'string' ) ) ) ) ) ); + } ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-classes', array(), '*' ) ); + } + + /** + * The runtime guard rides `touches_for()`, not just `css_touches_for()` + * (Controller ruling on Task 4): a `design_system` write whose LIVE + * schema grew a CSS-capable path beyond its exemptions must still carry + * its design_system touch(es) — the conservative custom_css touch is + * additive, never a replacement, for a call the door would otherwise + * let through as `design_system:*` alone. + */ + public function test_a_design_system_write_with_a_grown_live_schema_declares_both_design_system_and_custom_css(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () { + return array( 'type' => 'object', 'additionalProperties' => true ); + } ); + $input = array( 'operations' => array( array( 'action' => 'create', 'label' => 'foo', 'css' => 'color:red' ) ) ); + $result = Aura_Worker_Elementor_Door::touches_for( 'elementor/manage-classes', $input ); + $this->assertIsArray( $result ); + $this->assertContains( array( 'type' => 'design_system', 'id' => '*' ), $result ); + $this->assertContains( array( 'type' => 'custom_css', 'id' => '*' ), $result ); + } + + /* ---- final review: fail-closed schema shapes (Task 4 minors) ---- */ + + public function test_a_tuple_items_list_is_css_capable(): void { + $this->assertSame( + array( 'pair{unresolved}' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'pair' => array( 'type' => 'array', 'items' => array( array( 'type' => 'string' ), array( 'type' => 'integer' ) ) ) ) ) ) + ); + } + + public function test_a_combinator_is_css_capable(): void { + foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $k ) { + $this->assertSame( + array( 'value{unresolved}' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'value' => array( $k => array( array( 'type' => 'string' ), array( 'type' => 'integer' ) ) ) ) ) ), + $k + ); + $this->assertSame( array( '(root){unresolved}' ), Aura_Worker_Elementor_Door::css_capable_paths( array( $k => array( array( 'type' => 'object' ) ) ) ), "{$k} at the root" ); + } + } + + public function test_pattern_properties_are_css_capable(): void { + $this->assertSame( array( '(root){unresolved}' ), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'patternProperties' => array( '^x_' => array( 'type' => 'string' ) ) ) ) ); + $this->assertSame( + array( 'meta{unresolved}' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'meta' => array( 'type' => 'object', 'patternProperties' => array( '.*' => array( 'type' => 'string' ) ) ) ) ) ) + ); + } + + public function test_nested_arrays_are_descended(): void { + $this->assertSame( + array( 'grid[][].css' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'grid' => array( 'type' => 'array', 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'css' => array( 'type' => 'string' ), 'n' => array( 'type' => 'integer' ) ) ) ) ) ) ) ) + ); + $this->assertSame( + array( 'grid[][]' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'grid' => array( 'type' => 'array', 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'additionalProperties' => true ) ) ) ) ) ) + ); + } + + /** Controller ruling (final review M2): a bare {type: object} stays CLOSED. */ + public function test_a_bare_object_stays_closed(): void { + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'interactions' => array( 'type' => 'array', 'items' => array( 'type' => 'object' ) ), 'meta' => array( 'type' => 'object' ) ) ) ) ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object' ) ) ); + } + + /** Task 4 minor: the precise-producer guard, on manage-elements. */ + public function test_manage_elements_whose_schema_grew_extra_css_turns_conservative(): void { + $schema = $this->schemas()['elementor/manage-elements']; + $this->assertArrayHasKey( 'operations', $schema['properties'] ); + $schema['properties']['operations']['items']['properties']['extra_css'] = array( 'type' => 'string' ); + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) use ( $schema ) { + return 'elementor/manage-elements' === $slug ? $schema : null; + } ); + $in = array( 'post_id' => 42, 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-elements', $in, '42' ) ); + } + + /** Task 4 minor: the 4.3 schema itself leaves manage-elements precise. */ + public function test_manage_elements_with_its_fixture_schema_stays_precise(): void { + $schema = $this->schemas()['elementor/manage-elements']; + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () use ( $schema ) { + return $schema; + } ); + $in = array( 'post_id' => 42, 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-elements', $in, '42' ) ); + } + + /** Task 4 minor: an unreadable (null) live schema leaves a precise producer precise. */ + public function test_a_null_live_schema_leaves_a_precise_producer_precise(): void { + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function () { + return null; + } ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}' ) ), '42' ) + ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-elements', array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'x' ) ) ), '42' ) + ); + } + + /** + * Task 4 minor: the PRODUCTION read (no seam) goes through + * wp_get_ability()->get_input_schema(). An ability whose registered + * schema is open makes a NO_CSS slug conservative. + */ + public function test_the_production_schema_read_uses_the_registered_ability(): void { + sa_register_ability( 'elementor/publish-document', array( + 'execute_callback' => function () { + return true; + }, + 'input_schema' => array( 'type' => 'object', 'additionalProperties' => true ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/publish-document', array( 'post_id' => 42 ), '42' ) ); + // And a closed one does not. + sa_register_ability( 'elementor/create-preview-link', array( + 'execute_callback' => function () { + return true; + }, + 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array( 'type' => 'integer' ) ) ), + ) ); + $this->assertSame( array(), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/create-preview-link', array( 'post_id' => 42 ), '42' ) ); + } + + /** + * Codex r1 on #135: an unresolved shape at a path that is ALREADY handled + * or exempt must not be satisfied by that entry — it surfaces as a + * distinct `{unresolved}` path no list names. + */ + public function test_an_unresolved_shape_on_a_handled_path_is_not_handled(): void { + $this->assertEqualsCanonicalizing( + array( 'settings', 'settings{unresolved}' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'settings' => array( 'oneOf' => array( array( 'type' => 'object' ), array( 'type' => 'object', 'properties' => array( 'extra_css' => array( 'type' => 'string' ) ) ) ) ) ) ) ) + ); + $this->assertEqualsCanonicalizing( + array( 'ops[]{unresolved}' ), + Aura_Worker_Elementor_Door::css_capable_paths( array( 'type' => 'object', 'properties' => array( 'ops' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'patternProperties' => array( '.*' => array( 'type' => 'string' ) ) ) ) ) ) ) + ); + } + + public function test_a_producer_whose_handled_settings_became_one_of_is_conservative(): void { + $schema = $this->schemas()['elementor/update-page-settings']; + $schema['properties']['settings'] = array( 'oneOf' => array( array( 'type' => 'object' ), array( 'type' => 'object', 'properties' => array( 'extra_css' => array( 'type' => 'string' ) ) ) ) ); + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) use ( $schema ) { + return 'elementor/update-page-settings' === $slug ? $schema : null; + } ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + Aura_Worker_Elementor_Door::css_touches_for( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}' ) ), '42' ) + ); + } + + public function test_a_no_css_slug_whose_exempt_path_became_any_of_is_conservative(): void { + $schema = $this->schemas()['elementor/manage-classes']; + $this->assertArrayHasKey( 'css', $schema['properties']['operations']['items']['properties'] ); + $schema['properties']['operations']['items']['properties']['css'] = array( 'anyOf' => array( array( 'type' => 'string' ), array( 'type' => 'object' ) ) ); + Aura_Worker_Elementor_Door::_set_schema_reader_for_tests( function ( $slug ) use ( $schema ) { + return 'elementor/manage-classes' === $slug ? $schema : null; + } ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '*' ) ), Aura_Worker_Elementor_Door::css_touches_for( 'elementor/manage-classes', array(), '*' ) ); + } +} diff --git a/tests/unit/ElementorDoorCssTouchesTest.php b/tests/unit/ElementorDoorCssTouchesTest.php new file mode 100644 index 00000000..c57e19d8 --- /dev/null +++ b/tests/unit/ElementorDoorCssTouchesTest.php @@ -0,0 +1,211 @@ +assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'body{color:red}' ) ) ) + ); + } + + public function test_page_settings_with_css_and_a_title_is_precise_but_mixed(): void { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}', 'post_title' => 'x' ) ) ) + ); + } + + public function test_css_value_shapes(): void { + foreach ( array( null, '', ' ', "\n\t" ) as $clear ) { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => $clear ) ) ), 'clearing is not CSS: ' . var_export( $clear, true ) ); + } + foreach ( array( array( 'color:red' ), 123, true ) as $odd ) { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => $odd ) ) ), + 'unknown shape is conservative: ' . var_export( $odd, true ) + ); + } + } + + public function test_an_extra_top_level_field_makes_the_call_mixed(): void { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}' ), 'status' => 'publish' ) ) + ); + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), + $this->t( 'elementor/manage-elements', array( 'post_id' => 42, 'operations' => array( array( 'action' => 'update', 'element_id' => 'a', 'style' => 'x' ) ), 'publish' => true ) ) + ); + } + + public function test_page_settings_without_css_declares_nothing(): void { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'post_title' => 'x' ) ) ) ); + } + + public function test_manage_elements_style_only_batch_is_css_only(): void { + $in = array( 'operations' => array( + array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ), + array( 'action' => 'update', 'element_id' => 'b2', 'settings' => array( 'custom_css' => 'selector{}' ), 'style_apply_mode' => 'patch' ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_css_plus_a_delete_is_mixed(): void { + $in = array( 'operations' => array( + array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ), + array( 'action' => 'delete', 'element_id' => 'b2' ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_css_plus_a_title_setting_is_mixed(): void { + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'settings' => array( 'custom_css' => 'x{}', 'title' => 'Hi' ) ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42', 'precise' => true ) ), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_without_css_declares_nothing(): void { + $in = array( 'operations' => array( array( 'action' => 'move', 'element_id' => 'a1', 'new_parent_id' => 'document' ), array( 'action' => 'update', 'element_id' => 'b', 'style' => ' ' ) ) ); + $this->assertSame( array(), $this->t( 'elementor/manage-elements', $in ) ); + } + + public function test_manage_elements_with_a_non_array_operations_is_conservative(): void { + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', array( 'operations' => 'garbage' ) ) ); + } + + public function test_build_composition_is_a_conservative_producer(): void { + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/build-composition', array( 'post_id' => 42, 'xml_structure' => '' ) ) ); + } + + public function test_manage_component_is_a_conservative_site_wide_producer(): void { + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '*' ) ), $this->t( 'elementor/manage-component', array( 'action' => 'create' ), '*' ) ); + } + + public function test_design_system_and_other_writes_declare_no_css(): void { + foreach ( array( 'elementor/manage-classes', 'elementor/manage-default-styles', 'elementor/reorder-classes', 'elementor/manage-global-variable', 'elementor/publish-document', 'elementor/create-preview-link', 'elementor/create-page' ) as $slug ) { + $this->assertSame( array(), $this->t( $slug, array( 'operations' => array( array( 'css' => 'a{}' ) ) ) ), $slug ); + } + } + + /* ---- final review: fail-closed value shapes (M1, Task 3 minors) ---- */ + + /** + * css_value(): empty array is a clearing; every other non-string, + * non-null value is CSS of unknown shape — false, 0 and 0.0 included. + */ + public function test_non_string_css_values_are_unknown_except_an_empty_array(): void { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => array() ) ) ), 'array() clears' ); + foreach ( array( 'false' => false, 'int 0' => 0, 'float 0.0' => 0.0, 'float 1.5' => 1.5, 'stdClass' => new stdClass() ) as $label => $odd ) { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + $this->t( 'elementor/update-page-settings', array( 'settings' => array( 'custom_css' => $odd ) ) ), + 'unknown shape is conservative: ' . $label + ); + } + } + + /** M1: `settings` that is not an array is CSS of unknown shape, never "no CSS". */ + public function test_page_settings_that_are_not_an_array_are_unknown_css(): void { + foreach ( array( 'stdClass' => (object) array( 'custom_css' => 'body{}' ), 'string' => 'custom_css=body{}', 'int' => 7 ) as $label => $settings ) { + $this->assertSame( + array( array( 'type' => 'custom_css', 'id' => '42' ) ), + $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => $settings ) ), + $label + ); + } + // Null settings still declare nothing (nothing is being written). + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => null ) ) ); + } + + /** M1 / Task 3 minor: an op's non-array `settings` is unknown CSS and never css_only. */ + public function test_manage_elements_op_settings_that_are_not_an_array_are_unknown_css(): void { + foreach ( array( 'stdClass' => (object) array( 'custom_css' => 'x{}' ), 'string' => 'custom_css' ) as $label => $settings ) { + $in = array( 'operations' => array( + array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red' ), + array( 'action' => 'update', 'element_id' => 'b2', 'settings' => $settings ), + ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', $in ), $label ); + } + } + + /* ---- final review: the CSS touch as touches_for() returns it (Task 3 minor) ---- */ + + public function test_touches_for_appends_a_precise_css_touch_after_the_page(): void { + $GLOBALS['_posts'][42] = (object) array( 'ID' => 42, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => '' ); + $this->assertSame( + array( + array( 'type' => 'page', 'id' => '42' ), + array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ), + ), + Aura_Worker_Elementor_Door::touches_for( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'body{}' ) ) ) + ); + } + + public function test_touches_for_a_component_write_is_design_system_plus_wildcard_css(): void { + $this->assertSame( + array( + array( 'type' => 'design_system', 'id' => '*' ), + array( 'type' => 'custom_css', 'id' => '*' ), + ), + Aura_Worker_Elementor_Door::touches_for( 'elementor/manage-component', array( 'action' => 'create' ) ) + ); + } + + public function test_touches_for_a_no_css_design_system_write_carries_no_css_touch(): void { + $touches = Aura_Worker_Elementor_Door::touches_for( 'elementor/manage-classes', array( 'operations' => array( array( 'action' => 'create', 'label' => 'foo', 'css' => 'color:red' ) ) ) ); + $this->assertIsArray( $touches ); + $this->assertContains( array( 'type' => 'design_system', 'id' => '*' ), $touches ); + foreach ( $touches as $t ) { + $this->assertNotSame( 'custom_css', $t['type'] ); + } + } + + /* ---- Codex r2 on #135: CSS-named keys in an open settings container ---- */ + + public function test_a_css_named_page_setting_is_css_of_unknown_shape(): void { + $conservative = array( array( 'type' => 'custom_css', 'id' => '42' ) ); + $this->assertSame( $conservative, $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'extra_css' => 'a{}' ) ) ) ); + $this->assertSame( $conservative, $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'custom_css' => 'a{}', 'extra_css' => 'b{}' ) ) ), 'no precise evidence' ); + $this->assertSame( $conservative, $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => array( 'Page_CSS' => array( 'a' => 'b' ) ) ) ), 'case-insensitive, any non-empty shape' ); + } + + public function test_css_named_exceptions_and_clearing_declare_nothing(): void { + foreach ( array( array( '_css_classes' => 'x' ), array( 'css_filters_blur' => 3 ), array( 'css_filters_css_filter' => 'custom' ), array( 'extra_css' => ' ' ), array( 'extra_css' => null ), array( 'button_style' => 'a{}' ) ) as $settings ) { + $this->assertSame( array(), $this->t( 'elementor/update-page-settings', array( 'post_id' => 42, 'settings' => $settings ) ), wp_json_encode( $settings ) ); + } + } + + public function test_a_css_named_op_setting_is_css_of_unknown_shape(): void { + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'settings' => array( 'my_css' => 'a{}' ) ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', $in ) ); + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'style' => 'color:red', 'settings' => array( 'my_css' => 'a{}' ) ) ) ); + $this->assertSame( array( array( 'type' => 'custom_css', 'id' => '42' ) ), $this->t( 'elementor/manage-elements', $in ), 'beside precise CSS' ); + $in = array( 'operations' => array( array( 'action' => 'update', 'element_id' => 'a1', 'settings' => array( '_css_classes' => 'x', 'css_filters_blur' => 2 ) ) ) ); + $this->assertSame( array(), $this->t( 'elementor/manage-elements', $in ), 'exceptions' ); + } +} diff --git a/tests/unit/RulesCustomCssMatchTest.php b/tests/unit/RulesCustomCssMatchTest.php new file mode 100644 index 00000000..93601bd0 --- /dev/null +++ b/tests/unit/RulesCustomCssMatchTest.php @@ -0,0 +1,186 @@ + 'rule/css', + 'effect' => $effect, + 'target' => array( 'type' => 'custom_css', 'id' => $id ), + 'reason' => 'no agent CSS', + 'until' => null, + ); + } + + private function css( string $id, array $extra = array() ): array { + return array_merge( array( 'type' => 'custom_css', 'id' => $id ), $extra ); + } + + private function exact( string $id ): array { + return $this->css( $id, array( 'precise' => true, 'css_only' => true ) ); + } + + public function test_block_with_an_id_matches_that_id_and_the_create_wildcard_only(): void { + $rule = $this->rule( 'block', '42' ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ) ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( '*' ) ), array( $rule ) ), 'create-time CSS may land anywhere' ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '43' ) ), array( $rule ) ) ); + } + + public function test_an_id_less_or_star_rule_matches_any_css_touch(): void { + foreach ( array( null, '*' ) as $id ) { + $rule = $this->rule( 'warn', $id ); + foreach ( array( '42', '*', '7' ) as $t ) { + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( $t ) ), array( $rule ) ), "id-less rule missed custom_css:{$t}" ); + } + } + } + + public function test_an_empty_string_id_never_becomes_site_wide(): void { + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $this->rule( 'block', '' ) ) ) ); + } + + public function test_a_css_rule_does_not_match_a_plain_page_edit(): void { + // Additive: the page edit declares page:42 only; a CSS rule must not bite it. + $this->assertNull( Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42' ) ), array( $this->rule( 'block', '42' ) ) ) ); + } + + public function test_a_page_rule_still_matches_a_css_write_through_its_page_touch(): void { + $page = array( 'key' => 'rule/p', 'effect' => 'block', 'target' => array( 'type' => 'page', 'id' => '42' ), 'reason' => 'x', 'until' => null ); + $hit = Aura_Worker_Rules::match( array( $this->css( '42' ), array( 'type' => 'page', 'id' => '42' ) ), array( $page ) ); + $this->assertSame( 'rule/p', $hit['key'] ); + } + + public function test_unknown_matches_css_block_and_warn_but_never_css_allow(): void { + $unknown = array( array( 'type' => 'unknown', 'id' => '*' ) ); + $this->assertNotNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'block', '42' ) ) ) ); + $this->assertNotNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'warn' ) ) ) ); + $this->assertNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'allow', '42' ) ) ) ); + $this->assertNull( Aura_Worker_Rules::match( $unknown, array( $this->rule( 'allow' ) ) ) ); + } + + public function test_allow_with_an_id_needs_a_precise_css_only_touch_on_that_id(): void { + $rule = $this->rule( 'allow', '42' ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ), 'conservative touch' ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42', array( 'precise' => true ) ) ), array( $rule ) ), 'mixed call' ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->exact( '43' ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '*', array( 'precise' => true, 'css_only' => true ) ) ), array( $rule ) ), '* is never precise' ); + } + + public function test_id_less_allow_admits_a_precise_css_only_touch_on_any_concrete_id(): void { + foreach ( array( null, '*' ) as $id ) { + $rule = $this->rule( 'allow', $id ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '*', array( 'precise' => true, 'css_only' => true ) ) ), array( $rule ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ) ); + } + } + + public function test_evidence_fields_count_only_as_literal_true(): void { + $rule = $this->rule( 'allow', '42' ); + foreach ( array( 'true', 1, '1', null, false, array( true ) ) as $bad ) { + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42', array( 'precise' => $bad, 'css_only' => true ) ) ), array( $rule ) ), 'precise=' . var_export( $bad, true ) ); + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( '42', array( 'precise' => true, 'css_only' => $bad ) ) ), array( $rule ) ), 'css_only=' . var_export( $bad, true ) ); + } + // Fields on a non-custom_css touch are ignored: a precise PAGE touch is just page:42. + $page_allow = array( 'key' => 'rule/pa', 'effect' => 'allow', 'target' => array( 'type' => 'page', 'id' => '42' ), 'reason' => 'x', 'until' => null ); + $this->assertNotNull( Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42', 'precise' => true, 'css_only' => true ) ), array( $page_allow ) ) ); + $this->assertNull( Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42', 'precise' => true, 'css_only' => true ) ), array( $rule ) ) ); + } + + public function test_a_non_digit_id_is_never_precise(): void { + $this->assertNull( Aura_Worker_Rules::match( array( $this->css( 'abc', array( 'precise' => true, 'css_only' => true ) ) ), array( $this->rule( 'allow' ) ) ) ); + } + + public function test_block_still_outranks_a_css_allow(): void { + $hit = Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $this->rule( 'allow', '42' ), $this->rule( 'block' ) ) ); + $this->assertSame( 'block', $hit['effect'] ); + } + + public function test_a_site_freeze_still_catches_a_css_write(): void { + $freeze = array( 'key' => 'rule/freeze', 'effect' => 'block', 'target' => array( 'type' => 'site', 'id' => null ), 'reason' => 'x', 'until' => null ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->exact( '42' ) ), array( $freeze ) ) ); + } + + // Review round 1, Important #1 (controller ruling): an allow admits a + // call only when EVERY custom_css touch it declared is precise — one + // exact touch riding alongside a conservative, create-time or unknown + // declaration must not buy the whole call an allow. + + public function test_allow_never_admits_a_set_that_also_carries_the_create_wildcard(): void { + $touches = array( $this->exact( '42' ), $this->css( '*' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), 'id rule' ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), 'id-less rule' ); + } + + public function test_allow_never_admits_a_set_that_also_carries_a_conservative_touch_on_another_id(): void { + $touches = array( $this->exact( '42' ), $this->css( '43' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), 'id rule' ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), 'id-less rule' ); + } + + public function test_allow_never_admits_a_set_that_also_carries_unknown(): void { + $touches = array( $this->exact( '42' ), array( 'type' => 'unknown', 'id' => '*' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), 'id rule' ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), 'id-less rule' ); + } + + public function test_allow_still_admits_a_precise_css_touch_alongside_its_own_page_and_post_touches(): void { + $touches = array( $this->exact( '42' ), array( 'type' => 'page', 'id' => '42' ), array( 'type' => 'post', 'id' => '42' ) ); + $this->assertNotNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ) ); + } + + // Review round 1, Minor #3. + + public function test_a_rule_target_with_no_id_key_at_all_behaves_as_id_less(): void { + $rule = array( + 'key' => 'rule/css', + 'effect' => 'warn', + 'target' => array( 'type' => 'custom_css' ), // no 'id' key at all + 'reason' => 'x', + 'until' => null, + ); + $this->assertNotNull( Aura_Worker_Rules::match( array( $this->css( '42' ) ), array( $rule ) ) ); + } + + public function test_enforceable_match_treats_a_css_allow_winner_as_no_rule(): void { + $rule = $this->rule( 'allow', '42' ); + $this->assertNull( Aura_Worker_Rules::enforceable_match( array( $this->exact( '42' ) ), array( $rule ) ) ); + } + + /** + * Codex r1 on #135: exactness is per id and needs EVERY touch on that id. + * A conservative twin on the same id must not be erased by an exact one, + * in either order. + */ + public function test_an_exact_and_a_conservative_touch_on_the_same_id_is_not_exact(): void { + foreach ( array( array( $this->exact( '42' ), $this->css( '42' ) ), array( $this->css( '42' ), $this->exact( '42' ) ) ) as $i => $touches ) { + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ), "id rule, order {$i}" ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) ), "id-less rule, order {$i}" ); + $this->assertSame( 'block', Aura_Worker_Rules::match( $touches, array( $this->rule( 'block', '42' ) ) )['effect'], "block, order {$i}" ); + } + // Evidence that is not literal true counts as inexact too. + $touches = array( $this->exact( '42' ), $this->css( '42', array( 'precise' => true, 'css_only' => 'yes' ) ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ) ); + } + + public function test_two_exact_touches_on_the_same_id_stay_exact(): void { + $touches = array( $this->exact( '42' ), $this->exact( '42' ) ); + $this->assertSame( 'allow', Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) )['effect'] ); + $this->assertSame( 'allow', Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow' ) ) )['effect'] ); + } + + public function test_exact_on_one_id_and_conservative_on_another_is_still_refused(): void { + $touches = array( $this->exact( '42' ), $this->css( '43' ) ); + $this->assertNull( Aura_Worker_Rules::match( $touches, array( $this->rule( 'allow', '42' ) ) ) ); + } +} diff --git a/tests/unit/RulesOldForkWideningTest.php b/tests/unit/RulesOldForkWideningTest.php new file mode 100644 index 00000000..1c8f2db0 --- /dev/null +++ b/tests/unit/RulesOldForkWideningTest.php @@ -0,0 +1,136 @@ + 'x.y', + 'seq' => 1, + 'issued_at' => '2026-08-21T00:00:00Z', + 'received_at' => time(), + 'rules' => $rules, + ); + } + + private function css_rule( string $effect, ?string $id ): array { + return array( 'key' => 'rule/css', 'effect' => $effect, 'target' => array( 'type' => 'custom_css', 'id' => $id ), 'reason' => 'no CSS', 'until' => null ); + } + + public function test_fork_state_follows_the_loaded_version(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( false ); + $this->assertSame( 'absent', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', true ); + $this->assertSame( 'precise', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( 'not-a-version', true ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state(), 'unreadable counts as old' ); + } + + /** + * Final review I1: `precise` needs the capability, not just the number. + * A 1.37.0 that ships no Elementor_MCP_Rules::css_touches() is widened; + * so is an older fork that somehow has it. + */ + public function test_precise_needs_css_touches_as_well_as_the_version(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', false ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1', true ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.38.0', true ); + $this->assertSame( 'precise', Aura_Worker_Rules::fork_css_state() ); + } + + public function test_absent_capability_derives_from_the_loaded_code(): void { + // null = ask method_exists(); this process defines no Elementor_MCP_Rules. + $this->assertFalse( class_exists( 'Elementor_MCP_Rules', false ) ); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0' ); + $this->assertSame( 'widened', Aura_Worker_Rules::fork_css_state() ); + } + + public function test_a_1_37_fork_without_css_touches_is_still_widened(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', false ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertSame( 'block', $v['effect'] ); + } + + public function test_an_old_fork_page_edit_is_blocked_by_a_css_block(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ), array( 'type' => 'post', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertSame( 'block', $v['effect'] ); + } + + public function test_an_old_fork_site_write_meets_an_id_rule_through_the_wildcard(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->css_rule( 'warn', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'site', 'id' => '*' ) ), 'elementor-mcp/build-page' ); + $this->assertSame( 'warn', $v['effect'] ); + } + + public function test_a_current_fork_is_not_widened(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', true ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertNull( $v['effect'] ); + } + + public function test_only_the_forks_own_abilities_are_widened(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $this->store( array( $this->css_rule( 'block', '42' ) ) ); + foreach ( array( 'update_page_block', 'elementor/manage-elements', 'content__update_post' ) as $tool ) { + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), $tool ); + $this->assertNull( $v['effect'], "{$tool} must not be widened" ); + } + } + + /** + * Non-vacuous (Task 2 review minor 1): enforce() drops every allow + * winner anyway, so this asks match() itself — the matcher the door + * uses for allow. The widened touch set is the one enforce() builds. + */ + public function test_widening_never_speaks_for_allow(): void { + Aura_Worker_Rules::_set_fork_version_for_tests( '1.36.1' ); + $widen = new ReflectionMethod( 'Aura_Worker_Rules', 'widen_for_old_fork' ); + if ( PHP_VERSION_ID < 80100 ) { + $widen->setAccessible( true ); // a no-op since 8.1, deprecated in 8.5 + } + $widened = $widen->invoke( null, array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertContains( array( 'type' => 'custom_css', 'id' => '42' ), $widened, 'the old fork IS widened' ); + + $allow = array( $this->css_rule( 'allow', '42' ) ); + // Control: a precise CSS-only touch does satisfy this allow… + $this->assertSame( 'allow', Aura_Worker_Rules::match( array( array( 'type' => 'page', 'id' => '42' ), array( 'type' => 'custom_css', 'id' => '42', 'precise' => true, 'css_only' => true ) ), $allow )['effect'] ); + // …the widened touch never does. + $this->assertNull( Aura_Worker_Rules::match( $widened, $allow ) ); + + // Paired with a warn: the widened touch reaches the warn, never the allow. + $this->store( array( $this->css_rule( 'allow', '42' ), array( 'key' => 'rule/css-warn', 'effect' => 'warn', 'target' => array( 'type' => 'custom_css', 'id' => '42' ), 'reason' => 'careful', 'until' => null ) ) ); + $v = Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' ); + $this->assertSame( 'warn', $v['effect'] ); + $this->assertSame( 'rule/css-warn', $v['rule']['key'] ); + Aura_Worker_Rules::reset_records(); + Aura_Worker_Rules::_set_fork_version_for_tests( '1.37.0', true ); + $this->assertNull( Aura_Worker_Rules::enforce( array( array( 'type' => 'page', 'id' => '42' ) ), 'elementor-mcp/update-element' )['effect'], 'a current fork declares nothing here' ); + } +} diff --git a/tests/unit/StatusCssRulesTest.php b/tests/unit/StatusCssRulesTest.php new file mode 100644 index 00000000..6eeb1133 --- /dev/null +++ b/tests/unit/StatusCssRulesTest.php @@ -0,0 +1,39 @@ +get_status( new WP_REST_Request( 'GET', '/aura/v1/status' ) )->get_data(); + } + + public function test_css_rules_is_an_object_naming_the_fork_state(): void { + $cases = array( + array( false, null, 'absent' ), + array( '1.36.1', false, 'widened' ), + array( '1.37.0', true, 'precise' ), + // A version number is not a capability (final review I1). + array( '1.37.0', false, 'widened' ), + array( '1.36.1', true, 'widened' ), + ); + foreach ( $cases as $case ) { + list( $v, $cap, $want ) = $case; + Aura_Worker_Rules::_set_fork_version_for_tests( $v, $cap ); + $body = $this->body(); + $this->assertIsObject( $body['css_rules'] ); + $this->assertSame( '{"fork":"' . $want . '"}', wp_json_encode( $body['css_rules'] ) ); + } + } +}