diff --git a/src/Integrations/Divi/Release/DiviFeatureGate.php b/src/Integrations/Divi/Release/DiviFeatureGate.php index 6620a80..3670545 100644 --- a/src/Integrations/Divi/Release/DiviFeatureGate.php +++ b/src/Integrations/Divi/Release/DiviFeatureGate.php @@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Release; use LIT\MCP\Abilities\Ability; use LIT\MCP\Abilities\AbilityGuard; +use LIT\MCP\Abilities\Errors; use LIT\MCP\ChangeLog\ChangeLog; use WP_Error; @@ -17,7 +18,8 @@ defined('ABSPATH') || exit; * answers divi_incompatible / divi_untested with the affected capability instead of failing inside Divi. A refused * write is logged with result "refused" (as before); a refused read changes nothing and is not. A tool that declares * nothing (RequiresDiviFeatures, or DeclaresRequirements for lit/content-create without divi) is left alone; the - * adapters still check each write themselves. + * adapters still check each write themselves. A declared ID that is no Divi capability (FeatureSets::unknownIds()) + * locks the call as divi_incompatible with details.unknown – the lock fails closed. */ final class DiviFeatureGate implements AbilityGuard { @@ -27,6 +29,11 @@ final class DiviFeatureGate implements AbilityGuard public function refusal(Ability $ability, array $input): ?WP_Error { + $unknown = FeatureSets::unknownIds($ability, $input); + if ($unknown !== []) { + return $this->refuseUnknown($ability, $unknown); + } + $lock = ReleaseGate::lockOf(FeatureSets::declaredBy($ability, $input)); if ($lock === null) { return null; @@ -45,6 +52,34 @@ final class DiviFeatureGate implements AbilityGuard return $lock->toError(); } + /** @param non-empty-list $unknown Declared IDs that are no Divi capability. */ + private function refuseUnknown(Ability $ability, array $unknown): WP_Error + { + $status = ReleaseGate::status(); + $ids = implode(', ', $unknown); + if (! self::readsOnly($ability)) { + $this->log?->record( + $ability->name(), + 'divi', + $status->version, + sprintf('Abgelehnt: %s – unbekannte Fähigkeit %s.', $status->versionLabel(), $ids), + result: 'refused' + ); + } + + return Errors::make( + 'divi_incompatible', + sprintf( + '%s: Das Werkzeug verlangt unbekannte Fähigkeiten (%s). Das ist gesperrt. Es wurde nichts geändert.', + $status->versionLabel(), + $ids + ), + 'Den Nutzer informieren, dass LIT MCP aktualisiert werden muss.', + 423, + ['unknown' => $unknown] + ); + } + /** A refusal is the whole answer: there is no result to annotate (the lock of reading tools is gone, E2). */ public function annotate(Ability $ability, mixed $result): mixed { diff --git a/src/Integrations/Divi/Release/FeatureSets.php b/src/Integrations/Divi/Release/FeatureSets.php index 24bfbee..6a4b022 100644 --- a/src/Integrations/Divi/Release/FeatureSets.php +++ b/src/Integrations/Divi/Release/FeatureSets.php @@ -17,8 +17,8 @@ final class FeatureSets { /** * The capabilities $ability declares for $input: its own diviFeatures() (the Divi tools) or the IDs a core tool - * names through DeclaresRequirements (lit/content-create with divi). Anything else declares none; an ID that is no - * DiviFeature is ignored (the core may name capabilities of other integrations). + * names through DeclaresRequirements (lit/content-create with divi). Anything else declares none. Every declared + * ID is a Divi ID; one that is no DiviFeature is skipped here but locks the call (unknownIds(), fail closed). * * @param array $input * @return list @@ -44,6 +44,25 @@ final class FeatureSets return self::merge($features); } + /** + * The IDs a core tool declares for $input that are no DiviFeature. Such a call cannot be judged, so + * DiviFeatureGate refuses it instead of letting it run unlocked. + * + * @param array $input + * @return list + */ + public static function unknownIds(Ability $ability, array $input): array + { + if ($ability instanceof RequiresDiviFeatures || ! $ability instanceof DeclaresRequirements) { + return []; + } + + return array_values(array_filter( + $ability->requirements($input), + static fn (string $id): bool => DiviFeature::tryFrom($id) === null + )); + } + /** * @param list ...$lists * @return list Every capability once, in the order of first appearance. diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php index 676de7e..b1f1a71 100644 --- a/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php +++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureGateTest.php @@ -265,14 +265,18 @@ final class DiviFeatureGateTest extends TestCase ); } - public function testAnUnknownRequirementOfTheCoreIsIgnored(): void + public function testAnUnknownRequirementOfTheCoreLocksTheToolAndIsLogged(): void { - DiviFeatureFixtures::lock(DiviFeature::PageWrite); - $tool = $this->contentCreate($this->composerNeeding('something-else')); + $log = new FakeChangeLog(); + $tool = $this->contentCreate($this->composerNeeding('page-write', 'something-else')); - self::assertNull( - $this->gate(FakeChangeLog::forbidden())->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]) - ); + $error = $this->gate($log)->refusal($tool, ['post_type' => 'page', 'divi' => [[]]]); + $payload = self::assertToolError($error, 'divi_incompatible'); + + self::assertSame(['something-else'], $payload['unknown']); + self::assertSame(423, $error->get_error_data()['status'] ?? null); + self::assertSame('lit/content-create', $log->records[0]['ability']); + self::assertSame('refused', $log->records[0]['result']); } public function testAnnotateNeverChangesAResult(): void diff --git a/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php b/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php index 81b0f4f..131382e 100644 --- a/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php +++ b/tests/Unit/Integrations/Divi/Release/FeatureSetsTest.php @@ -108,10 +108,10 @@ final class FeatureSetsTest extends TestCase self::assertSame([DiviFeature::Roles], FeatureSets::declaredBy($tool, ['a' => 1])); } - public function testDeclaredByTranslatesTheIdsOfACoreToolAndIgnoresUnknownOnes(): void + public function testDeclaredByTranslatesTheIdsOfACoreTool(): void { $tool = Mockery::mock(Ability::class, DeclaresRequirements::class); - $tool->expects('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']); + $tool->expects('requirements')->with(['divi' => []])->andReturn(['page-write', 'catalog']); self::assertSame( [DiviFeature::PageWrite, DiviFeature::Catalog], @@ -119,6 +119,22 @@ final class FeatureSetsTest extends TestCase ); } + public function testUnknownIdsNamesTheIdsOfACoreToolThatAreNoDiviFeature(): void + { + $tool = Mockery::mock(Ability::class, DeclaresRequirements::class); + $tool->allows('requirements')->with(['divi' => []])->andReturn(['page-write', 'other-integration', 'catalog']); + + self::assertSame(['other-integration'], FeatureSets::unknownIds($tool, ['divi' => []])); + } + + public function testUnknownIdsIsEmptyForDiviToolsAndAnyOtherTool(): void + { + $divi = Mockery::mock(Ability::class, RequiresDiviFeatures::class); + + self::assertSame([], FeatureSets::unknownIds($divi, [])); + self::assertSame([], FeatureSets::unknownIds(Mockery::mock(Ability::class), [])); + } + public function testDeclaredByIsEmptyForAnyOtherTool(): void { self::assertSame([], FeatureSets::declaredBy(Mockery::mock(Ability::class), ['x' => 1]));