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
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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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'] ?? '' ) ) );
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user