Files
unsupervised-scheduler/tests/Unit/Auth/RegistrationStatusTest.php
T
KydoimosandClaude Opus 5 1847159e31 Fix five findings from a security assessment of the plugin
The assessment looked for three things: whether students can reach each
other's bookings, whether payment settings can be dodged, and whether the
plugin opens a way into the rest of the install. The student-isolation and
payment paths held up. These are what did not.

- The front-end login form told WordPress not to work out whether the site
  was secure, so on HTTPS every student's session cookie was issued without
  the Secure flag. wp_signon() only derives it from is_ssl() when the second
  argument is left at its default; an explicit false reads like "no
  preference" and is not.

- The update check took whatever download URL the release API returned and
  handed it to core, which unpacks it over the installed plugin. The package
  must now be https on git.unsupervised.ca exactly, compared on the parsed
  host so a lookalike name cannot pass.

- Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook
  signing key in wp_options. Removal is now a choice made in advance on
  Access -> Plugin removal: records are kept unless the owner opts in (with a
  typed confirmation), while credentials and the borrowed core registration
  settings go every time.

- Open registration switches on the site-wide users_can_register and makes
  Student the default role, arming any other signup form on the site to mint
  students who could book and be billed immediately. The pending state is now
  decided once, on user_register, rather than by whichever form created the
  account.

- Cancel and withdraw answered "not yours" differently from "does not exist",
  which let a signed-in student enumerate the studio's bookings. Both now
  give the same 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:55:53 -03:00

168 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class RegistrationStatusTest extends TestCase
{
public function testMarkPendingIssuesHashedTokenAndSetsFlags(): void
{
Functions\when('wp_generate_password')->justReturn('rawtoken');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_TOKEN, hash('sha256', 'rawtoken'));
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_EXPIRES, \Mockery::type('string'));
// Explicitly unconfirmed: the account may already have been held (and so
// counted as confirmed) by the user_register guard, and this signup did
// ask for a confirmation, so it has to be waited for.
Functions\expect('delete_user_meta')
->once()
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED);
self::assertSame('rawtoken', RegistrationStatus::markPending(7));
}
public function testConfirmEmailSetsFlagAndClearsToken(): void
{
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED, '1');
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_TOKEN);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_EXPIRES);
RegistrationStatus::confirmEmail(7);
}
public function testApproveClearsPendingFlags(): void
{
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AWAITING_APPROVAL);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_TOKEN);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_EXPIRES);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AUTO_APPROVE);
RegistrationStatus::approve(7);
}
public function testMarkPendingWithAutoApproveSetsMarkerMeta(): void
{
Functions\when('wp_generate_password')->justReturn('rawtoken');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_TOKEN, hash('sha256', 'rawtoken'));
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_EXPIRES, \Mockery::type('string'));
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AUTO_APPROVE, '1');
Functions\expect('delete_user_meta')
->once()
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED);
self::assertSame('rawtoken', RegistrationStatus::markPending(7, true));
}
/**
* The hold put on a student account that turned up without going through the
* studio's signup form. Awaiting approval — so the booking capability is
* withheld — but counted as email-confirmed, because no confirmation was ever
* asked for and there is no token to answer with: blocking the login instead
* would strand the account with no way forward.
*/
public function testHoldMarksAwaitingApprovalWithoutDemandingAConfirmationThatWasNeverSent(): void
{
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED, '1');
// No token is issued, so nothing is generated and nothing can be spent.
Functions\expect('wp_generate_password')->never();
RegistrationStatus::hold(7);
}
public function testIsAutoApproveReadsMeta(): void
{
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
return $key === RegistrationStatus::META_AUTO_APPROVE ? '1' : '';
});
self::assertTrue(RegistrationStatus::isAutoApprove(7));
self::assertFalse(RegistrationStatus::isAwaitingApproval(7));
}
public function testAwaitingApprovalAndEmailConfirmedReadMeta(): void
{
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
return $key === RegistrationStatus::META_AWAITING_APPROVAL ? '1' : '';
});
self::assertTrue(RegistrationStatus::isAwaitingApproval(7));
self::assertFalse(RegistrationStatus::emailConfirmed(7));
}
public function testUserIdForTokenLooksUpByHashOnly(): void
{
Functions\expect('get_users')
->once()
->with(\Mockery::on(static fn (array $args): bool =>
$args['meta_key'] === RegistrationStatus::META_CONFIRM_TOKEN
&& $args['meta_value'] === hash('sha256', 'rawtoken')))
->andReturn([9]);
self::assertSame(9, RegistrationStatus::userIdForToken('rawtoken'));
}
public function testUserIdForTokenReturnsNullWhenNoMatch(): void
{
Functions\when('get_users')->justReturn([]);
self::assertNull(RegistrationStatus::userIdForToken('rawtoken'));
}
public function testEmptyTokenShortCircuits(): void
{
// get_users must never be called for an empty token.
Functions\expect('get_users')->never();
self::assertNull(RegistrationStatus::userIdForToken(''));
}
public function testTokenExpiredWhenExpiryPassed(): void
{
Functions\when('get_user_meta')->justReturn('2020-01-01 00:00:00');
self::assertTrue(RegistrationStatus::isTokenExpired(7, '2020-01-02 00:00:00'));
}
public function testTokenNotExpiredWhenExpiryInFuture(): void
{
Functions\when('get_user_meta')->justReturn('2020-01-03 00:00:00');
self::assertFalse(RegistrationStatus::isTokenExpired(7, '2020-01-02 00:00:00'));
}
public function testMissingExpiryTreatedAsExpired(): void
{
Functions\when('get_user_meta')->justReturn('');
self::assertTrue(RegistrationStatus::isTokenExpired(7, '2020-01-02 00:00:00'));
}
}