Phase 1 of the tenant-marking series. The "which environment is this user working in?" decision was reimplemented in six places, each reading UserActiveContext.db_uuid and then guessing whether the uuid belongs to a clinic or a doctor. Every copy was a place the roles could silently diverge. EntityContextResolver already encoded the right precedence (explicit clinic_uuid > stored active context > role) but only five files used it, and it did not recognise secretaries at all: canActInClinic accepted admins, clinic owners and member doctors, so a secretary's active clinic context always collapsed to unknown. That gap is why SecretaryAccessChecker carried its own copy of the logic. - canActInClinic now also accepts an active DoctorSecretary relation, and a matching canActForDoctor covers the personal-practice branch. - AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker, PatientRecordScopeResolver, MyAppointmentsController and the secretary dashboard all resolve through it now. - PatientRecordScopeResolver keeps only its real responsibility: which doctors' patients are visible inside the resolved environment. - The resolver answers "where"; ClinicDoctorPermissionChecker and SecretaryPermissionChecker still answer "what may you do". Left deliberately untouched, with the reason recorded at each site: SubscriptionController, InventoryController and TenantTagController check ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor sees personal inventory/tags/subscription even inside a clinic. Switching them changes what users see, which is a product decision, not a refactor. AuthController keeps its repository because it writes the active context. tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such row, so getEffectivePlan returned null, every hasFeature() was false and 83 tests across Patient, ClinicService, Insurance and Appointment failed with 403. No schema, route, request, response or error code changed. Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
3.8 KiB
PHP
116 lines
3.8 KiB
PHP
<?php
|
|
|
|
namespace App\Tests;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Subscription\Entity\SubscriptionPlan;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
|
|
|
/**
|
|
* Base class for functional API tests.
|
|
*
|
|
* Provides a booted KernelBrowser, the EntityManager, and helpers to create
|
|
* users and issue JWTs so tests can hit authenticated /api and /oauth endpoints.
|
|
* Runs against the dedicated db_test database (see .env.test / doctrine when@test).
|
|
*/
|
|
abstract class ApiTestCase extends WebTestCase
|
|
{
|
|
protected KernelBrowser $client;
|
|
protected EntityManagerInterface $em;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->client = static::createClient();
|
|
$this->em = static::getContainer()->get(EntityManagerInterface::class);
|
|
$this->ensureFreePlan();
|
|
}
|
|
|
|
/**
|
|
* The «free» plan is SubscriptionService::getEffectivePlan's fallback for any
|
|
* tenant without a paid subscription. Without it every hasFeature() is false
|
|
* and the patient / service / insurance endpoints answer 403 instead of doing
|
|
* their job. db_test is never reset, so the insert is idempotent.
|
|
*
|
|
* Mirrors the row shipped in the dev database.
|
|
*/
|
|
private function ensureFreePlan(): void
|
|
{
|
|
$repo = $this->em->getRepository(SubscriptionPlan::class);
|
|
if ($repo->findOneBy(['name' => 'free']) !== null) {
|
|
return;
|
|
}
|
|
|
|
$this->em->persist(new SubscriptionPlan('free', 0, 1, [
|
|
'patient_records' => true,
|
|
'services' => true,
|
|
'sms_panel' => true,
|
|
'insurance' => true,
|
|
]));
|
|
$this->em->flush();
|
|
}
|
|
|
|
/**
|
|
* Persist a user with the given roles. Mobile is randomised per test to
|
|
* avoid unique-constraint clashes across cases without a full DB reset.
|
|
*/
|
|
protected function createUser(array $roles = ['ROLE_USER'], ?string $mobile = null): User
|
|
{
|
|
// 9 random digits after 09 (full ^09\d{9}$ space) — db_test is never reset,
|
|
// so a narrower space eventually collides on the unique mobile.
|
|
$mobile ??= '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
|
$user = new User($mobile);
|
|
$user->setRoles($roles);
|
|
$user->setStatus(1);
|
|
$this->em->persist($user);
|
|
$this->em->flush();
|
|
|
|
return $user;
|
|
}
|
|
|
|
protected function jwtFor(User $user): string
|
|
{
|
|
return static::getContainer()
|
|
->get(JWTTokenManagerInterface::class)
|
|
->create($user);
|
|
}
|
|
|
|
/**
|
|
* Issue an authenticated JSON request and return the decoded response body.
|
|
*/
|
|
protected function authJson(string $method, string $uri, User $user, array $body = []): array
|
|
{
|
|
$this->client->request(
|
|
$method,
|
|
$uri,
|
|
server: [
|
|
'HTTP_AUTHORIZATION' => 'Bearer ' . $this->jwtFor($user),
|
|
'CONTENT_TYPE' => 'application/json',
|
|
],
|
|
content: $body ? json_encode($body) : null,
|
|
);
|
|
|
|
return json_decode($this->client->getResponse()->getContent(), true) ?? [];
|
|
}
|
|
|
|
protected function responseCode(): int
|
|
{
|
|
return $this->client->getResponse()->getStatusCode();
|
|
}
|
|
|
|
/**
|
|
* Count the SQL queries executed while running $fn. Used to assert that a
|
|
* list endpoint's query count does not grow with the number of rows (N+1).
|
|
*/
|
|
protected function countQueries(callable $fn): int
|
|
{
|
|
$holder = static::getContainer()->get('doctrine.debug_data_holder');
|
|
$holder->reset();
|
|
$fn();
|
|
|
|
return array_sum(array_map('count', $holder->getData()));
|
|
}
|
|
}
|