}> */ public array $calls = []; public function where(...$args): static { $this->calls[] = ['where', $args]; return $this; } public function orWhere(...$args): static { $this->calls[] = ['orWhere', $args]; return $this; } public function whereColumn(...$args): static { $this->calls[] = ['whereColumn', $args]; return $this; } public function orWhereNull(...$args): static { $this->calls[] = ['orWhereNull', $args]; return $this; } }; } /** * A query-builder stand-in that runs every where(Closure) it receives * through a probe and, when it recognises the access-predicate group (the * one that adds the admin/owner branch), captures that closure and * short-circuits the rest of the query build via a sentinel exception. */ private function capturingBuilder(): object { $test = $this; return new class($test) { public ?\Closure $accessClosure = null; private object $test; public function __construct(object $test) { $this->test = $test; } public function where($arg = null): static { if ($arg instanceof \Closure) { $probe = $this->test->probeClosure($arg); foreach ($probe->calls as $call) { if ($call === ['orWhere', ['requestingUser.role', '>=', 40]]) { $this->accessClosure = $arg; // Stop the query build here — the rest is irrelevant. throw new \RuntimeException('LT_ACCESS_CAPTURED'); } } } return $this; } public function __call(string $name, array $args): static { return $this; } }; } /** Run a closure through a fresh clause spy and return the spy. */ public function probeClosure(\Closure $closure): object { $spy = $this->clauseSpy(); $closure($spy); return $spy; } /** * Build a Projects repo whose connection hands back the capturing builder * and whose db helper returns a harmless wrapped column, so a real access * query can be built up to (and only to) the access predicate. */ private function repoWithCapturingQuery(object $builder): ProjectRepository { $connection = $this->make(MySqlConnection::class, [ 'table' => fn ($table = null) => $builder, 'raw' => fn ($value) => $value, ]); $repo = $this->make(ProjectRepository::class, []); $connProp = new \ReflectionProperty(ProjectRepository::class, 'connection'); $connProp->setAccessible(true); $connProp->setValue($repo, $connection); $helperProp = new \ReflectionProperty(ProjectRepository::class, 'dbHelper'); $helperProp->setAccessible(true); $helperProp->setValue($repo, $this->make(DatabaseHelper::class, [ 'wrapColumn' => fn ($column) => '`'.$column.'`', ])); return $repo; } public function test_shared_predicate_grants_member_public_admin_and_delegates_client(): void { $repo = $this->make(ProjectRepository::class, []); $method = new \ReflectionMethod(ProjectRepository::class, 'accessibleProjectPredicate'); $method->setAccessible(true); $q = $this->clauseSpy(); $clientClause = fn ($q2) => null; $method->invoke($repo, $q, 42, $clientClause); // Exactly four access branches — a fifth would silently widen access. $this->assertCount(4, $q->calls, 'The access predicate must add exactly four branches.'); $this->assertSame(['where', ['relation.userId', 42]], $q->calls[0], 'member'); $this->assertSame(['orWhere', ['project.psettings', 'all']], $q->calls[1], 'public'); $this->assertSame('orWhere', $q->calls[2][0], 'client (delegated to caller clause)'); $this->assertSame($clientClause, $q->calls[2][1][0], 'the caller-supplied client clause is forwarded unchanged'); $this->assertSame(['orWhere', ['requestingUser.role', '>=', 40]], $q->calls[3], 'admin/owner blanket'); } public function test_get_projects_user_has_access_to_scopes_client_clause_to_passed_client_id(): void { $builder = $this->capturingBuilder(); $repo = $this->repoWithCapturingQuery($builder); try { $repo->getProjectsUserHasAccessTo(42, 'all', 7); } catch (\RuntimeException $e) { $this->assertSame('LT_ACCESS_CAPTURED', $e->getMessage()); } $this->assertInstanceOf(\Closure::class, $builder->accessClosure, 'access predicate group was not built'); $clientClause = $this->clientClauseFrom($builder->accessClosure); $sub = $this->clauseSpy(); $clientClause($sub); $this->assertSame(['where', ['project.psettings', 'clients']], $sub->calls[0]); $this->assertSame(['where', ['project.clientId', 7]], $sub->calls[1], 'client-shared access must scope to the passed client id'); } public function test_get_user_projects_all_scopes_client_clause_to_own_client_column(): void { $builder = $this->capturingBuilder(); $repo = $this->repoWithCapturingQuery($builder); try { $repo->getUserProjects(42, 'all', null, 'all'); } catch (\RuntimeException $e) { $this->assertSame('LT_ACCESS_CAPTURED', $e->getMessage()); } $this->assertInstanceOf(\Closure::class, $builder->accessClosure, 'access predicate group was not built'); $clientClause = $this->clientClauseFrom($builder->accessClosure); $sub = $this->clauseSpy(); $clientClause($sub); $this->assertSame(['where', ['project.psettings', 'clients']], $sub->calls[0]); $this->assertSame( ['whereColumn', ['project.clientId', 'requestingUser.clientId']], $sub->calls[1], 'getUserProjects(all) must match the requesting user\'s own client column' ); } /** Pull the delegated client clause (the 3rd branch) out of an access closure. */ private function clientClauseFrom(\Closure $accessClosure): \Closure { $spy = $this->probeClosure($accessClosure); $this->assertSame('orWhere', $spy->calls[2][0]); $this->assertInstanceOf(\Closure::class, $spy->calls[2][1][0]); return $spy->calls[2][1][0]; } }