|null $ids null = the ability does not implement DeclaresRequirements. */ private function tool(bool $readonly, ?array $ids): Ability { $ability = $ids === null ? Mockery::mock(Ability::class) : Mockery::mock(Ability::class, DeclaresRequirements::class); $ability->allows('name')->andReturn('lit/fake'); $ability->allows('annotations')->andReturn(['readonly' => $readonly]); if ($ids !== null) { $ability->allows('requirements')->andReturn($ids); } return $ability; } /** fake-write is locked (missing call), fake-read free, fake-other belongs to a release-per-version profile. */ /** @param list $delegated */ private function gate(?FakeChangeLog $log = null, array $delegated = []): CapabilityGate { $lock = new Profile( id: 'fake', label: 'Fake Plugin', tier: Tier::LockOnFailure, capabilities: [FakeCapability::Read, FakeCapability::Write], manifests: [new Manifest('Fake', calls: [new Call('lit_mcp_missing_function', 0, [FakeCapability::Write])])] ); $release = new Profile( id: 'release', label: 'Release', tier: Tier::ReleasePerVersion, capabilities: [FakeCapability::Other], matrix: static fn (): CompatibilityMatrix => CompatibilityMatrix::allIncompatible( [FakeCapability::Other], 'would lock' ) ); return new CapabilityGate(new Compatibility([$lock, $release]), $log, $delegated); } public function testAFreeCapabilityPassesAndAWriteIsNotLogged(): void { self::assertNull($this->gate(FakeChangeLog::forbidden())->refusal($this->tool(false, ['fake-read']), [])); } public function testALockedCapabilityIsRefusedWith423(): void { $error = $this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-read', 'fake-write']), []); $payload = self::assertToolError($error, 'compat_incompatible'); self::assertSame(['fake-write'], array_column($payload['features'], 'id')); } public function testTheIdOfADelegatedProfileIsLeftToItsOwnGate(): void { $gate = $this->gate(FakeChangeLog::forbidden(), ['release']); self::assertNull($gate->refusal($this->tool(false, ['fake-other']), [])); } public function testAReleasePerVersionProfileWithoutDelegationIsLockedByItsMatrix(): void { $payload = self::assertToolError( $this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-other']), []), 'compat_incompatible' ); self::assertSame(['fake-other'], array_column($payload['features'], 'id')); } public function testAnUnknownIdLocksTheCall(): void { $payload = self::assertToolError( $this->gate(FakeChangeLog::forbidden())->refusal($this->tool(true, ['fake-read', 'fake-nope']), []), 'compat_incompatible' ); self::assertSame(['fake-nope'], $payload['unknown']); } public function testAnUnknownIdOfAWriteIsRefusedWith423AndLogged(): void { $log = new FakeChangeLog(); $error = $this->gate($log)->refusal($this->tool(false, ['fake-read', 'something-else']), []); self::assertSame(423, $error->get_error_data()['status'] ?? null); $log->assertRecorded( ['ability' => 'lit/fake', 'object_type' => 'compat', 'object_id' => 'something-else', 'result' => 'refused'] ); self::assertStringContainsString('unbekannte Fähigkeit something-else', $log->summaries()[0]); } public function testARefusedWriteIsLoggedWithTheProfileId(): void { $log = new FakeChangeLog(); $this->gate($log)->refusal($this->tool(false, ['fake-write']), []); $log->assertRecorded( ['ability' => 'lit/fake', 'object_type' => 'compat', 'object_id' => 'fake', 'result' => 'refused'] ); self::assertStringContainsString('Fake Plugin', $log->summaries()[0]); } public function testARefusedWriteWithAnUnknownIdIsLogged(): void { $log = new FakeChangeLog(); $this->gate($log)->refusal($this->tool(false, ['fake-nope']), []); $log->assertRecorded(['object_type' => 'compat', 'object_id' => 'fake-nope', 'result' => 'refused']); } public function testAnAbilityWithoutRequirementsIsUntouched(): void { $gate = $this->gate(FakeChangeLog::forbidden()); $tool = $this->tool(false, null); self::assertNull($gate->refusal($tool, [])); self::assertSame(['ok' => true], $gate->annotate($tool, ['ok' => true])); self::assertSame(['type' => 'object'], $gate->outputSchema($tool, ['type' => 'object'])); self::assertNull($gate->outputSchema($tool, null)); } }