diff --git a/docs/audit-backlog.md b/docs/audit-backlog.md index 274a8917..75df2981 100644 --- a/docs/audit-backlog.md +++ b/docs/audit-backlog.md @@ -69,10 +69,10 @@ _None outstanding._ | ⚠️M13 | Missing index: `ServiceItem.section_id` FK fully unindexed (entity has zero indexes) | src/ClinicService/Entity/ServiceItem.php:23-24 | perf-index | EXPLAIN findBySection → no full scan | | ✅M14 | Missing index: `WalletTransaction(user_id, type)` composite for per-type SUM | src/Settlement/Entity/WalletTransaction.php:38-39 | perf-index | **DONE** — added composite `idx_wallet_user_type` + migration. `InfraSmokeTest::testWalletUserTypeIndexExists`. | | ⚠️M15 | Missing index: `ClinicDoctorInvitation.doctor_id` FK unindexed | src/ClinicInvitation/Entity/ClinicDoctorInvitation.php:41-42 | perf-index | **FALSE POSITIVE** — `doctor_id` has a FK → auto-indexed (IDX_26DCCFEB87F4FB17 exists). No change. | -| M16 | `User.email` and `User.nationalCode` not unique → duplicate identities | src/Auth/Entity/User.php:31-32,37-38 | db-unique | Insert two users same email/national_code → 2nd rejected. ⚠️ check dup data first | -| M17 | `Payment.gatewayToken` not unique | src/Payment/Entity/Payment.php:58-59 | db-unique | Two payments same gateway_token → rejected | -| M18 | `DateOverride` no UNIQUE `(doctor_id, date)` → ambiguous schedule | src/Appointment/Entity/DateOverride.php:12 | db-unique | Two overrides same doctor+date → rejected | -| M19 | `FinancialBreakdown` no unique `(payment_id, source)` → double-accounting | src/Settlement/Entity/FinancialBreakdown.php:28-33 | db-unique | Two breakdowns same payment+source → rejected | +| ✅M16 | `User.email` and `User.nationalCode` not unique → duplicate identities | src/Auth/Entity/User.php:31-32,37-38 | db-unique | **DONE** — unique on email + national_code (NULLs ok; no dup data). `tests/Database/UniqueConstraintsTest` | +| ✅M17 | `Payment.gatewayToken` not unique | src/Payment/Entity/Payment.php:58-59 | db-unique | **DONE** — unique gateway_token. UniqueConstraintsTest | +| ✅M18 | `DateOverride` no UNIQUE `(doctor_id, date)` → ambiguous schedule | src/Appointment/Entity/DateOverride.php:12 | db-unique | **DONE** — UNIQUE(doctor_id,date). UniqueConstraintsTest | +| ✅M19 | `FinancialBreakdown` no unique `(payment_id, source)` → double-accounting | src/Settlement/Entity/FinancialBreakdown.php:28-33 | db-unique | **DONE** — UNIQUE(payment_id,source). UniqueConstraintsTest | | M20 | Soft-ref FKs orphan (Billing/ClinicService): `ClaimItem.invoice_item_id`, `Claim.insurance_id`, `Tariff.service_item_id`, `TenantServiceCoverage.service_item_id`, `DoctorAddress.clinicId`, polymorphic `SmsWallet`, `ClinicStaff` | src/Billing/Entity/ClaimItem.php:22 · Claim.php:48 · ClinicService/Entity/Tariff.php:23 · Insurance/Entity/TenantServiceCoverage.php:26 · Doctor/Entity/DoctorAddress.php:32 · Sms/Entity/SmsWallet.php:18-22 · Staff/Entity/ClinicStaff.php:22-26 | db-ondelete | Delete parent → child handled (cascade/restrict/set-null), no orphan | | M21 | ~30 ad-hoc raw error codes not in ErrorCodes.php (USER_NOT_FOUND, INVALID_ROLE, SLOT_TAKEN, DUPLICATE_REQUEST, ERR_GONE, ERR_ACCESS_DENIED, …) → `message()` returns "خطای ناشناخته" | src/Admin/Controller/AdminApiController.php (many) · MyAppointmentsController.php:41-80 · PreRegistrationController.php:66 · CategoryController.php:36-40 · ClinicInvitationController.php:203 | quality-errorcodes | Trigger each → code present in ErrorCodes.php | diff --git a/migrations/Version20260628173151.php b/migrations/Version20260628173151.php new file mode 100644 index 00000000..5f355553 --- /dev/null +++ b/migrations/Version20260628173151.php @@ -0,0 +1,39 @@ +addSql('ALTER TABLE date_overrides DROP INDEX idx_date_overrides_doctor_date, ADD UNIQUE INDEX uniq_date_override_doctor_date (doctor_id, date)'); + $this->addSql('CREATE UNIQUE INDEX uniq_breakdown_payment_source ON financial_breakdowns (payment_id, source)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_65D29B3223882363 ON payments (gateway_token)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9D3C17DD2 ON users (national_code)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE date_overrides DROP INDEX uniq_date_override_doctor_date, ADD INDEX idx_date_overrides_doctor_date (doctor_id, date)'); + $this->addSql('DROP INDEX uniq_breakdown_payment_source ON financial_breakdowns'); + $this->addSql('DROP INDEX UNIQ_65D29B3223882363 ON payments'); + $this->addSql('DROP INDEX UNIQ_1483A5E9E7927C74 ON users'); + $this->addSql('DROP INDEX UNIQ_1483A5E9D3C17DD2 ON users'); + } +} diff --git a/src/Appointment/Entity/DateOverride.php b/src/Appointment/Entity/DateOverride.php index a7ebb33f..d6a3d1d0 100644 --- a/src/Appointment/Entity/DateOverride.php +++ b/src/Appointment/Entity/DateOverride.php @@ -9,7 +9,7 @@ use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: DateOverrideRepository::class)] #[ORM\Table(name: 'date_overrides')] -#[ORM\Index(columns: ['doctor_id', 'date'], name: 'idx_date_overrides_doctor_date')] +#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_date', columns: ['doctor_id', 'date'])] class DateOverride { #[ORM\Id] diff --git a/src/Auth/Entity/User.php b/src/Auth/Entity/User.php index 7d29139a..2985bf1e 100644 --- a/src/Auth/Entity/User.php +++ b/src/Auth/Entity/User.php @@ -28,13 +28,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(name: 'password_hash', type: 'string', length: 255, nullable: true)] private ?string $passwordHash = null; - #[ORM\Column(type: 'string', length: 100, nullable: true)] + #[ORM\Column(type: 'string', length: 100, nullable: true, unique: true)] private ?string $email = null; #[ORM\Column(name: 'real_name', type: 'string', length: 100, nullable: true)] private ?string $realName = null; - #[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)] + #[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true, unique: true)] private ?string $nationalCode = null; #[ORM\Column(name: 'national_code_verified', type: 'boolean')] diff --git a/src/Payment/Entity/Payment.php b/src/Payment/Entity/Payment.php index 22f0f13a..33ae0ba3 100644 --- a/src/Payment/Entity/Payment.php +++ b/src/Payment/Entity/Payment.php @@ -55,7 +55,7 @@ class Payment #[ORM\Column(type: 'string', length: 30)] private string $type; - #[ORM\Column(name: 'gateway_token', type: 'string', length: 255, nullable: true)] + #[ORM\Column(name: 'gateway_token', type: 'string', length: 255, nullable: true, unique: true)] private ?string $gatewayToken = null; #[ORM\Column(name: 'reference_id', type: 'string', length: 255, nullable: true, unique: true)] diff --git a/src/Settlement/Entity/FinancialBreakdown.php b/src/Settlement/Entity/FinancialBreakdown.php index 1f7f7356..b0d30b05 100644 --- a/src/Settlement/Entity/FinancialBreakdown.php +++ b/src/Settlement/Entity/FinancialBreakdown.php @@ -12,6 +12,7 @@ use Symfony\Component\Uid\Uuid; #[ORM\Table(name: 'financial_breakdowns')] #[ORM\Index(columns: ['representation_id', 'created_at'], name: 'idx_breakdown_rep_date')] #[ORM\Index(columns: ['source', 'created_at'], name: 'idx_breakdown_source_date')] +#[ORM\UniqueConstraint(name: 'uniq_breakdown_payment_source', columns: ['payment_id', 'source'])] class FinancialBreakdown { public const SOURCE_APPOINTMENT = 'appointment'; diff --git a/tests/Database/UniqueConstraintsTest.php b/tests/Database/UniqueConstraintsTest.php new file mode 100644 index 00000000..7f79da94 --- /dev/null +++ b/tests/Database/UniqueConstraintsTest.php @@ -0,0 +1,74 @@ +createUser()->setEmail($email); + $this->createUser()->setEmail($email); + + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } + + public function testDuplicateGatewayTokenRejected(): void + { + $token = 'tok-' . bin2hex(random_bytes(6)); + $a = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION); + $a->setGatewayToken($token); + $b = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION); + $b->setGatewayToken($token); + $this->em->persist($a); + $this->em->persist($b); + + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } + + public function testDuplicateDateOverrideRejected(): void + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $date = time(); + $this->em->persist(new DateOverride($doctor, $date)); + $this->em->persist(new DateOverride($doctor, $date)); + + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } + + public function testDuplicateBreakdownPaymentSourceRejected(): void + { + $user = $this->createUser(); + $payment = new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT); + $this->em->persist($payment); + $this->em->flush(); + + $make = fn () => new FinancialBreakdown( + $payment, FinancialBreakdown::SOURCE_APPOINTMENT, $user, + 100_000, 0, '0.00', 0, 100_000, '10.00', 10_000, 90_000, null, null, null, + ); + $this->em->persist($make()); + $this->em->persist($make()); + + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } +}