|
|
@@ -0,0 +1,106 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Tests\Integration\Auth;
|
|
|
+
|
|
|
+use App\Domain\Auth\Role;
|
|
|
+use App\Infrastructure\Auth\RoleMappingRepository;
|
|
|
+use App\Tests\Integration\Support\AppTestCase;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SEC_REVIEW F51 — `RoleMappingRepository::resolveRole` builds an IN
|
|
|
+ * clause from `count($groupIds)` placeholders. The PHPDoc declares
|
|
|
+ * `list<string>` but PHP doesn't enforce that at runtime; a future
|
|
|
+ * caller could pass a mixed array and break the placeholder/bind
|
|
|
+ * invariant. The repository now `array_filter`s to strings and
|
|
|
+ * re-keys with `array_values` before generating placeholders.
|
|
|
+ */
|
|
|
+final class RoleMappingRepositoryTest extends AppTestCase
|
|
|
+{
|
|
|
+ public function testResolvesHighestRoleAcrossMatchingGroups(): void
|
|
|
+ {
|
|
|
+ $this->seedMapping('group-viewer', Role::Viewer);
|
|
|
+ $this->seedMapping('group-admin', Role::Admin);
|
|
|
+
|
|
|
+ $repo = new RoleMappingRepository($this->db);
|
|
|
+ self::assertSame(
|
|
|
+ Role::Admin,
|
|
|
+ $repo->resolveRole(['group-viewer', 'group-admin'], Role::Viewer),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testFallsBackToDefaultWhenNoGroupMatches(): void
|
|
|
+ {
|
|
|
+ $repo = new RoleMappingRepository($this->db);
|
|
|
+ self::assertSame(
|
|
|
+ Role::Viewer,
|
|
|
+ $repo->resolveRole(['unmapped-group'], Role::Viewer),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testEmptyListReturnsDefault(): void
|
|
|
+ {
|
|
|
+ $repo = new RoleMappingRepository($this->db);
|
|
|
+ self::assertSame(Role::Viewer, $repo->resolveRole([], Role::Viewer));
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testNonStringEntriesAreFilteredOut(): void
|
|
|
+ {
|
|
|
+ // SEC_REVIEW F51: a mixed array (e.g. PHPDoc lied, callsite
|
|
|
+ // pushed an int / null / bool by mistake) must not crash the
|
|
|
+ // placeholder math or change the result. The non-string
|
|
|
+ // entries are discarded; the remaining strings drive the
|
|
|
+ // lookup.
|
|
|
+ $this->seedMapping('group-admin', Role::Admin);
|
|
|
+
|
|
|
+ $repo = new RoleMappingRepository($this->db);
|
|
|
+ /** @var list<string> $groups @phpstan-ignore-line — exercising the runtime guard */
|
|
|
+ $groups = ['group-admin', 42, null, true, ['nested']];
|
|
|
+
|
|
|
+ self::assertSame(
|
|
|
+ Role::Admin,
|
|
|
+ $repo->resolveRole($groups, Role::Viewer),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testAllNonStringEntriesFallsBackToDefault(): void
|
|
|
+ {
|
|
|
+ // After filtering, the list is empty — caller meant something
|
|
|
+ // but typed nothing usable; treat as "no groups → default".
|
|
|
+ $repo = new RoleMappingRepository($this->db);
|
|
|
+ /** @var list<string> $groups @phpstan-ignore-line — exercising the runtime guard */
|
|
|
+ $groups = [42, null, true, ['nested']];
|
|
|
+
|
|
|
+ self::assertSame(
|
|
|
+ Role::Viewer,
|
|
|
+ $repo->resolveRole($groups, Role::Viewer),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testHashWithGapsInIndicesIsAccepted(): void
|
|
|
+ {
|
|
|
+ // Another PHPDoc-lies case: caller passed a hash whose keys
|
|
|
+ // aren't 0..n-1. `array_values(array_filter(...))` re-keys
|
|
|
+ // before the placeholder/bind invariant is built.
|
|
|
+ $this->seedMapping('group-operator', Role::Operator);
|
|
|
+
|
|
|
+ $repo = new RoleMappingRepository($this->db);
|
|
|
+ /** @var list<string> $groups @phpstan-ignore-line — exercising the runtime guard */
|
|
|
+ $groups = [5 => 'group-operator', 12 => 'unmapped'];
|
|
|
+
|
|
|
+ self::assertSame(
|
|
|
+ Role::Operator,
|
|
|
+ $repo->resolveRole($groups, Role::Viewer),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ private function seedMapping(string $groupId, Role $role): void
|
|
|
+ {
|
|
|
+ $this->db->insert('oidc_role_mappings', [
|
|
|
+ 'group_id' => $groupId,
|
|
|
+ 'role' => $role->value,
|
|
|
+ 'created_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'),
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+}
|