Merge pull request 'Security hardening: booking auth, offering exposure, payments, invites (#31–#37)' (#38) from feature/security-fixes into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 47s
CI / Tests (PHP 8.2) (push) Successful in 51s
CI / Coding Standards (push) Successful in 58s
CI / PHPStan (push) Successful in 1m2s
CI / Tests (PHP 8.3) (push) Successful in 1m41s
CI / Build Plugin Zip (push) Successful in 55s

Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
2026-06-09 20:11:34 +00:00
18 changed files with 437 additions and 43 deletions
+34
View File
@@ -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.
*
+3 -3
View File
@@ -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.
+19 -5
View File
@@ -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,
+16 -4
View File
@@ -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;
}
/**
+44 -9
View File
@@ -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() );
+12 -3
View File
@@ -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<string, mixed>
*/
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;
}
}
+12 -2
View File
@@ -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).
+16 -5
View File
@@ -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'] ?? '' ) ) );
+11 -1
View File
@@ -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.
*/
+10 -1
View File
@@ -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).
*/
+1 -1
View File
@@ -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 );
+7 -1
View File
@@ -73,10 +73,16 @@ if (! defined('ABSPATH')) {
</thead>
<tbody>
<?php $linkBase = $registrationPageUrl !== '' ? $registrationPageUrl : home_url('/'); ?>
<?php $now = current_time('mysql'); ?>
<?php foreach ($pendingInvites as $invite) : ?>
<?php $link = esc_url(add_query_arg('us_invite', $invite->token, $linkBase)); ?>
<tr>
<td><?php echo esc_html($invite->email); ?></td>
<td>
<?php echo esc_html($invite->email); ?>
<?php if ($invite->isExpired($now)) : ?>
<span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span>
<?php endif; ?>
</td>
<td>
<input type="text" class="large-text code" readonly value="<?php echo esc_attr($link); ?>" onclick="this.select()">
</td>
+6 -4
View File
@@ -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')) {
</tr>
<tr>
<th><label for="secret_key"><?php esc_html_e('Secret key', 'unsupervised-schedular'); ?></label></th>
<td><input type="password" name="secret_key" id="secret_key" class="regular-text" value="<?php echo esc_attr($secretKey); ?>" autocomplete="off"></td>
<td>
<input type="password" name="secret_key" id="secret_key" class="regular-text" value="" autocomplete="off" placeholder="<?php echo esc_attr($secretKeySet ? esc_html__('Saved — leave blank to keep', 'unsupervised-schedular') : ''); ?>">
</td>
</tr>
<tr>
<th><label for="webhook_secret"><?php esc_html_e('Webhook signing secret', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="password" name="webhook_secret" id="webhook_secret" class="regular-text" value="<?php echo esc_attr($webhookSecret); ?>" autocomplete="off">
<input type="password" name="webhook_secret" id="webhook_secret" class="regular-text" value="" autocomplete="off" placeholder="<?php echo esc_attr($webhookSecretSet ? esc_html__('Saved — leave blank to keep', 'unsupervised-schedular') : ''); ?>">
<p class="description">
<?php esc_html_e('In the Stripe Dashboard, add a webhook endpoint for the payment_intent.succeeded and payment_intent.payment_failed events pointing at:', 'unsupervised-schedular'); ?><br>
<code><?php echo esc_html($webhookUrl); ?></code><br>
+35
View File
@@ -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();
@@ -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
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Booking;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Booking\BookingEndpoint;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class BookingEndpointTest extends TestCase
{
private AvailabilityRepository $availability;
private BookingRepository $bookings;
private OfferingRepository $offerings;
private RegistrationGate $gate;
private PaymentService $payments;
private BookingEndpoint $endpoint;
protected function setUp(): void
{
parent::setUp();
Functions\when('absint')->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']);
}
}
+15
View File
@@ -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);
+51
View File
@@ -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<string, mixed> $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