fix: content-create with divi returns the Visual Builder edit_url (review I-1)
DiviPage::builderUrl() is shared by DiviPage::editUrl() and the content composer, which now returns edit_url; content-create without divi keeps the wp-admin link. divi-write-smoke checks the builder URL and, new in section 9, the M1/M2 gates live (raw Divi markup refused; Divi read-only stops content-create divi and execute-ability lit/divi-insert). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -90,7 +90,11 @@ final class DiviContentComposer implements ContentComposer
|
|||||||
|
|
||||||
$saved = $this->saver->saveNew($postId, Serializer::document($layout['document']));
|
$saved = $this->saver->saveNew($postId, Serializer::document($layout['document']));
|
||||||
|
|
||||||
return ['warnings' => array_merge($warnings, $saved), 'outline' => (string) $outline['text']];
|
return [
|
||||||
|
'warnings' => array_merge($warnings, $saved),
|
||||||
|
'outline' => (string) $outline['text'],
|
||||||
|
'edit_url' => DiviPage::builderUrl($postId),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ final class DiviPage
|
|||||||
|
|
||||||
public function editUrl(): string
|
public function editUrl(): string
|
||||||
{
|
{
|
||||||
return (string) add_query_arg('et_fb', '1', (string) get_permalink($this->post));
|
return self::builderUrl($this->post);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entry opened in the Divi Visual Builder – the edit_url of every Divi write (review I-1). */
|
||||||
|
public static function builderUrl(WP_Post|int $post): string
|
||||||
|
{
|
||||||
|
return (string) add_query_arg('et_fb', '1', (string) get_permalink($post));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ interface ContentComposer
|
|||||||
* lit/content-create delete the draft again.
|
* lit/content-create delete the draft again.
|
||||||
*
|
*
|
||||||
* @param list<mixed> $sections
|
* @param list<mixed> $sections
|
||||||
* @return array{warnings: list<string>, outline: ?string}|WP_Error
|
* @return array{warnings: list<string>, outline: ?string, edit_url?: string}|WP_Error edit_url: where
|
||||||
|
* the new content is edited (e.g. the Divi builder) instead of the WordPress editor.
|
||||||
* @throws Throwable When storing failed.
|
* @throws Throwable When storing failed.
|
||||||
*/
|
*/
|
||||||
public function write(int $postId, string $postType, array $sections): array|WP_Error;
|
public function write(int $postId, string $postType, array $sections): array|WP_Error;
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ final class ContentCreateAbility implements Ability
|
|||||||
|
|
||||||
$warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
|
$warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
|
||||||
|
|
||||||
return $this->result($postId, $postType, $input, $warnings, $layout['outline'] ?? null);
|
return $this->result($postId, $postType, $input, $warnings, $layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,10 +220,10 @@ final class ContentCreateAbility implements Ability
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $input
|
* @param array<string, mixed> $input
|
||||||
* @param list<string> $warnings
|
* @param list<string> $warnings
|
||||||
* @param string|null $outline Divi outline of the new layout (with divi only).
|
* @param array<string, mixed>|null $layout What the composer wrote (with divi only): outline, edit_url.
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function result(int $postId, string $postType, array $input, array $warnings, ?string $outline): array
|
private function result(int $postId, string $postType, array $input, array $warnings, ?array $layout): array
|
||||||
{
|
{
|
||||||
// The draft is saved – failures from here on are logged and reported as warnings only.
|
// The draft is saved – failures from here on are logged and reported as warnings only.
|
||||||
$change = (new PostSaveFollowUp($this->name(), $this->log, $postId, 'der Entwurf', $warnings))
|
$change = (new PostSaveFollowUp($this->name(), $this->log, $postId, 'der Entwurf', $warnings))
|
||||||
@@ -234,10 +234,13 @@ final class ContentCreateAbility implements Ability
|
|||||||
|
|
||||||
$result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post)
|
$result = $change->result(sprintf('"%s" als Entwurf angelegt (ID %d).', $title, $postId), $post)
|
||||||
->changed((string) $postId, $postType, 'created', $title)
|
->changed((string) $postId, $postType, 'created', $title)
|
||||||
->urls((string) get_preview_post_link($postId), (string) get_edit_post_link($postId, 'raw'));
|
->urls(
|
||||||
|
(string) get_preview_post_link($postId),
|
||||||
|
(string) ($layout['edit_url'] ?? get_edit_post_link($postId, 'raw'))
|
||||||
|
);
|
||||||
if (array_key_exists('divi', $input)) {
|
if (array_key_exists('divi', $input)) {
|
||||||
// The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away.
|
// The Divi tools lock on tree_rev (ruling P16): the next lit-divi-* call can use it right away.
|
||||||
$result->outline($outline)
|
$result->outline(isset($layout['outline']) ? (string) $layout['outline'] : null)
|
||||||
->with('tree_rev', Rev::of($post instanceof WP_Post ? (string) $post->post_content : ''));
|
->with('tree_rev', Rev::of($post instanceof WP_Post ? (string) $post->post_content : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
|
|||||||
EDITOR_LOGIN=lit-smoke-editor
|
EDITOR_LOGIN=lit-smoke-editor
|
||||||
FIXTURES=$PLUGIN/tests/Fixtures/divi
|
FIXTURES=$PLUGIN/tests/Fixtures/divi
|
||||||
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
|
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
|
||||||
|
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__')
|
||||||
CREATED_POSTS=()
|
CREATED_POSTS=()
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
@@ -36,6 +37,11 @@ foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
|
|||||||
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
|
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
|
||||||
wp role delete lit_smoke_editor >/dev/null 2>&1 || true
|
wp role delete lit_smoke_editor >/dev/null 2>&1 || true
|
||||||
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
|
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
|
||||||
|
if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then
|
||||||
|
wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
|
||||||
|
else
|
||||||
|
wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
|
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
|
||||||
|
|
||||||
@@ -221,6 +227,7 @@ NEW_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty')
|
|||||||
[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED"
|
[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED"
|
||||||
CREATED_POSTS+=("$NEW_ID")
|
CREATED_POSTS+=("$NEW_ID")
|
||||||
echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED"
|
echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED"
|
||||||
|
echo "$CREATED" | jq -e '.edit_url | contains("et_fb=1")' >/dev/null || fail "content-create edit_url is not the builder: $CREATED"
|
||||||
[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing"
|
[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing"
|
||||||
[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft"
|
[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft"
|
||||||
tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered"
|
tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered"
|
||||||
@@ -361,4 +368,32 @@ echo "$AGAIN" | jq -e '.ok == true and (.warnings | any(contains("beim Speichern
|
|||||||
|| fail "8b: follow-up write with the new tree_rev: $AGAIN"
|
|| fail "8b: follow-up write with the new tree_rev: $AGAIN"
|
||||||
pass "Divi save filters, other byte form: normalized with CHANGED_ON_SAVE, tree_rev = stored state, page valid, then stable"
|
pass "Divi save filters, other byte form: normalized with CHANGED_ON_SAVE, tree_rev = stored state, page valid, then stable"
|
||||||
|
|
||||||
|
# 9. No way around the Divi pipeline (security audit M1, M2):
|
||||||
|
# 9a: raw content with Divi block markup is refused on create and update.
|
||||||
|
RAW=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Roh","content":"<!-- wp:divi/section --><!-- /wp:divi/section -->"}')
|
||||||
|
[ "$(error_code "$RAW")" = "validation_failed" ] || fail "9a: raw Divi markup accepted by content-create: $RAW"
|
||||||
|
PLAIN=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Klar","content":"<!-- wp:paragraph --><p>x</p><!-- /wp:paragraph -->"}')
|
||||||
|
PLAIN_ID=$(echo "$PLAIN" | jq -r '.changed[0].id // empty')
|
||||||
|
[ -n "$PLAIN_ID" ] || fail "9a: plain block markup refused: $PLAIN"
|
||||||
|
CREATED_POSTS+=("$PLAIN_ID")
|
||||||
|
PLAIN_REV=$(tool lit-content-get "{\"post_id\":$PLAIN_ID}" | jq -r '.rev')
|
||||||
|
RAW_UPDATE=$(tool lit-content-update "$(jq -cn --argjson id "$PLAIN_ID" --arg rev "$PLAIN_REV" '{post_id: $id, rev: $rev,
|
||||||
|
content: "<!--\n wp:divi/text {\"module\":{}} /-->"}')")
|
||||||
|
[ "$(error_code "$RAW_UPDATE")" = "validation_failed" ] || fail "9a: raw Divi markup accepted by content-update: $RAW_UPDATE"
|
||||||
|
pass "raw content with Divi markup refused (create and update); other block markup allowed"
|
||||||
|
|
||||||
|
# 9b: Divi group "nur lesen" stops content-create divi and lit/divi-insert (also via execute-ability).
|
||||||
|
wp option update lit_mcp_exposure '{"@readonly:divi":true}' --format=json >/dev/null
|
||||||
|
connect "$ADMIN_LOGIN"
|
||||||
|
GATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Gesperrt","divi":[{"type":"section"}]}')
|
||||||
|
[ "$(error_code "$GATED")" = "unsupported" ] || fail "9b: content-create divi with Divi read-only: $GATED"
|
||||||
|
UNGATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Ohne"}')
|
||||||
|
UNGATED_ID=$(echo "$UNGATED" | jq -r '.changed[0].id // empty')
|
||||||
|
[ -n "$UNGATED_ID" ] || fail "9b: content-create without divi must keep working: $UNGATED"
|
||||||
|
CREATED_POSTS+=("$UNGATED_ID")
|
||||||
|
VIA_EXECUTE=$(ability lit/divi-insert "{\"post_id\":$UNGATED_ID,\"tree_rev\":\"x\",\"nodes\":[{\"type\":\"section\"}]}")
|
||||||
|
echo "$VIA_EXECUTE" | jq -e 'has("__error")' >/dev/null || fail "9b: lit/divi-insert reachable via execute-ability: $VIA_EXECUTE"
|
||||||
|
echo "$VIA_EXECUTE" | grep -q 'not found' || fail "9b: expected ability not found: $VIA_EXECUTE"
|
||||||
|
pass "Divi read-only: content-create divi refused, content-create without divi works, divi-insert not executable"
|
||||||
|
|
||||||
finish
|
finish
|
||||||
|
|||||||
@@ -77,9 +77,14 @@ final class DiviContentComposerTest extends TestCase
|
|||||||
|
|
||||||
public function testWritesAPlaceholderWrappedLayoutBoundToTheNewEntry(): void
|
public function testWritesAPlaceholderWrappedLayoutBoundToTheNewEntry(): void
|
||||||
{
|
{
|
||||||
|
\Brain\Monkey\Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=42');
|
||||||
|
\Brain\Monkey\Functions\when('add_query_arg')->alias(
|
||||||
|
static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
|
||||||
|
);
|
||||||
$written = $this->composer()->write(42, 'page', self::sections());
|
$written = $this->composer()->write(42, 'page', self::sections());
|
||||||
|
|
||||||
self::assertIsArray($written);
|
self::assertIsArray($written);
|
||||||
|
self::assertSame('https://kunde.test/?page_id=42&et_fb=1', $written['edit_url'], 'review I-1: builder URL');
|
||||||
self::assertSame([42], $this->registryCalls, 'the final layout uses the live map of the new entry');
|
self::assertSame([42], $this->registryCalls, 'the final layout uses the live map of the new entry');
|
||||||
$content = $this->saved[42];
|
$content = $this->saved[42];
|
||||||
// Ruling S9: exactly one divi/placeholder around the sections, as TreeEditor does for empty pages.
|
// Ruling S9: exactly one divi/placeholder around the sections, as TreeEditor does for empty pages.
|
||||||
|
|||||||
@@ -85,13 +85,21 @@ final class ContentCreateDiviTest extends TestCase
|
|||||||
]));
|
]));
|
||||||
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true');
|
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=42&preview=true');
|
||||||
Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit');
|
Functions\when('get_edit_post_link')->justReturn('https://kunde.test/wp-admin/post.php?post=42&action=edit');
|
||||||
|
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=42');
|
||||||
|
Functions\when('add_query_arg')->alias(
|
||||||
|
static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void
|
public function testCreatesADiviDraftWithTreeRevOutlineAndWarnings(): void
|
||||||
{
|
{
|
||||||
$composer = $this->composer();
|
$composer = $this->composer();
|
||||||
$composer->expects('write')->with(42, 'page', self::SECTIONS)
|
$composer->expects('write')->with(42, 'page', self::SECTIONS)
|
||||||
->andReturn(['warnings' => ['s0: leer (section ohne Inhalt).'], 'outline' => 's0 divi/section']);
|
->andReturn([
|
||||||
|
'warnings' => ['s0: leer (section ohne Inhalt).'],
|
||||||
|
'outline' => 's0 divi/section',
|
||||||
|
'edit_url' => 'https://kunde.test/?page_id=42&et_fb=1',
|
||||||
|
]);
|
||||||
// D7: the insert carries only the normal WordPress fields (kses as usual) – never Divi markup;
|
// D7: the insert carries only the normal WordPress fields (kses as usual) – never Divi markup;
|
||||||
// the layout is written by the Divi composer through DiviSaver's single kses window.
|
// the layout is written by the Divi composer through DiviSaver's single kses window.
|
||||||
Functions\expect('wp_insert_post')->once()->with([
|
Functions\expect('wp_insert_post')->once()->with([
|
||||||
@@ -108,6 +116,8 @@ final class ContentCreateDiviTest extends TestCase
|
|||||||
self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']);
|
self::assertSame(Rev::of(self::MARKUP), $result['tree_rev']);
|
||||||
self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']);
|
self::assertSame(['s0: leer (section ohne Inhalt).'], $result['warnings']);
|
||||||
self::assertSame('s0 divi/section', $result['outline']);
|
self::assertSame('s0 divi/section', $result['outline']);
|
||||||
|
// Review I-1: like every Divi write, the builder – not wp-admin – is where to continue.
|
||||||
|
self::assertSame('https://kunde.test/?page_id=42&et_fb=1', $result['edit_url']);
|
||||||
self::assertStringContainsString('Entwurf', $result['summary'], 'drafts stay drafts');
|
self::assertStringContainsString('Entwurf', $result['summary'], 'drafts stay drafts');
|
||||||
self::assertArrayHasKey('tree_rev', $this->ability(null)->outputSchema()['properties']);
|
self::assertArrayHasKey('tree_rev', $this->ability(null)->outputSchema()['properties']);
|
||||||
self::assertArrayHasKey('divi', $this->ability(null)->inputSchema()['properties']);
|
self::assertArrayHasKey('divi', $this->ability(null)->inputSchema()['properties']);
|
||||||
@@ -165,6 +175,7 @@ final class ContentCreateDiviTest extends TestCase
|
|||||||
$result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']);
|
$result = $this->ability($composer)->execute(['post_type' => 'page', 'title' => 'Start']);
|
||||||
|
|
||||||
self::assertTrue($result['ok']);
|
self::assertTrue($result['ok']);
|
||||||
|
self::assertStringContainsString('/wp-admin/post.php', $result['edit_url'], 'without divi: the editor');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEveryValidationErrorComesBackAndNothingIsCreated(): void
|
public function testEveryValidationErrorComesBackAndNothingIsCreated(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user