` 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 $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 $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 $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'), ]); } }