validate()`: `system.errors.display` is a bool against a * `type: int` rule, and Grav's `list` validator rejects complete * security/backups/scheduler list items. These tests pin both halves: required * /invalid submitted values ARE rejected, and editing an unrelated field does * NOT trip those stock-config landmines. * * Requires a booted Grav (Validation translates messages), so it lives in the * integration group. */ #[Group('integration')] class BlueprintValidationTest extends TestCase { private object $controller; private \ReflectionMethod $validate; protected function setUp(): void { parent::setUp(); // Boot the real Grav framework via the shared Codeception fixture, the // same way the other integration tests do — Validation needs the // language service to translate messages. $grav = Fixtures::get('grav'); $grav(); $this->controller = (new \ReflectionClass(ConfigController::class))->newInstanceWithoutConstructor(); $this->validate = (new \ReflectionClass(AbstractApiController::class))->getMethod('validateChangedFields'); } private function blueprint(array $items): Blueprint { $bp = new Blueprint('test', $items); $bp->init(); return $bp; } /** @return string[] field names that failed, empty if validation passed */ private function failingFields(array $changes, ?Blueprint $blueprint): array { try { $this->validate->invoke($this->controller, $changes, $blueprint); return []; } catch (ValidationException $e) { return array_map(static fn(array $err) => $err['field'], $e->getValidationErrors()); } } #[Test] public function required_field_submitted_empty_is_rejected(): void { $bp = $this->blueprint(['form' => ['fields' => [ 'api_key' => ['type' => 'text', 'label' => 'API Key', 'validate' => ['required' => true]], ]]]); $this->assertSame(['api_key'], $this->failingFields(['api_key' => ''], $bp)); $this->assertSame([], $this->failingFields(['api_key' => 'abc'], $bp)); } #[Test] public function untouched_required_field_does_not_block_unrelated_edit(): void { $bp = $this->blueprint(['form' => ['fields' => [ 'api_key' => ['type' => 'text', 'validate' => ['required' => true]], 'timeout' => ['type' => 'number', 'validate' => ['type' => 'int', 'min' => 1, 'max' => 60]], ]]]); // api_key is required but not part of this change — must not be flagged. $this->assertSame([], $this->failingFields(['timeout' => 30], $bp)); $this->assertSame(['timeout'], $this->failingFields(['timeout' => 999], $bp)); } #[Test] public function int_typed_field_accepts_boolean_via_coercion(): void { // Mirrors system.errors.display: declared type:int, but Grav's runtime // accepts bool (true === 1). Both must validate. $bp = $this->blueprint(['form' => ['fields' => [ 'errors.display' => ['type' => 'select', 'validate' => ['type' => 'int']], ]]]); $this->assertSame([], $this->failingFields(['errors' => ['display' => 1]], $bp)); $this->assertSame([], $this->failingFields(['errors' => ['display' => true]], $bp)); $this->assertSame([], $this->failingFields(['errors' => ['display' => false]], $bp)); } #[Test] public function real_system_blueprint_does_not_false_positive_on_unrelated_edit(): void { $system = (new Blueprints('blueprints://config'))->get('system'); // Stock system config fails a whole-object validate on errors.display; // a delta that doesn't touch it must still save cleanly. $this->assertSame([], $this->failingFields(['timezone' => 'UTC'], $system)); $this->assertSame([], $this->failingFields(['errors' => ['display' => true]], $system)); } #[Test] public function real_security_blueprint_does_not_trip_list_validation_bug(): void { $security = (new Blueprints('blueprints://config'))->get('security'); // security.twig_sandbox.allowed_methods is a list whose per-item // required `.class` field trips a core validation bug on a whole-object // validate. Editing an unrelated scalar must not surface it. $this->assertSame([], $this->failingFields(['xss_enabled' => true], $security)); } #[Test] public function real_account_blueprint_validates_submitted_fields(): void { $account = (new Blueprints('blueprints://user'))->get('account'); $this->assertSame(['email'], $this->failingFields(['email' => 'not-an-email'], $account)); $this->assertSame([], $this->failingFields(['email' => 'joe@example.com'], $account)); $this->assertSame(['fullname'], $this->failingFields(['fullname' => ''], $account)); // Dynamic data-options@ select must not false-positive (options unresolved). $this->assertSame([], $this->failingFields(['language' => 'fr'], $account)); } /** * GHSA-5wc5-7v9g-f7v6 / CVE-2026-11982 regression: the partial-validation * path must run the XSS safety check, not just type/required validation. * * A non-superadmin page editor previously stored an event-handler payload * in page Markdown through PATCH /pages because validateChangedFields() * called Validation::validate() but never Validation::checkSafety() — the * method that invokes Security::detectXss(). The full blueprint validator * (classic admin) runs checkSafety() per field; this path now matches it. */ #[Test] public function stored_xss_payload_in_content_is_rejected(): void { // Mirrors the page blueprint's content field (type markdown, validated // as textarea), which is the field the advisory's PoC abused. $bp = $this->blueprint(['form' => ['fields' => [ 'content' => ['type' => 'markdown', 'label' => 'Content', 'validate' => ['type' => 'textarea']], ]]]); // The advisory's payload: an unquoted on* event handler in raw HTML. $payload = "### XSS PoC\n\n"; $this->assertSame(['content'], $this->failingFields(['content' => $payload], $bp)); // Benign Markdown must still save cleanly — the gate only blocks XSS. $this->assertSame([], $this->failingFields(['content' => "### Hello\nJust *normal* content.\n"], $bp)); } #[Test] public function field_opting_out_of_xss_check_still_allows_html(): void { // A field that explicitly sets `xss_check: false` must behave exactly // like the classic admin, which skips checkSafety() for it. This keeps // the fix from over-blocking fields a publisher is trusted to author. $bp = $this->blueprint(['form' => ['fields' => [ 'content' => ['type' => 'markdown', 'xss_check' => false, 'validate' => ['type' => 'textarea']], ]]]); $this->assertSame([], $this->failingFields(['content' => ''], $bp)); } }