diff --git a/backend/config/services.yaml b/backend/config/services.yaml index f8c7cb4..dad4740 100644 --- a/backend/config/services.yaml +++ b/backend/config/services.yaml @@ -41,9 +41,9 @@ services: App\Auth\Permissions: arguments: ['%kernel.project_dir%', 'config/permissions.yaml'] - App\Instrumentation\DataCollector\PermissionVoterCollector: + App\Instrumentation\DataCollector\PermissionVotesCollector: tags: - - { name: data_collector, template: 'profiler/permission_voter_collector.html.twig', id: 'app.permission_voter_collector' } + - { name: data_collector, template: 'profiler/permission_votes.html.twig', id: 'app.permission_votes_collector' } App\Instrumentation\DataCollector\ArchitectureCollector: arguments: diff --git a/backend/src/Auth/PermissionVoter.php b/backend/src/Auth/PermissionVoter.php index e9bf8a6..4f1c0b6 100644 --- a/backend/src/Auth/PermissionVoter.php +++ b/backend/src/Auth/PermissionVoter.php @@ -5,7 +5,7 @@ namespace App\Auth; use App\Game\CurrentUserInterface; -use App\Instrumentation\DataCollector\PermissionVoterCollector; +use App\Instrumentation\DataCollector\PermissionVotesCollector; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Vote; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -17,7 +17,7 @@ final class PermissionVoter extends Voter { public function __construct( private readonly CurrentUserInterface $currentUser, - private readonly PermissionVoterCollector $collector, + private readonly PermissionVotesCollector $collector, ) { } diff --git a/backend/src/Instrumentation/DataCollector/PermissionVoterCollector.php b/backend/src/Instrumentation/DataCollector/PermissionVotesCollector.php similarity index 96% rename from backend/src/Instrumentation/DataCollector/PermissionVoterCollector.php rename to backend/src/Instrumentation/DataCollector/PermissionVotesCollector.php index e677526..46d0033 100644 --- a/backend/src/Instrumentation/DataCollector/PermissionVoterCollector.php +++ b/backend/src/Instrumentation/DataCollector/PermissionVotesCollector.php @@ -8,7 +8,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; -class PermissionVoterCollector extends DataCollector +class PermissionVotesCollector extends DataCollector { public function addVoterCall(string $attribute, mixed $subject, bool $hasPermission): void { @@ -98,6 +98,6 @@ public function reset(): void public function getName(): string { - return 'app.permission_voter_collector'; + return 'app.permission_votes_collector'; } } diff --git a/backend/templates/profiler/icon.svg b/backend/templates/profiler/lock.svg similarity index 100% rename from backend/templates/profiler/icon.svg rename to backend/templates/profiler/lock.svg diff --git a/backend/templates/profiler/permission_voter_collector.html.twig b/backend/templates/profiler/permission_votes.html.twig similarity index 88% rename from backend/templates/profiler/permission_voter_collector.html.twig rename to backend/templates/profiler/permission_votes.html.twig index 1ad6e10..f9557b1 100644 --- a/backend/templates/profiler/permission_voter_collector.html.twig +++ b/backend/templates/profiler/permission_votes.html.twig @@ -1,15 +1,15 @@ -{# templates/profiler/permission_voter_collector.html.twig #} +{# templates/profiler/permission_votes.html.twig #} {% extends '@WebProfiler/Profiler/layout.html.twig' %} {% block toolbar %} {% set icon %} - {{ include('profiler/icon.svg') }} + {{ include('profiler/lock.svg') }} {{ collector.voterCalls|length }} {% endset %} {% set text %}
- Voter Calls + Permission votes {{ collector.voterCalls|length }}
{% endset %} @@ -19,18 +19,18 @@ {% block menu %} - {{ include('profiler/icon.svg') }} - Voter + {{ include('profiler/lock.svg') }} + Votes {{ collector.voterCalls|length }} {% endblock %} {% block panel %} -

PermissionVoter Calls

+

Permission votes

{% if collector.voterCalls is empty %}
-

No calls to PermissionVoter during this request.

+

No votes yet.

{% else %} diff --git a/backend/tests/Unit/Instrumentation/DataCollector/PermissionVotesCollectorTest.php b/backend/tests/Unit/Instrumentation/DataCollector/PermissionVotesCollectorTest.php new file mode 100644 index 0000000..7ef6f2b --- /dev/null +++ b/backend/tests/Unit/Instrumentation/DataCollector/PermissionVotesCollectorTest.php @@ -0,0 +1,451 @@ +getName()); + } + + #[Test] + public function should_return_empty_voter_calls_initially(): void + { + $collector = new PermissionVotesCollector(); + + $calls = $collector->getVoterCalls(); + + self::assertSame([], $calls); + } + + #[Test] + public function should_add_voter_call_with_string_subject(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', 'string', true); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('VIEW', $calls[0]['attribute']); + self::assertSame('string', $calls[0]['subject']); + self::assertTrue($calls[0]['hasPermission']); + } + + #[Test] + public function should_add_voter_call_with_integer_subject(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('EDIT', 123, false); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('EDIT', $calls[0]['attribute']); + self::assertSame('integer', $calls[0]['subject']); + self::assertFalse($calls[0]['hasPermission']); + } + + #[Test] + public function should_add_voter_call_with_array_subject(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('DELETE', [], true); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('DELETE', $calls[0]['attribute']); + self::assertSame('array', $calls[0]['subject']); + self::assertTrue($calls[0]['hasPermission']); + } + + #[Test] + public function should_add_voter_call_with_object_subject(): void + { + $collector = new PermissionVotesCollector(); + $object = new \stdClass(); + + $collector->addVoterCall('APPROVE', $object, true); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('APPROVE', $calls[0]['attribute']); + self::assertSame('stdClass', $calls[0]['subject']); + self::assertTrue($calls[0]['hasPermission']); + } + + #[Test] + public function should_add_multiple_voter_calls(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('EDIT', 'string', false); + $collector->addVoterCall('DELETE', 'array', true); + + $calls = $collector->getVoterCalls(); + self::assertCount(3, $calls); + self::assertSame('VIEW', $calls[0]['attribute']); + self::assertSame('EDIT', $calls[1]['attribute']); + self::assertSame('DELETE', $calls[2]['attribute']); + } + + #[Test] + public function should_collect_does_nothing(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + + $collector->collect(Request::create('/'), new Response()); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + } + + #[Test] + public function should_collect_with_exception_does_nothing(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + + $exception = new \Exception('Test exception'); + $collector->collect(Request::create('/'), new Response(), $exception); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + } + + #[Test] + public function should_reset_voter_calls(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('EDIT', 'string', false); + + self::assertCount(2, $collector->getVoterCalls()); + + $collector->reset(); + + self::assertCount(0, $collector->getVoterCalls()); + } + + #[Test] + public function should_get_attribute_stats_with_single_attribute(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('VIEW', 'integer', true); + $collector->addVoterCall('VIEW', 'array', false); + + $stats = $collector->getAttributeStats(); + + self::assertCount(1, $stats); + self::assertSame(3, $stats['VIEW']); + } + + #[Test] + public function should_get_attribute_stats_with_multiple_attributes(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('VIEW', 'integer', true); + $collector->addVoterCall('EDIT', 'string', false); + $collector->addVoterCall('EDIT', 'integer', false); + $collector->addVoterCall('EDIT', 'array', false); + $collector->addVoterCall('DELETE', 'string', true); + + $stats = $collector->getAttributeStats(); + + self::assertCount(3, $stats); + self::assertSame(2, $stats['VIEW']); + self::assertSame(3, $stats['EDIT']); + self::assertSame(1, $stats['DELETE']); + } + + #[Test] + public function should_get_empty_attribute_stats_when_no_calls(): void + { + $collector = new PermissionVotesCollector(); + + $stats = $collector->getAttributeStats(); + + self::assertSame([], $stats); + } + + #[Test] + public function should_get_chart_labels_matching_attributes(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('EDIT', 'string', false); + $collector->addVoterCall('DELETE', 'array', true); + + $labels = $collector->getChartLabels(); + + self::assertCount(3, $labels); + self::assertContains('VIEW', $labels); + self::assertContains('EDIT', $labels); + self::assertContains('DELETE', $labels); + } + + #[Test] + public function should_get_empty_chart_labels_when_no_calls(): void + { + $collector = new PermissionVotesCollector(); + + $labels = $collector->getChartLabels(); + + self::assertSame([], $labels); + } + + #[Test] + public function should_get_chart_data_matching_attribute_counts(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('VIEW', 'integer', true); + $collector->addVoterCall('EDIT', 'string', false); + $collector->addVoterCall('EDIT', 'integer', false); + $collector->addVoterCall('EDIT', 'array', false); + + $data = $collector->getChartData(); + + self::assertCount(2, $data); + // Data values should match the counts (order may vary due to array_keys/array_values) + sort($data); + self::assertSame([2, 3], $data); + } + + #[Test] + public function should_get_empty_chart_data_when_no_calls(): void + { + $collector = new PermissionVotesCollector(); + + $data = $collector->getChartData(); + + self::assertSame([], $data); + } + + #[Test] + public function should_handle_null_subject(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', null, true); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('NULL', $calls[0]['subject']); + } + + #[Test] + public function should_handle_boolean_subject(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', true, false); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('boolean', $calls[0]['subject']); + } + + #[Test] + public function should_handle_float_subject(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', 3.14, true); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('double', $calls[0]['subject']); + } + + #[Test] + public function should_handle_resource_subject(): void + { + $collector = new PermissionVotesCollector(); + $stream = fopen('php://memory', 'r'); + + if (false === $stream) { + self::markTestSkipped('Could not create stream resource'); + } + + try { + $collector->addVoterCall('VIEW', $stream, true); + + $calls = $collector->getVoterCalls(); + self::assertCount(1, $calls); + self::assertSame('resource', $calls[0]['subject']); + } finally { + fclose($stream); + } + } + + #[Test] + public function should_track_permission_grants_and_denials(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('VIEW', 'integer', false); + $collector->addVoterCall('VIEW', 'array', true); + + $calls = $collector->getVoterCalls(); + + $granted = array_filter($calls, static fn ($call) => $call['hasPermission']); + $denied = array_filter($calls, static fn ($call) => !$call['hasPermission']); + + self::assertCount(2, $granted); + self::assertCount(1, $denied); + } + + #[Test] + public function should_preserve_insertion_order_in_voter_calls(): void + { + $collector = new PermissionVotesCollector(); + + $attributes = ['FIRST', 'SECOND', 'THIRD', 'FOURTH', 'FIFTH']; + foreach ($attributes as $attr) { + $collector->addVoterCall($attr, 'string', true); + } + + $calls = $collector->getVoterCalls(); + + foreach ($attributes as $index => $attr) { + self::assertSame($attr, $calls[$index]['attribute']); + } + } + + #[Test] + public function should_count_same_attribute_with_different_subjects(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('VIEW', 'integer', true); + $collector->addVoterCall('VIEW', new \stdClass(), false); + $collector->addVoterCall('VIEW', [], true); + + $stats = $collector->getAttributeStats(); + + self::assertSame(4, $stats['VIEW']); + } + + #[Test] + public function should_handle_same_attribute_and_subject_type_different_permissions(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('VIEW', 'string', false); + $collector->addVoterCall('VIEW', 'string', true); + + $stats = $collector->getAttributeStats(); + + self::assertSame(3, $stats['VIEW']); + } + + #[Test] + public function should_get_chart_labels_and_data_in_consistent_order(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('VIEW', 'integer', true); + $collector->addVoterCall('EDIT', 'string', false); + $collector->addVoterCall('EDIT', 'integer', false); + $collector->addVoterCall('EDIT', 'array', false); + $collector->addVoterCall('DELETE', 'string', true); + + $labels = $collector->getChartLabels(); + $data = $collector->getChartData(); + + self::assertCount(count($labels), $data); + self::assertCount(3, $labels); + self::assertCount(3, $data); + } + + #[Test] + public function should_reset_clears_all_data(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + $collector->addVoterCall('EDIT', 'integer', false); + $collector->addVoterCall('DELETE', 'array', true); + + self::assertNotEmpty($collector->getVoterCalls()); + self::assertNotEmpty($collector->getAttributeStats()); + self::assertNotEmpty($collector->getChartLabels()); + self::assertNotEmpty($collector->getChartData()); + + $collector->reset(); + + self::assertEmpty($collector->getVoterCalls()); + self::assertEmpty($collector->getAttributeStats()); + self::assertEmpty($collector->getChartLabels()); + self::assertEmpty($collector->getChartData()); + } + + #[Test] + public function should_add_calls_after_reset(): void + { + $collector = new PermissionVotesCollector(); + $collector->addVoterCall('VIEW', 'string', true); + + $collector->reset(); + $collector->addVoterCall('EDIT', 'integer', false); + + $calls = $collector->getVoterCalls(); + + self::assertCount(1, $calls); + self::assertSame('EDIT', $calls[0]['attribute']); + } + + #[Test] + public function should_handle_unicode_attribute_names(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('ANZEIGEN', 'string', true); + $collector->addVoterCall('BEARBEITEN', 'string', false); + $collector->addVoterCall('LÖSCHEN', 'string', true); + + $stats = $collector->getAttributeStats(); + + self::assertCount(3, $stats); + self::assertSame(1, $stats['ANZEIGEN']); + self::assertSame(1, $stats['BEARBEITEN']); + self::assertSame(1, $stats['LÖSCHEN']); + } + + #[Test] + public function should_handle_case_sensitive_attribute_names(): void + { + $collector = new PermissionVotesCollector(); + + $collector->addVoterCall('view', 'string', true); + $collector->addVoterCall('VIEW', 'string', false); + $collector->addVoterCall('View', 'string', true); + + $stats = $collector->getAttributeStats(); + + self::assertCount(3, $stats); + self::assertSame(1, $stats['view']); + self::assertSame(1, $stats['VIEW']); + self::assertSame(1, $stats['View']); + } +}