feat: lit/divi-dynamic-sources with text-like fields only (E12, F4)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l
|
||||
`lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs
|
||||
(`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`),
|
||||
Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`,
|
||||
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`).
|
||||
`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`).
|
||||
|
||||
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
|
||||
@@ -29,6 +30,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility;
|
||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||
use LIT\MCP\Integrations\Integration;
|
||||
@@ -118,6 +120,7 @@ final class DiviIntegration implements Integration
|
||||
new DiviDuplicateNodeAbility($writer),
|
||||
new DiviReplaceTextAbility($this->schema, $loader, $writer),
|
||||
new DiviNodeCopyAbility($writer, $loader, $this->diviWritable),
|
||||
new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||
|
||||
use Closure;
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\Annotations;
|
||||
use LIT\MCP\Abilities\JsonMap;
|
||||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use Throwable;
|
||||
use WP_Error;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
|
||||
* (DynamicContentOptions::get_options with a sample entry of the type the user may edit, else 0;
|
||||
* no loop_* – S36; via DynamicContentMap::options()) with the placeholder that writes each, and
|
||||
* the fields of the type usable as {{field:<key>}}.
|
||||
*
|
||||
* Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods
|
||||
* table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each
|
||||
* left out with its reason. Hidden fields (security audit M4) are neither offered nor named. The
|
||||
* field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25).
|
||||
*/
|
||||
final class DiviDynamicSourcesAbility implements Ability
|
||||
{
|
||||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||||
private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
|
||||
|
||||
private const ID_TYPES = ['relation', 'file'];
|
||||
|
||||
/** Candidates the default sample lookup checks for edit_post. */
|
||||
private const SAMPLE_CANDIDATES = 10;
|
||||
|
||||
/** @var Closure(string): int */
|
||||
private Closure $samplePost;
|
||||
|
||||
/** @var Closure(string): ?string */
|
||||
private Closure $podStorage;
|
||||
|
||||
/**
|
||||
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
|
||||
* @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table");
|
||||
* null without a pod.
|
||||
*/
|
||||
public function __construct(
|
||||
private ContentSchema $schema,
|
||||
private DynamicContentMap $map,
|
||||
private DiviRoles $roles,
|
||||
?Closure $samplePost = null,
|
||||
?Closure $podStorage = null
|
||||
) {
|
||||
$this->samplePost = $samplePost ?? self::defaultSample(...);
|
||||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'lit/divi-dynamic-sources';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Dynamic-Content-Quellen';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, '
|
||||
. 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:<ID>}}) bzw. dem Namen für die Vollform, '
|
||||
. 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} eignen. Nur textartige Felder; Beziehungs-, '
|
||||
. 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.';
|
||||
}
|
||||
|
||||
public function inputSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']],
|
||||
'required' => ['post_type'],
|
||||
'additionalProperties' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function outputSchema(): ?array
|
||||
{
|
||||
$entry = static fn (array $properties): array => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => array_fill_keys($properties, ['type' => 'string']),
|
||||
'required' => $properties,
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'post_type' => ['type' => 'string'],
|
||||
'sample_post_id' => ['type' => 'integer'],
|
||||
'sources' => [
|
||||
'type' => 'array',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'name' => ['type' => 'string'],
|
||||
'label' => ['type' => 'string'],
|
||||
'group' => ['type' => 'string'],
|
||||
'placeholder' => ['type' => ['string', 'null']],
|
||||
'settings' => ['type' => 'object'],
|
||||
],
|
||||
'required' => ['name', 'label', 'group', 'placeholder', 'settings'],
|
||||
],
|
||||
],
|
||||
'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']),
|
||||
'omitted' => $entry(['key', 'label', 'reason']),
|
||||
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
],
|
||||
'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'],
|
||||
];
|
||||
}
|
||||
|
||||
public function annotations(): array
|
||||
{
|
||||
return Annotations::read();
|
||||
}
|
||||
|
||||
public function isDirect(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function permission(array $input): bool|WP_Error
|
||||
{
|
||||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
|
||||
|
||||
return $type instanceof WP_Error ? $type : true;
|
||||
}
|
||||
|
||||
public function execute(array $input): mixed
|
||||
{
|
||||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
|
||||
if ($type instanceof WP_Error) {
|
||||
return $type;
|
||||
}
|
||||
|
||||
$postType = (string) $input['post_type'];
|
||||
$sample = ($this->samplePost)($postType);
|
||||
$notes = [
|
||||
'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"<name>",'
|
||||
. '"settings":{…}}})$ mit den genannten Einstellungen schreiben.',
|
||||
];
|
||||
|
||||
$options = $this->map->options($sample);
|
||||
if ($options === null) {
|
||||
$notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.';
|
||||
$options = [];
|
||||
foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) {
|
||||
$options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings];
|
||||
}
|
||||
}
|
||||
|
||||
$sources = [];
|
||||
foreach ($options as $name => $option) {
|
||||
$sources[] = [
|
||||
'name' => (string) $name,
|
||||
'label' => $option['label'],
|
||||
'group' => $option['group'],
|
||||
'placeholder' => self::placeholder((string) $name),
|
||||
'settings' => JsonMap::of($option['settings']),
|
||||
];
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
if ($this->roles->allows('read_dynamic_content_custom_fields')) {
|
||||
[$fields, $omitted] = $this->fields($postType);
|
||||
} else {
|
||||
$notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte '
|
||||
. 'Felder lesen“ nicht (Divi → Rollen-Editor).';
|
||||
}
|
||||
|
||||
return [
|
||||
'post_type' => $postType,
|
||||
'sample_post_id' => $sample,
|
||||
'sources' => $sources,
|
||||
'fields' => $fields,
|
||||
'omitted' => $omitted,
|
||||
'notes' => $notes,
|
||||
];
|
||||
}
|
||||
|
||||
private static function placeholder(string $name): ?string
|
||||
{
|
||||
$post = array_search($name, Placeholders::POST, true);
|
||||
if ($post !== false) {
|
||||
return '{{post:' . $post . '}}';
|
||||
}
|
||||
|
||||
return str_starts_with($name, 'post_link_url_') ? '{{link:<ID>}}' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields.
|
||||
*/
|
||||
private function fields(string $postType): array
|
||||
{
|
||||
$storage = ($this->podStorage)($postType);
|
||||
$fields = [];
|
||||
$omitted = [];
|
||||
foreach ($this->schema->fields($postType) as $field) {
|
||||
if ($field->status === FieldDefinition::HIDDEN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['key' => $field->key, 'label' => $field->label];
|
||||
$reason = self::omission($field, $storage);
|
||||
if ($reason !== null) {
|
||||
$omitted[] = $entry + ['reason' => $reason];
|
||||
continue;
|
||||
}
|
||||
|
||||
$fields[] = $entry + [
|
||||
'type' => $field->type,
|
||||
'source' => $field->source,
|
||||
'placeholder' => '{{field:' . $field->key . '}}',
|
||||
];
|
||||
}
|
||||
|
||||
return [$fields, $omitted];
|
||||
}
|
||||
|
||||
private static function omission(FieldDefinition $field, ?string $storage): ?string
|
||||
{
|
||||
return match (true) {
|
||||
! DynamicContentRules::isMetaKey($field->key)
|
||||
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
|
||||
$field->source === 'pods' && $storage === 'table'
|
||||
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
|
||||
in_array($field->type, self::ID_TYPES, true)
|
||||
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||||
$field->type === 'unknown'
|
||||
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
|
||||
! in_array($field->type, self::TEXT_TYPES, true)
|
||||
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The most recently modified entry of the type the current user may edit, else 0. */
|
||||
private static function defaultSample(string $postType): int
|
||||
{
|
||||
$ids = get_posts([
|
||||
'post_type' => $postType,
|
||||
'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
|
||||
'posts_per_page' => self::SAMPLE_CANDIDATES,
|
||||
'orderby' => 'modified',
|
||||
'order' => 'DESC',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
]);
|
||||
|
||||
foreach (is_array($ids) ? $ids : [] as $id) {
|
||||
if (current_user_can('edit_post', (int) $id)) {
|
||||
return (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static function defaultStorage(string $postType): ?string
|
||||
{
|
||||
if (! function_exists('pods_api')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pod = pods_api()->load_pod(['name' => $postType]);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,51 @@ class DynamicContentMap
|
||||
|
||||
/** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
|
||||
public function forPost(int $postId): ?array
|
||||
{
|
||||
$raw = $this->raw($postId);
|
||||
|
||||
return $raw === null ? null : self::fromOptions($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
|
||||
* names and types as forPost(), loop_* left out). custom_meta_<key> options a filter registers
|
||||
* for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so
|
||||
* the listing never names them. Only label, group and setting types leave Divi's registry –
|
||||
* no select options or defaults (they list meta keys). Null when the registry is unavailable.
|
||||
*
|
||||
* @return array<string, array{label: string, group: string, settings: array<string, string>}>|null
|
||||
*/
|
||||
public function options(int $postId): ?array
|
||||
{
|
||||
$raw = $this->raw($postId);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$options = [];
|
||||
foreach (self::fromOptions($raw) as $name => $settings) {
|
||||
if (str_starts_with($name, 'custom_meta_') && ! DynamicContentRules::isMetaKey($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$option = is_array($raw[$name] ?? null) ? $raw[$name] : [];
|
||||
$options[$name] = [
|
||||
'label' => is_string($option['label'] ?? null) ? $option['label'] : $name,
|
||||
'group' => is_string($option['group'] ?? null) ? $option['group'] : '',
|
||||
'settings' => $settings,
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divi's registry for the post; any filter error is logged (message only) and yields null.
|
||||
*
|
||||
* @return array<array-key, mixed>|null
|
||||
*/
|
||||
private function raw(int $postId): ?array
|
||||
{
|
||||
try {
|
||||
$options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId);
|
||||
@@ -52,7 +97,7 @@ class DynamicContentMap
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($options) && $options !== [] ? self::fromOptions($options) : null;
|
||||
return is_array($options) && $options !== [] ? $options : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,6 +30,7 @@ final class DiviIntegrationTest extends TestCase
|
||||
'lit/divi-duplicate-node',
|
||||
'lit/divi-replace-text',
|
||||
'lit/divi-node-copy',
|
||||
'lit/divi-dynamic-sources',
|
||||
];
|
||||
|
||||
public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||
|
||||
use Brain\Monkey\Functions;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
|
||||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||
use LIT\MCP\Tests\Unit\TestCase;
|
||||
use Mockery;
|
||||
|
||||
final class DiviDynamicSourcesAbilityTest extends TestCase
|
||||
{
|
||||
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<string> $deniedRoles
|
||||
* @param array<array-key, mixed>|null $registry
|
||||
*/
|
||||
private function ability(
|
||||
array $deniedRoles = [],
|
||||
?string $storage = 'meta',
|
||||
?array $registry = null
|
||||
): DiviDynamicSourcesAbility {
|
||||
$registry ??= DiviFixtures::dynamicContentRegistry();
|
||||
|
||||
return new DiviDynamicSourcesAbility(
|
||||
$this->schema(),
|
||||
new DynamicContentMap(static fn (int $id): array => $registry),
|
||||
LibraryFixtures::roles($deniedRoles),
|
||||
static fn (string $postType): int => 831,
|
||||
static fn (string $postType): ?string => $storage
|
||||
);
|
||||
}
|
||||
|
||||
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:<ID>}}', $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());
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,73 @@ final class DynamicContentMapTest extends TestCase
|
||||
self::assertNull((new DynamicContentMap())->forPost(7));
|
||||
}
|
||||
|
||||
public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void
|
||||
{
|
||||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||||
|
||||
$options = $map->options(831);
|
||||
|
||||
self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']);
|
||||
self::assertSame('post_meta_key', $options['post_meta_key']['label']);
|
||||
self::assertSame('Standard', $options['post_meta_key']['group']);
|
||||
self::assertArrayNotHasKey('loop_post_title', $options);
|
||||
self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831));
|
||||
}
|
||||
|
||||
public function testOptionsUseTheSameNamesAndTypesAsTheAllowMap(): void
|
||||
{
|
||||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||||
|
||||
$options = $map->options(831);
|
||||
|
||||
self::assertSame(array_keys((array) $map->forPost(831)), array_keys((array) $options));
|
||||
$settings = array_map(static fn (array $option): array => $option['settings'], (array) $options);
|
||||
self::assertSame($map->forPost(831), $settings);
|
||||
// Placeholder options the registry lacks: name as label, no group.
|
||||
self::assertSame(['label' => 'post_tags', 'group' => ''], array_slice($options['post_tags'], 0, 2));
|
||||
}
|
||||
|
||||
public function testOptionsNeverNameProtectedMetaKeys(): void
|
||||
{
|
||||
// A third-party filter may register per-key options (D4 style custom_meta_<key>).
|
||||
$registry = DiviFixtures::dynamicContentRegistry() + [
|
||||
'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []],
|
||||
'custom_meta_custom_meta__secret' => ['label' => 'x', 'group' => 'Custom', 'fields' => []],
|
||||
'custom_meta_telefon' => ['label' => 'Telefon', 'group' => 'Custom', 'fields' => []],
|
||||
];
|
||||
$map = new DynamicContentMap(static fn (int $id): array => $registry);
|
||||
|
||||
$options = (array) $map->options(831);
|
||||
|
||||
self::assertArrayNotHasKey('custom_meta__edit_lock', $options);
|
||||
self::assertArrayNotHasKey('custom_meta_custom_meta__secret', $options);
|
||||
self::assertSame('Telefon', $options['custom_meta_telefon']['label']);
|
||||
// The allow map itself is unchanged: VariableRef refuses those names by its own rules (S12/S30).
|
||||
self::assertArrayHasKey('custom_meta__edit_lock', (array) $map->forPost(831));
|
||||
}
|
||||
|
||||
public function testTheAllowMapIsUnchangedByTheRawLookupRefactor(): void
|
||||
{
|
||||
// Snapshot of forPost() for the live-like registry, taken before forPost() moved onto raw()
|
||||
// (Task 15): the security allow map must stay exactly the same (rulings S22/S29/S30/S37).
|
||||
$before = '{"post_title":{"before":"richtext","after":"richtext"},"post_excerpt":{"before":"richtext",'
|
||||
. '"after":"richtext","words":"text","read_more_label":"text"},"post_date":{"before":"richtext",'
|
||||
. '"after":"richtext","date_format":"select","custom_date_format":"text"},"post_author":{"before":'
|
||||
. '"richtext","after":"richtext","name_format":"select","link":"yes_no_button"},"site_title":{"before":'
|
||||
. '"richtext","after":"richtext"},"post_link_url":[],"post_link_url_page":{"post_id":"select_post"},'
|
||||
. '"post_featured_image":[],"post_meta_key":{"before":"richtext","after":"richtext","select_meta_key":'
|
||||
. '"select","meta_key":"text","enable_html":"yes_no_button"},"broken_fields":{"a":"","b":""},'
|
||||
. '"no_fields":[],"post_modified_date":{"before":"richtext","after":"richtext","date_format":"select",'
|
||||
. '"custom_date_format":"text"},"post_categories":{"before":"richtext","after":"richtext"},'
|
||||
. '"post_tags":{"before":"richtext","after":"richtext"}}';
|
||||
|
||||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||||
$after = $map->forPost(831);
|
||||
|
||||
self::assertSame(json_decode($before, true), $after);
|
||||
self::assertSame($before, json_encode($after));
|
||||
}
|
||||
|
||||
private static function assertRejected(callable $call, string $fragment): void
|
||||
{
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user