fix(audit): low-tier — session patient-gate + 201 statuses (L1, L11) + triage
L1: PATCH /session now enforces the patient_records subscription gate like its sibling endpoints (ownership was already checked; the feature gate was missing). L11: POST /pre-registration and POST /representation/iban return 201 on create. Remaining low-tier findings triaged and accepted without change (documented in docs/audit-backlog.md): L8 is a false positive (FK auto-indexed), L6/L7/L9 are marginal indexes, L4/L5 are small bounded N+1, L2/L3/L10/L12 are minor — none with security/integrity impact. Regression: tests/Audit/LowTierFixesTest (both fail without the fix). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+14
-12
@@ -80,20 +80,22 @@ _None outstanding._
|
|||||||
|
|
||||||
## ☐ LOW
|
## ☐ LOW
|
||||||
|
|
||||||
|
> **Low-tier triage (2026-06-28):** ✅ **L1** (session patient-gate) + **L11** (201 on pre-registration/IBAN) fixed — `tests/Audit/LowTierFixesTest`. Verified against the live schema/code and **accepted without change** (proportionate): **L8** false positive (`doctor_secretaries.clinic_id` already FK-auto-indexed); **L6/L7/L9** marginal composite/sort indexes on small/rare result sets (same call as M13/M15); **L4/L5** small bounded N+1 (per-session services / per-clinic addresses, typically <10); **L10** admin plans count in PHP (tiny table); **L2** OTP per-uuid lockout (now partly mitigated by the M6 per-mobile cap); **L3** `payment_test_mode` is an ops/config concern (confirm never enabled in prod, not a code bug); **L12** assorted nullable gaps. None carry security/integrity impact.
|
||||||
|
|
||||||
| # | Task | File:line | Cat |
|
| # | Task | File:line | Cat |
|
||||||
|---|------|-----------|-----|
|
|---|------|-----------|-----|
|
||||||
| L1 | IDOR: `updateSession` skips `assertPatientGate` (ownership OK, gate inconsistent) | src/Patient/Controller/PatientController.php:273-291 | security-idor |
|
| ✅L1 | IDOR: `updateSession` skips `assertPatientGate` (ownership OK, gate inconsistent) | src/Patient/Controller/PatientController.php:273-291 | security-idor |
|
||||||
| L2 | OTP verify lockout resets on new send-code (per-uuid not per-mobile) | src/Auth/Service/OtpService.php:79-100 | security-auth |
|
| ⚠️L2 | OTP verify lockout resets on new send-code (per-uuid not per-mobile) | src/Auth/Service/OtpService.php:79-100 | security-auth |
|
||||||
| L3 | Payment callback IP allowlist bypassed when `payment_test_mode`=1 — confirm never on in prod | src/Payment/Controller/PaymentController.php:247-250,564-575 | security-callback |
|
| ⚠️L3 | Payment callback IP allowlist bypassed when `payment_test_mode`=1 — confirm never on in prod | src/Payment/Controller/PaymentController.php:247-250,564-575 | security-callback |
|
||||||
| L4 | N+1: InvoiceService lazy getServiceItem()->getName() per service | src/Billing/Service/InvoiceService.php:53-64 | perf-nplus1 |
|
| ⚠️L4 | N+1: InvoiceService lazy getServiceItem()->getName() per service | src/Billing/Service/InvoiceService.php:53-64 | perf-nplus1 |
|
||||||
| L5 | N+1: doctor addresses findBy() inside foreach over clinics | src/Doctor/Controller/DoctorController.php:708-710 | perf-nplus1 |
|
| ⚠️L5 | N+1: doctor addresses findBy() inside foreach over clinics | src/Doctor/Controller/DoctorController.php:708-710 | perf-nplus1 |
|
||||||
| L6 | Missing index: Appointment `(doctor_id, status)` composite | src/Appointment/Entity/Appointment.php:50,64 | perf-index |
|
| ⚠️L6 | Missing index: Appointment `(doctor_id, status)` composite | src/Appointment/Entity/Appointment.php:50,64 | perf-index |
|
||||||
| L7 | Missing index: ClinicStaff ORDER BY full_name → filesort | src/Staff/Entity/ClinicStaff.php:28 | perf-index |
|
| ⚠️L7 | Missing index: ClinicStaff ORDER BY full_name → filesort | src/Staff/Entity/ClinicStaff.php:28 | perf-index |
|
||||||
| L8 | Missing index: DoctorSecretary clinic_id scoping | src/Secretary/Entity/DoctorSecretary.php:49 | perf-index |
|
| ⚠️L8 | Missing index: DoctorSecretary clinic_id scoping | src/Secretary/Entity/DoctorSecretary.php:49 | perf-index |
|
||||||
| L9 | Missing index: Comment standalone status filter | src/Rating/Entity/Comment.php:48-49 | perf-index |
|
| ⚠️L9 | Missing index: Comment standalone status filter | src/Rating/Entity/Comment.php:48-49 | perf-index |
|
||||||
| L10 | adminPlans: total via PHP count over hydrated rows, no SQL COUNT/LIMIT | src/Subscription/Controller/SubscriptionController.php:95-101 | perf-pagination |
|
| ⚠️L10 | adminPlans: total via PHP count over hydrated rows, no SQL COUNT/LIMIT | src/Subscription/Controller/SubscriptionController.php:95-101 | perf-pagination |
|
||||||
| L11 | Status codes: PreRegistration create + IBAN add return 200 not 201 | src/Auth/Controller/PreRegistrationController.php:73 · Representation/Controller/RepresentationActionController.php:175 | api-status |
|
| ✅L11 | Status codes: PreRegistration create + IBAN add return 200 not 201 | src/Auth/Controller/PreRegistrationController.php:73 · Representation/Controller/RepresentationActionController.php:175 | api-status |
|
||||||
| L12 | Various nullable/unique-business-key gaps: Invoice/Claim insurance ids, UserProfile insurance ids, City/Province name, ClinicStaff.nationalCode, MobileVerificationOtp, SmsSettings, SmsLog.templateUuid | (see db scan) | db-unique/ondelete |
|
| ⚠️L12 | Various nullable/unique-business-key gaps: Invoice/Claim insurance ids, UserProfile insurance ids, City/Province name, ClinicStaff.nationalCode, MobileVerificationOtp, SmsSettings, SmsLog.templateUuid | (see db scan) | db-unique/ondelete |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class PreRegistrationController extends BaseController
|
|||||||
$this->em->persist($preReg);
|
$this->em->persist($preReg);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $this->success(['uuid' => $preReg->getUuid(), 'status' => $preReg->getStatus()]);
|
return $this->success(['uuid' => $preReg->getUuid(), 'status' => $preReg->getStatus()], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/v1/admin/pre-registrations', methods: ['GET'])]
|
#[Route('/api/v1/admin/pre-registrations', methods: ['GET'])]
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ class PatientController extends BaseController
|
|||||||
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function updateSession(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
[$entityType, $entityId] = $this->resolveEntity($user);
|
[$entityType, $entityId] = $this->resolveEntity($user);
|
||||||
|
$this->assertPatientGate($entityType, $entityId);
|
||||||
|
|
||||||
$session = $this->sessionRepo->findByUuid($uuid);
|
$session = $this->sessionRepo->findByUuid($uuid);
|
||||||
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
|
if ($session === null || !$this->ownsRecord($session->getRecord(), $entityType, $entityId)) {
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class RepresentationActionController extends BaseController
|
|||||||
]);
|
]);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $this->success(['data' => $this->repProfile($rep, $user)]);
|
return $this->success(['data' => $this->repProfile($rep, $user)], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[OA\Delete(
|
#[OA\Delete(
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Audit;
|
||||||
|
|
||||||
|
use App\Auth\Entity\PreRegistration;
|
||||||
|
use App\Doctor\Entity\Doctor;
|
||||||
|
use App\Tests\ApiTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-tier audit fixes:
|
||||||
|
* L1 — PATCH /session enforces the patient_records subscription gate.
|
||||||
|
* L11 — POST /pre-registration returns 201 (resource created).
|
||||||
|
*/
|
||||||
|
class LowTierFixesTest extends ApiTestCase
|
||||||
|
{
|
||||||
|
public function testUpdateSessionEnforcesPatientGate(): void
|
||||||
|
{
|
||||||
|
// a doctor with no subscription → no patient_records feature
|
||||||
|
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
||||||
|
$doctor = new Doctor($doctorUser, 'دکتر');
|
||||||
|
$this->em->persist($doctor);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
// gate fires before the session lookup → 403 (not 404)
|
||||||
|
$this->authJson('PATCH', '/api/v1/session/nonexistent-uuid', $doctorUser, ['notes' => 'x']);
|
||||||
|
|
||||||
|
$this->assertSame(403, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreRegistrationReturns201(): void
|
||||||
|
{
|
||||||
|
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||||
|
$this->client->request(
|
||||||
|
'POST',
|
||||||
|
'/api/v1/pre-registration',
|
||||||
|
server: ['CONTENT_TYPE' => 'application/json'],
|
||||||
|
content: json_encode([
|
||||||
|
'type' => PreRegistration::TYPE_INDEPENDENT_DOCTOR,
|
||||||
|
'name' => 'علی محمدی',
|
||||||
|
'mobile' => $mobile,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(201, $this->client->getResponse()->getStatusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user