Post IDs Divi's registry was asked for. */ private array $registryCalls = []; protected function setUp(): void { parent::setUp(); Functions\when('get_post_type_object')->justReturn( (object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']] ); Functions\when('current_user_can')->justReturn(true); } private function schema(): ContentSchema { $schema = Mockery::mock(ContentSchema::class); $schema->allows('isManaged')->andReturn(true); $schema->allows('fields')->with('page')->andReturn([ 'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE), 'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE), 'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE), 'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE), '_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE), 'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN), 'gefunden' => new FieldDefinition('gefunden', 'gefunden', 'unknown', 'sample', FieldDefinition::UNKNOWN), 'aktiv' => new FieldDefinition('aktiv', 'Aktiv', 'boolean', 'pods', FieldDefinition::WRITABLE), 'kennwort' => new FieldDefinition('kennwort', 'Kennwort', 'password', 'pods', FieldDefinition::WRITABLE), ]); return $schema; } /** * @param list $deniedRoles * @param array|null $registry */ private function ability( array $deniedRoles = [], ?string $storage = 'meta', ?array $registry = null, ?DiviPageLoader $loader = null ): DiviDynamicSourcesAbility { $registry ??= DiviFixtures::dynamicContentRegistry(); return new DiviDynamicSourcesAbility( $this->schema(), new DynamicContentMap(function (int $id) use ($registry): array { $this->registryCalls[] = $id; return $registry; }), LibraryFixtures::roles($deniedRoles), static fn (string $postType): int => 831, static fn (string $postType): ?string => $storage, $loader ); } /** * Ruling X6: the ability with a loader over post 7 – s0.r0.c0 runs a term loop, s0.r0.c1 none – and a * registry with one post and one term loop option (Divi 5.15.1 groups and fields). */ private function withLoopPage(): DiviDynamicSourcesAbility { $loop = ['module' => ['advanced' => ['loop' => ['desktop' => ['value' => [ 'enable' => 'on', 'queryType' => 'post_taxonomies', ]]]]]]; $text = ''; $column = static fn (array $attrs): string => '' . $text . ''; DiviFixtures::givenPost( '' . $column($loop) . $column([]) . '' ); $field = static fn (string $type): array => ['label' => 'x', 'type' => $type, 'default' => '']; $common = ['before' => $field('text'), 'after' => $field('text'), 'loop_position' => $field('text')]; $registry = DiviFixtures::dynamicContentRegistry() + [ 'loop_post_date' => ['label' => 'Datum', 'group' => 'Loop', 'fields' => $common + [ 'date_format' => $field('select'), ]], 'loop_term_name' => ['label' => 'Name', 'group' => 'Loop Terms', 'fields' => $common], ]; return $this->ability([], 'meta', $registry, new DiviPageLoader(DiviFixtures::schema())); } /** Ruling X6: post_id + node add the loop sources of the node's loop kind, read from that entry. */ public function testANodeInALoopAddsTheLoopSourcesOfItsKind(): void { $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c0.m0']); self::assertSame(7, $result['sample_post_id'], 'the entry itself, not a sample'); self::assertSame([7], array_values(array_unique($this->registryCalls))); self::assertSame( ['node' => 's0.r0.c0.m0', 'query_type' => 'post_taxonomies', 'label' => 'Terme'], $result['loop'] ); // The fixture registry also has the meta key option of each kind: only the term one fits, no short form. self::assertSame( ['loop_term_name', 'loop_term_meta_key_manual_custom_field'], array_column($result['loop_sources'], 'name') ); self::assertSame('{{loop:term_name}}', $result['loop_sources'][0]['placeholder']); self::assertNull($result['loop_sources'][1]['placeholder']); self::assertEquals( ['before' => 'richtext', 'after' => 'richtext', 'loop_position' => 'text'], (array) $result['loop_sources'][0]['settings'] ); self::assertSame([], array_filter( array_column($result['sources'], 'name'), static fn (string $name): bool => str_starts_with($name, 'loop_') )); } public function testANodeOutsideALoopGetsNoLoopSources(): void { $result = $this->withLoopPage()->execute(['post_type' => 'page', 'post_id' => 7, 'node' => 's0.r0.c1.m0']); self::assertSame(['node' => 's0.r0.c1.m0', 'query_type' => null, 'label' => null], $result['loop']); self::assertSame([], $result['loop_sources']); self::assertStringContainsString('kein Schleifen-Kontext', implode(' ', $result['notes'])); } public function testWithoutANodeTheAnswerHasNoLoopPart(): void { $result = $this->ability()->execute(['post_type' => 'page']); self::assertArrayNotHasKey('loop', $result); self::assertArrayNotHasKey('loop_sources', $result); } public function testPostIdAndNodeComeTogetherAndNeedTheEntry(): void { $ability = $this->withLoopPage(); foreach ([['post_id' => 7], ['node' => 's0']] as $half) { self::assertToolError($ability->permission(['post_type' => 'page'] + $half), 'validation_failed'); self::assertToolError($ability->execute(['post_type' => 'page'] + $half), 'validation_failed'); } $node = ['post_type' => 'page', 'post_id' => 7, 'node' => 's0']; self::assertToolError($ability->execute(['node' => 's9'] + $node), 'not_found'); self::assertToolError($ability->execute(['node' => 'x'] + $node), 'validation_failed'); self::assertToolError($ability->execute(['post_type' => 'post'] + $node), 'validation_failed'); Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_post'); self::assertToolError($ability->permission($node), 'forbidden'); self::assertToolError($ability->execute($node), 'forbidden'); self::assertSame([], $this->registryCalls, 'nothing is read for a refused entry'); } public function testListsDiviSourcesWithTheirPlaceholders(): void { $result = $this->ability()->execute(['post_type' => 'page']); $sources = array_column($result['sources'], null, 'name'); self::assertSame(831, $result['sample_post_id']); self::assertSame('{{post:title}}', $sources['post_title']['placeholder']); self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); self::assertNull($sources['site_title']['placeholder']); self::assertArrayNotHasKey('loop_post_title', $sources); self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']); self::assertFalse($this->ability()->isDirect()); self::assertTrue($this->ability()->annotations()['readonly']); self::assertSame('lit/divi-dynamic-sources', $this->ability()->name()); } /** Review M-9: {{link:}} refuses attachments (status inherit), so their link sources get no hint. */ public function testNoLinkHintForAttachmentsOrInternalTypes(): void { $registry = DiviFixtures::dynamicContentRegistry(); foreach (['post_link_url_attachment', 'post_link_url__et_pb_speculation'] as $name) { $registry[$name] = $registry['post_link_url_page']; $registry[$name]['id'] = $name; } $sources = array_column($this->ability([], 'meta', $registry)->execute(['post_type' => 'page'])['sources'], null, 'name'); self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); self::assertNull($sources['post_link_url_attachment']['placeholder']); self::assertNull($sources['post_link_url__et_pb_speculation']['placeholder']); } public function testOffersTextLikeFieldsAndNamesTheOthers(): void { $result = $this->ability()->execute(['post_type' => 'page']); self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key')); self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']); $omitted = array_column($result['omitted'], 'reason', 'key'); self::assertSame(['foto', 'projekte', '_intern', 'gefunden', 'aktiv', 'kennwort'], array_keys($omitted)); self::assertStringContainsString('nur IDs', $omitted['foto']); self::assertStringContainsString('nur IDs', $omitted['projekte']); self::assertStringContainsString('„_“', $omitted['_intern']); // Ruling P9: unknown field types are not offered. self::assertStringContainsString('unbekannt', $omitted['gefunden']); self::assertStringContainsString('nicht textartig', $omitted['aktiv']); self::assertStringContainsString('nicht textartig', $omitted['kennwort']); } public function testProtectedAndHiddenFieldsLeakNothing(): void { $result = $this->ability()->execute(['post_type' => 'page']); $json = (string) json_encode($result); // Hidden fields are neither offered nor named (security audit M4). self::assertStringNotContainsString('geheim', $json); self::assertStringNotContainsString('Geheim', $json); // The protected key is named (so the client knows why) but never offered with a placeholder. self::assertStringNotContainsString('{{field:_intern}}', $json); self::assertSame(['key', 'label', 'reason'], array_keys($result['omitted'][2])); } public function testSourcesNeverNameProtectedMetaOptions(): void { $registry = DiviFixtures::dynamicContentRegistry() + [ 'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []], ]; $result = $this->ability([], 'meta', $registry)->execute(['post_type' => 'page']); self::assertStringNotContainsString('_edit_lock', (string) json_encode($result)); } public function testPodsTableStorageIsNotReadableByDivi(): void { $result = $this->ability([], 'table')->execute(['post_type' => 'page']); self::assertSame(['telefon'], array_column($result['fields'], 'key')); self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']); } public function testFieldListNeedsTheDiviRole(): void { $result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']); self::assertSame([], $result['fields']); self::assertSame([], $result['omitted']); self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes'])); } public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void { $result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']); self::assertContains('post_title', array_column($result['sources'], 'name')); self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes'])); } public function testUnknownPostTypeIsNotFound(): void { Functions\when('get_post_type_object')->justReturn(null); self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found'); self::assertToolError($this->ability()->execute(['post_type' => 'nix']), 'not_found'); } public function testNeedsTheTypesEditCapability(): void { Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); self::assertToolError($this->ability()->permission(['post_type' => 'page']), 'forbidden'); } }