From 5140e76347b4aa861ee6bfca2692ffd2ec4e3143 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Tue, 9 Jun 2026 17:00:54 -0300 Subject: [PATCH] Harden booking, offering exposure, payments, and invites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes from a pen-test review (issues #31–#37): - #31 Booking no longer trusts a client-supplied offering_id: a slot-tied offering is authoritative and any offering used must belong to the slot's instructor, closing a free/misrouted-payment bypass. - #34 Availability slot creation rejects an offering the instructor does not own (AvailabilityEndpoint now takes OfferingRepository). - #32 Offering/question/policy listing endpoints now require book_lesson instead of being public (no anonymous consumer exists); Offering::toArray also omits etransfer_email from listings as defense-in-depth. - #33 Slots are claimed atomically (UPDATE ... WHERE is_booked = 0) before a lesson is inserted, preventing a double-booking race. - #35 A single weekly booking is capped (MAX_WEEKLY_OCCURRENCES) and only creates lessons for slots it actually claimed. - #36 Stripe secret/webhook keys are write-only in the settings UI and a blank submit keeps the stored value; secrets are never echoed back into HTML. - #37 Pending invites expire after 14 days (Invite::isAcceptable), enforced at registration and surfaced on the admin invites list. Adds BookingEndpointTest plus Invite/Offering/AvailabilityRepository coverage and minimal WP_REST_Request/WP_REST_Response stubs. composer test (200), lint, and cs all green. Co-Authored-By: Claude Opus 4.8 --- src/Auth/Invite.php | 34 +++++ src/Auth/RegistrationPage.php | 6 +- src/Availability/AvailabilityEndpoint.php | 24 +++- src/Availability/AvailabilityRepository.php | 20 ++- src/Booking/BookingEndpoint.php | 53 +++++-- src/Offering/Offering.php | 15 +- src/Offering/OfferingEndpoint.php | 14 +- src/Payment/StudioSettings.php | 21 ++- src/Policy/PolicyEndpoint.php | 12 +- src/Registration/QuestionEndpoint.php | 11 +- src/RestRegistrar.php | 2 +- templates/admin/invites.php | 8 +- templates/admin/settings.php | 10 +- tests/Unit/Auth/InviteTest.php | 35 +++++ .../AvailabilityRepositoryTest.php | 17 ++- tests/Unit/Booking/BookingEndpointTest.php | 132 ++++++++++++++++++ tests/Unit/Offering/OfferingTest.php | 15 ++ tests/bootstrap.php | 51 +++++++ 18 files changed, 437 insertions(+), 43 deletions(-) create mode 100644 tests/Unit/Booking/BookingEndpointTest.php diff --git a/src/Auth/Invite.php b/src/Auth/Invite.php index 871080f..a429e2f 100644 --- a/src/Auth/Invite.php +++ b/src/Auth/Invite.php @@ -16,6 +16,12 @@ class Invite { */ public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_ACCEPTED, self::STATUS_REVOKED ]; + /** + * Days a pending invite remains usable after it is created. Limits the window + * in which a leaked or forwarded invitation link can be redeemed. + */ + public const EXPIRY_DAYS = 14; + public function __construct( public readonly string $email, public readonly string $token, @@ -24,6 +30,7 @@ class Invite { public readonly ?int $invitedBy = null, public readonly ?int $acceptedUserId = null, public readonly ?string $acceptedAt = null, + public readonly ?string $createdAt = null, public readonly ?int $id = null, ) {} @@ -36,6 +43,7 @@ class Invite { invitedBy: null !== $row->invited_by ? (int) $row->invited_by : null, acceptedUserId: null !== $row->accepted_user_id ? (int) $row->accepted_user_id : null, acceptedAt: $row->accepted_at, + createdAt: $row->created_at ?? null, id: (int) $row->id, ); } @@ -44,6 +52,32 @@ class Invite { return self::STATUS_PENDING === $this->status; } + /** + * Whether the invite was created more than {@see EXPIRY_DAYS} ago, measured + * against the supplied current `Y-m-d H:i:s` timestamp. An invite with no + * known creation time is treated as not expired. + */ + public function isExpired( string $now ): bool { + if ( null === $this->createdAt ) { + return false; + } + + $created = strtotime( $this->createdAt ); + $current = strtotime( $now ); + if ( false === $created || false === $current ) { + return false; + } + + return ( $current - $created ) > self::EXPIRY_DAYS * 86400; + } + + /** + * Whether this invite can still be redeemed: pending and not expired. + */ + public function isAcceptable( string $now ): bool { + return $this->isPending() && ! $this->isExpired( $now ); + } + /** * Returns a plain array representation of the invite. * diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index bcafff2..8fc96a8 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -45,7 +45,7 @@ class RegistrationPage { } $policyForms = $this->signupPolicies(); - $canRegister = null !== $invite && $invite->isPending(); + $canRegister = null !== $invite && $invite->isAcceptable( current_time( 'mysql' ) ); ob_start(); include USC_PLUGIN_DIR . 'templates/frontend/register-page.php'; @@ -82,8 +82,8 @@ class RegistrationPage { * message string on failure. */ private function handleSubmit( ?Invite $invite ): string|bool { - if ( null === $invite || ! $invite->isPending() ) { - return esc_html__( 'This invitation is invalid or has already been used.', 'unsupervised-schedular' ); + if ( null === $invite || ! $invite->isAcceptable( current_time( 'mysql' ) ) ) { + return esc_html__( 'This invitation is invalid, expired, or has already been used.', 'unsupervised-schedular' ); } // The submit nonce is verified by the caller (render) before this runs. diff --git a/src/Availability/AvailabilityEndpoint.php b/src/Availability/AvailabilityEndpoint.php index 3b8e308..049ec4d 100644 --- a/src/Availability/AvailabilityEndpoint.php +++ b/src/Availability/AvailabilityEndpoint.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace Unsupervised\Schedular\Availability; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Offering\OfferingRepository; class AvailabilityEndpoint { - public function __construct( private AvailabilityRepository $repository ) {} + public function __construct( + private AvailabilityRepository $repository, + private OfferingRepository $offerings, + ) {} public function registerRoutes( string $route_namespace ): void { register_rest_route( @@ -102,12 +106,22 @@ class AvailabilityEndpoint { return new \WP_REST_Response( array_map( fn( AvailabilitySlot $s ) => $s->toArray(), $slots ), 200 ); } - public function create( \WP_REST_Request $request ): \WP_REST_Response { - $offeringId = absint( $request->get_param( 'offering_id' ) ); - $duration = absint( $request->get_param( 'duration_minutes' ) ); + public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $instructorId = get_current_user_id(); + $offeringId = absint( $request->get_param( 'offering_id' ) ); + $duration = absint( $request->get_param( 'duration_minutes' ) ); + + // A slot may only be tied to an offering the instructor owns, so it can + // never inherit another instructor's price or payment routing at booking. + if ( $offeringId > 0 ) { + $offering = $this->offerings->findById( $offeringId ); + if ( null === $offering || $offering->instructorId !== $instructorId ) { + return new \WP_Error( 'invalid_offering', __( 'That offering is not available.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + } $slot = new AvailabilitySlot( - instructorId: get_current_user_id(), + instructorId: $instructorId, startDt: (string) $request->get_param( 'start_dt' ), endDt: (string) $request->get_param( 'end_dt' ), durationMinutes: $duration > 0 ? $duration : 60, diff --git a/src/Availability/AvailabilityRepository.php b/src/Availability/AvailabilityRepository.php index a368978..5d6e32b 100644 --- a/src/Availability/AvailabilityRepository.php +++ b/src/Availability/AvailabilityRepository.php @@ -165,14 +165,26 @@ class AvailabilityRepository { return $row ? AvailabilitySlot::fromRow( $row ) : null; } - public function markBooked( int $id ): bool { - return (bool) $this->db->update( + /** + * Atomically claim an unbooked slot. The `is_booked = 0` guard in the WHERE + * clause makes this the single point of truth for reserving a slot: only one + * concurrent request can transition it from unbooked to booked, so two students + * cannot book (and be charged for) the same slot. Returns true only when this + * call is the one that claimed it. + */ + public function claim( int $id ): bool { + $updated = $this->db->update( $this->table, [ 'is_booked' => 1 ], - [ 'id' => $id ], + [ + 'id' => $id, + 'is_booked' => 0, + ], [ '%d' ], - [ '%d' ] + [ '%d', '%d' ] ); + + return 1 === $updated; } /** diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 959c34c..81b1e8b 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -13,6 +13,12 @@ use Unsupervised\Schedular\Registration\RegistrationGate; class BookingEndpoint { + /** + * The most occurrences a single weekly booking may reserve at once, so one + * student cannot lock up an instructor's entire recurring schedule. + */ + private const MAX_WEEKLY_OCCURRENCES = 12; + public function __construct( private AvailabilityRepository $availability, private BookingRepository $bookings, @@ -108,15 +114,33 @@ class BookingEndpoint { return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } - $offeringId = absint( $request->get_param( 'offering_id' ) ); - if ( 0 === $offeringId ) { - $offeringId = (int) ( $slot->offeringId ?? 0 ); + // Resolve the offering for this booking. A client-supplied offering must + // never override the slot's price or payment routing: when the slot is tied + // to a specific offering that offering is authoritative, and any offering + // used must belong to the slot's instructor. This prevents substituting a + // cheaper/free offering to dodge payment, or another instructor's offering + // to misroute it. + $requestedOfferingId = absint( $request->get_param( 'offering_id' ) ); + $slotOfferingId = (int) ( $slot->offeringId ?? 0 ); + + if ( $slotOfferingId > 0 ) { + if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) { + return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + $offeringId = $slotOfferingId; + } else { + $offeringId = $requestedOfferingId; } + $offering = $offeringId > 0 ? $this->offerings->findById( $offeringId ) : null; if ( $offeringId > 0 && null === $offering ) { return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); } + if ( null !== $offering && $offering->instructorId !== $slot->instructorId ) { + return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] ); + } + $answers = $this->answers( $request ); $acceptedVersionIds = array_map( 'absint', (array) $request->get_param( 'accepted_policy_version_ids' ) ); @@ -142,16 +166,27 @@ class BookingEndpoint { // Weekly reservation across the slot's recurring group; otherwise a single lesson. if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) { - $slotIds = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) ); - $ids = $this->bookings->insertSeries( $template, $slotIds ); - foreach ( $slotIds as $reservedSlotId ) { - $this->availability->markBooked( $reservedSlotId ); + // Claim each occurrence atomically (capped so one booking cannot lock an + // instructor's entire schedule), then create a lesson only for the slots + // this request actually won — never for one already taken by someone else. + $candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) ); + $candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES ); + + $claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) ); + if ( [] === $claimed ) { + return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } + + $ids = $this->bookings->insertSeries( $template, $claimed ); $anchorId = $ids[0] ?? 0; } else { + // Claim before inserting: if another request already took the slot, the + // guarded update reports no rows and we reject rather than double-book. + if ( ! $this->availability->claim( $slotId ) ) { + return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); + } $anchorId = $this->bookings->insert( $template ); - $this->availability->markBooked( $slotId ); - $ids = [ $anchorId ]; + $ids = [ $anchorId ]; } $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index 9a51be8..8123725 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -68,10 +68,14 @@ class Offering { /** * Returns a plain array representation of the offering. * + * The e-transfer destination email is a private payment-routing detail, so it + * is only included when $includeEtransferEmail is true (e.g. manager-only + * responses). The public offerings listing must omit it. + * * @return array */ - public function toArray(): array { - return [ + public function toArray( bool $includeEtransferEmail = true ): array { + $out = [ 'id' => $this->id, 'instructor_id' => $this->instructorId, 'kind' => $this->kind, @@ -86,8 +90,13 @@ class Offering { 'term_start' => $this->termStart, 'term_end' => $this->termEnd, 'schedule_note' => $this->scheduleNote, - 'etransfer_email' => $this->etransferEmail, 'is_active' => $this->isActive, ]; + + if ( $includeEtransferEmail ) { + $out['etransfer_email'] = $this->etransferEmail; + } + + return $out; } } diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index 1991ac9..cc9879c 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -17,7 +17,7 @@ class OfferingEndpoint { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'index' ], - 'permission_callback' => '__return_true', + 'permission_callback' => [ $this, 'canBook' ], 'args' => [ 'instructor_id' => [ 'type' => 'integer', @@ -62,7 +62,8 @@ class OfferingEndpoint { activeOnly: true, ); - return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray(), $offerings ), 200 ); + // Public listing: omit the private e-transfer destination email. + return new \WP_REST_Response( array_map( fn( Offering $o ) => $o->toArray( includeEtransferEmail: false ), $offerings ), 200 ); } public function create( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { @@ -171,6 +172,15 @@ class OfferingEndpoint { return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_OFFERINGS ); } + /** + * Reading the offerings catalogue is only needed by the logged-in student + * booking flow, so it requires the same capability as booking — there is no + * anonymous consumer. + */ + public function canBook(): bool { + return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); + } + /** * An offering may be changed by its owning instructor or by a studio admin * (identified by the studio-only manage_instructors capability). diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php index 7cb8b90..a2438de 100644 --- a/src/Payment/StudioSettings.php +++ b/src/Payment/StudioSettings.php @@ -73,9 +73,12 @@ class StudioSettings { $this->save(); } - $publishableKey = $this->publishableKey(); - $secretKey = $this->secretKey(); - $webhookSecret = $this->webhookSecret(); + $publishableKey = $this->publishableKey(); + // Secrets are write-only in the UI: never echo a stored secret back into the + // page. We only surface whether one is set so the field can be left blank to + // keep the existing value. + $secretKeySet = '' !== $this->secretKey(); + $webhookSecretSet = '' !== $this->webhookSecret(); $webhookUrl = rest_url( 'us-scheduler/v1/payments/webhook' ); $mode = $this->mode(); $currency = $this->currency(); @@ -91,8 +94,16 @@ class StudioSettings { // phpcs:disable WordPress.Security.NonceVerification.Missing $mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) ); update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) ); - update_option( self::OPT_SECRET, sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) ) ); - update_option( self::OPT_WEBHOOK_SECRET, sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) ); + // Secret fields are write-only: a blank submission keeps the stored secret, + // so an admin saving other settings never wipes the keys. + $secretKey = sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) ); + if ( '' !== $secretKey ) { + update_option( self::OPT_SECRET, $secretKey ); + } + $webhookSecret = sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) ); + if ( '' !== $webhookSecret ) { + update_option( self::OPT_WEBHOOK_SECRET, $webhookSecret ); + } update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' ); update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) ); update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ); diff --git a/src/Policy/PolicyEndpoint.php b/src/Policy/PolicyEndpoint.php index 50da003..a82edaf 100644 --- a/src/Policy/PolicyEndpoint.php +++ b/src/Policy/PolicyEndpoint.php @@ -21,7 +21,7 @@ class PolicyEndpoint { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'index' ], - 'permission_callback' => '__return_true', + 'permission_callback' => [ $this, 'canBook' ], ], [ 'methods' => \WP_REST_Server::CREATABLE, @@ -185,6 +185,16 @@ class PolicyEndpoint { return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_POLICIES ); } + /** + * The published-policy listing is only read by the logged-in student + * booking/enrolment flow (the signup gate renders its policies server-side, not + * via this endpoint), so reading it requires the booking capability — there is + * no anonymous consumer. + */ + public function canBook(): bool { + return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); + } + /** * Load the version named in the route and confirm it belongs to the policy. */ diff --git a/src/Registration/QuestionEndpoint.php b/src/Registration/QuestionEndpoint.php index 35fcad5..78ce4db 100644 --- a/src/Registration/QuestionEndpoint.php +++ b/src/Registration/QuestionEndpoint.php @@ -21,7 +21,7 @@ class QuestionEndpoint { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'index' ], - 'permission_callback' => '__return_true', + 'permission_callback' => [ $this, 'canBook' ], ], ] ); @@ -150,6 +150,15 @@ class QuestionEndpoint { return is_user_logged_in() && current_user_can( RoleManager::CAP_MANAGE_QUESTIONS ); } + /** + * An offering's registration questions are only read by the logged-in student + * booking/enrolment flow, so reading them requires the booking capability — + * there is no anonymous consumer. + */ + public function canBook(): bool { + return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); + } + /** * Ensure the offering exists and the caller owns it (or is a studio admin). */ diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index c1180aa..9e81b19 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -34,7 +34,7 @@ class RestRegistrar { private PaymentEndpoint $paymentEndpoint; public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) { - $this->availabilityEndpoint = new AvailabilityEndpoint( $availability ); + $this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings ); $this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService ); $this->offeringEndpoint = new OfferingEndpoint( $offerings ); $this->questionEndpoint = new QuestionEndpoint( $questions, $offerings ); diff --git a/templates/admin/invites.php b/templates/admin/invites.php index 0d7c19f..d2b335a 100644 --- a/templates/admin/invites.php +++ b/templates/admin/invites.php @@ -73,10 +73,16 @@ if (! defined('ABSPATH')) { + token, $linkBase)); ?> - email); ?> + + email); ?> + isExpired($now)) : ?> + + + diff --git a/templates/admin/settings.php b/templates/admin/settings.php index 427bc5d..e405361 100644 --- a/templates/admin/settings.php +++ b/templates/admin/settings.php @@ -7,8 +7,8 @@ if (! defined('ABSPATH')) { /** * @var string $publishableKey - * @var string $secretKey - * @var string $webhookSecret + * @var bool $secretKeySet + * @var bool $webhookSecretSet * @var string $webhookUrl * @var string $mode * @var string $currency @@ -41,12 +41,14 @@ if (! defined('ABSPATH')) { - + + + - +



diff --git a/tests/Unit/Auth/InviteTest.php b/tests/Unit/Auth/InviteTest.php index 75dff7b..fe54d3f 100644 --- a/tests/Unit/Auth/InviteTest.php +++ b/tests/Unit/Auth/InviteTest.php @@ -60,6 +60,41 @@ class InviteTest extends TestCase self::assertTrue($invite->isPending()); } + public function testIsExpiredWhenOlderThanExpiryWindow(): void + { + $created = gmdate('Y-m-d H:i:s', strtotime('2026-06-01 09:00:00')); + $now = '2026-06-20 09:00:00'; // 19 days later, beyond the 14-day window. + + $invite = new Invite('a@b.test', 'tok', createdAt: $created); + + self::assertTrue($invite->isExpired($now)); + self::assertFalse($invite->isAcceptable($now)); + } + + public function testIsNotExpiredWithinExpiryWindow(): void + { + $invite = new Invite('a@b.test', 'tok', createdAt: '2026-06-01 09:00:00'); + $now = '2026-06-10 09:00:00'; // 9 days later, inside the window. + + self::assertFalse($invite->isExpired($now)); + self::assertTrue($invite->isAcceptable($now)); + } + + public function testIsNotExpiredWhenCreatedAtUnknown(): void + { + $invite = new Invite('a@b.test', 'tok'); + + self::assertFalse($invite->isExpired('2030-01-01 00:00:00')); + self::assertTrue($invite->isAcceptable('2030-01-01 00:00:00')); + } + + public function testAcceptedInviteIsNotAcceptableEvenWhenFresh(): void + { + $invite = new Invite('a@b.test', 'tok', status: Invite::STATUS_ACCEPTED, createdAt: '2026-06-01 09:00:00'); + + self::assertFalse($invite->isAcceptable('2026-06-02 09:00:00')); + } + public function testToArrayContainsExpectedKeys(): void { $arr = (new Invite('a@b.test', 'tok', id: 1))->toArray(); diff --git a/tests/Unit/Availability/AvailabilityRepositoryTest.php b/tests/Unit/Availability/AvailabilityRepositoryTest.php index 99f0ae0..896f001 100644 --- a/tests/Unit/Availability/AvailabilityRepositoryTest.php +++ b/tests/Unit/Availability/AvailabilityRepositoryTest.php @@ -116,16 +116,25 @@ class AvailabilityRepositoryTest extends TestCase self::assertSame(5, $slot->instructorId); } - public function testMarkBookedUpdatesRecord(): void + public function testClaimReturnsTrueWhenSlotWasUnbooked(): void { $this->db->shouldReceive('update') ->once() - ->with('wp_us_availability', ['is_booked' => 1], ['id' => 7], ['%d'], ['%d']) + ->with('wp_us_availability', ['is_booked' => 1], ['id' => 7, 'is_booked' => 0], ['%d'], ['%d', '%d']) ->andReturn(1); - $result = $this->repo->markBooked(7); + self::assertTrue($this->repo->claim(7)); + } - self::assertTrue($result); + public function testClaimReturnsFalseWhenSlotAlreadyBooked(): void + { + // The is_booked = 0 guard matches no row once the slot is taken. + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_availability', ['is_booked' => 1], ['id' => 7, 'is_booked' => 0], ['%d'], ['%d', '%d']) + ->andReturn(0); + + self::assertFalse($this->repo->claim(7)); } public function testDeleteReturnsFalseWhenRowNotDeleted(): void diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php new file mode 100644 index 0000000..f794dc5 --- /dev/null +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -0,0 +1,132 @@ +alias(static fn ($v): int => abs((int) $v)); + Functions\when('wp_unslash')->returnArg(); + Functions\when('sanitize_text_field')->returnArg(); + Functions\when('get_current_user_id')->justReturn(5); + + $this->availability = Mockery::mock(AvailabilityRepository::class); + $this->bookings = Mockery::mock(BookingRepository::class); + $this->offerings = Mockery::mock(OfferingRepository::class); + $this->gate = Mockery::mock(RegistrationGate::class); + $this->payments = Mockery::mock(PaymentService::class); + + $this->endpoint = new BookingEndpoint( + $this->availability, + $this->bookings, + $this->offerings, + $this->gate, + $this->payments, + ); + } + + private function slot(int $id, int $instructorId, ?int $offeringId, bool $isBooked = false, ?int $recurrenceGroup = null): AvailabilitySlot + { + return new AvailabilitySlot( + instructorId: $instructorId, + startDt: '2026-07-01 10:00:00', + endDt: '2026-07-01 11:00:00', + offeringId: $offeringId, + isBooked: $isBooked, + recurrenceGroup: $recurrenceGroup, + id: $id, + ); + } + + public function testBookRejectsOfferingFromAnotherInstructor(): void + { + // Generic slot owned by instructor 3; attacker supplies instructor 7's offering. + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(99)->andReturn( + new Offering(instructorId: 7, kind: Offering::KIND_PRIVATE_LESSON, title: 'Cheap', price: 0.0, id: 99) + ); + + // No booking should be created. + $this->availability->shouldNotReceive('claim'); + $this->bookings->shouldNotReceive('insert'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 99]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('offering_mismatch', $result->get_error_code()); + } + + public function testBookRejectsOfferingThatDoesNotMatchSlotTiedOffering(): void + { + // Slot is tied to offering 5; attacker tries to swap in offering 99. + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, 5)); + $this->offerings->shouldNotReceive('findById'); + $this->availability->shouldNotReceive('claim'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 99]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('offering_mismatch', $result->get_error_code()); + } + + public function testBookReturns409WhenSlotClaimFails(): void + { + // Generic slot, no offering; another request wins the claim first. + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(false); + $this->bookings->shouldNotReceive('insert'); + + $request = new \WP_REST_Request(['slot_id' => 10]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('slot_taken', $result->get_error_code()); + } + + public function testBookSucceedsForGenericSlotWithSameInstructorOffering(): void + { + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 0.0, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(77); + $this->gate->shouldReceive('record')->once(); + // Free offering → no payment. + $this->payments->shouldNotReceive('createForRegistration'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(201, $result->get_status()); + self::assertSame([77], $result->get_data()['ids']); + } +} diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index f837eb0..6a04aca 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -84,6 +84,21 @@ class OfferingTest extends TestCase } } + public function testToArrayIncludesEtransferEmailByDefault(): void + { + $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: 'studio@example.com', id: 10); + + self::assertArrayHasKey('etransfer_email', $offering->toArray()); + self::assertSame('studio@example.com', $offering->toArray()['etransfer_email']); + } + + public function testToArrayOmitsEtransferEmailWhenExcluded(): void + { + $offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', etransferEmail: 'studio@example.com', id: 10); + + self::assertArrayNotHasKey('etransfer_email', $offering->toArray(includeEtransferEmail: false)); + } + public function testValidKindAndBillingConstants(): void { self::assertContains(Offering::KIND_PRIVATE_LESSON, Offering::VALID_KINDS); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6c9ecb8..88793dd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -14,6 +14,57 @@ define('USC_PLUGIN_FILE', dirname(__DIR__) . '/unsupervised-schedular.php'); define('USC_PLUGIN_DIR', dirname(__DIR__) . '/'); define('USC_PLUGIN_URL', 'http://example.com/wp-content/plugins/unsupervised-schedular/'); +// Minimal WP_REST_Request stub: a simple parameter bag. +if (! class_exists('WP_REST_Request')) { + class WP_REST_Request + { + /** @param array $params */ + public function __construct(private array $params = []) + { + } + + public function get_param(string $key): mixed + { + return $this->params[$key] ?? null; + } + + public function set_param(string $key, mixed $value): void + { + $this->params[$key] = $value; + } + + public function get_body(): string + { + return (string) ($this->params['__body'] ?? ''); + } + + public function get_header(string $key): string + { + return (string) ($this->params[$key] ?? ''); + } + } +} + +// Minimal WP_REST_Response stub exposing the data and status. +if (! class_exists('WP_REST_Response')) { + class WP_REST_Response + { + public function __construct(public mixed $data = null, public int $status = 200) + { + } + + public function get_data(): mixed + { + return $this->data; + } + + public function get_status(): int + { + return $this->status; + } + } +} + // Minimal WP_Error stub for code under test that returns error objects. if (! class_exists('WP_Error')) { class WP_Error