Fix five findings from a security assessment of the plugin
CI / Coding Standards (pull_request) Failing after 28s
CI / Tests (PHP 8.5) (pull_request) Failing after 27s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Failing after 39s
CI / Tests (PHP 8.3) (pull_request) Failing after 1m7s
CI / Tests (PHP 8.2) (pull_request) Failing after 1m8s
CI / Static Analysis (pull_request) Successful in 1m17s
CI / Build Plugin Zip (pull_request) Skipped

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]>
This commit is contained in:
2026-09-05 11:31:12 -03:00
co-authored by Claude Opus 5
parent 2781243742
commit e522789104
28 changed files with 1246 additions and 50 deletions
+105
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
use Unsupervised\Schedular\Uninstaller;
class AccessSettingsTest extends TestCase
{
@@ -31,4 +32,108 @@ class AccessSettingsTest extends TestCase
self::assertFalse($settings->adminsAreStudioAdmins());
self::assertTrue($settings->adminsAreInstructors());
}
/**
* Render the page with the given options stored and the given form posted,
* returning the options as they end up.
*
* @param array<string, mixed> $options
* @param array<string, mixed> $post
* @return array<string, mixed>
*/
private function submit(array $options, array $post): array
{
// A regular closure, not an arrow fn: arrow functions capture by value,
// so reads after the save would still see the options as they were.
Functions\when('get_option')->alias(
static function (string $name, mixed $default = false) use (&$options): mixed {
return $options[$name] ?? $default;
}
);
Functions\when('update_option')->alias(
static function (string $name, mixed $value) use (&$options): bool {
$options[$name] = $value;
return true;
}
);
Functions\when('current_user_can')->justReturn(true);
Functions\when('check_admin_referer')->justReturn(true);
Functions\when('wp_unslash')->returnArg();
// The typed confirmation is read through sanitize_key, so "Delete",
// " delete " and "DELETE" all arrive here as the same word.
Functions\when('sanitize_key')->alias(
static fn($v): string => (string) preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $v))
);
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('submit_button')->justReturn('');
$_POST = $post + ['usc_action' => 'save'];
ob_start();
(new AccessSettings())->renderPage();
ob_end_clean();
$_POST = [];
return $options;
}
/**
* Erasing the studio's records cannot be undone, and a checkbox is one stray
* click. The tick alone must not be enough.
*/
public function testTickingDataRemovalWithoutTypingTheWordDoesNotEnableIt(): void
{
$options = $this->submit(
[Uninstaller::OPT_DELETE_DATA => '0'],
['delete_data' => '1', 'grant_studio' => '1']
);
self::assertSame('0', $options[Uninstaller::OPT_DELETE_DATA]);
// The rest of the page still saved: a mistyped confirmation must not
// silently swallow a capability change made in the same submit.
self::assertSame('1', $options[AccessSettings::OPT_GRANT_STUDIO]);
}
public function testTickAndTypedConfirmationTogetherEnableIt(): void
{
$options = $this->submit(
[Uninstaller::OPT_DELETE_DATA => '0'],
['delete_data' => '1', 'delete_data_confirm' => ' DELETE ']
);
self::assertSame('1', $options[Uninstaller::OPT_DELETE_DATA]);
}
public function testANearMissDoesNotCount(): void
{
$options = $this->submit(
[Uninstaller::OPT_DELETE_DATA => '0'],
['delete_data' => '1', 'delete_data_confirm' => 'delete everything']
);
self::assertSame('0', $options[Uninstaller::OPT_DELETE_DATA]);
}
/**
* Once it is on, saving the page for some other reason must not turn it off
* — nor demand the word again for a setting already made.
*/
public function testSavingOtherSettingsLeavesAnEnabledChoiceAlone(): void
{
$options = $this->submit(
[Uninstaller::OPT_DELETE_DATA => '1'],
['delete_data' => '1', 'grant_instructor' => '1']
);
self::assertSame('1', $options[Uninstaller::OPT_DELETE_DATA]);
}
public function testUntickingTurnsItOffWithNoCeremony(): void
{
$options = $this->submit([Uninstaller::OPT_DELETE_DATA => '1'], ['grant_studio' => '1']);
self::assertSame('0', $options[Uninstaller::OPT_DELETE_DATA]);
}
}
+34 -1
View File
@@ -19,7 +19,7 @@ class LoginPageTest extends TestCase
protected function tearDown(): void
{
unset($_POST['us_login']);
unset($_POST['us_login'], $_POST['log'], $_POST['pwd']);
parent::tearDown();
}
@@ -91,4 +91,37 @@ class LoginPageTest extends TestCase
self::assertSame('https://example.com/book/', $this->page->bookingUrl(9));
}
/**
* wp_signon()'s second argument decides whether the auth cookie carries the
* Secure flag, and *only* its default (the empty string) makes it work that
* out from is_ssl(). Passing an explicit false — which reads like "no
* preference" and is not — issues the plain, non-Secure cookie on an HTTPS
* site, leaving every student's session to leak over the first http://
* request to the domain. So the call must pass the credentials and nothing
* else, which is what this asserts: a second argument of any kind fails it.
*/
public function testSignOnDoesNotOverrideWordPressSecureCookieDetection(): void
{
$_POST['us_login'] = '1';
$_POST['log'] = '[email protected]';
$_POST['pwd'] = 'hunter2';
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->justReturn('https://example.com/login/');
Functions\when('sanitize_url')->returnArg();
Functions\when('sanitize_user')->returnArg();
Functions\when('wp_unslash')->returnArg();
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('check_admin_referer')->justReturn(true);
// The failure branch, so the render returns instead of redirecting and
// exiting the test process.
Functions\when('is_wp_error')->justReturn(true);
Functions\expect('wp_signon')->once()->with(\Mockery::type('array'))->andReturn(null);
$html = $this->page->render([]);
self::assertStringContainsString('Invalid username or password.', $html);
}
}
@@ -115,4 +115,62 @@ class RegistrationLoginGateTest extends TestCase
self::assertSame($allcaps, $result);
}
/**
* Describe a user to the `user_register` guard: which role they hold, and
* whether whoever created them is studio staff.
*/
private function stubSignup(string $role, bool $byStaff): void
{
$user = Mockery::mock(\WP_User::class);
$user->roles = [$role];
Functions\when('get_userdata')->justReturn($user);
Functions\when('current_user_can')->justReturn($byStaff);
}
public function testStudentCreatedByAnUnknownSignupFormIsHeldForApproval(): void
{
// Open registration switches the site's own users_can_register on and
// makes Student the default role, so any other signup form on the site
// now mints students. They must not arrive able to book.
$this->stubSignup(RoleManager::STUDENT, byStaff: false);
Functions\expect('update_user_meta')->once()
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
// Counted as confirmed: nobody asked them to confirm, and there is no
// token for them to answer with, so blocking the login would strand them.
Functions\expect('update_user_meta')->once()
->with(7, RegistrationStatus::META_EMAIL_CONFIRMED, '1');
(new RegistrationLoginGate())->holdUnknownSignup(7);
}
public function testStudentCreatedByStaffIsLeftActive(): void
{
// An administrator adding a student from wp-admin could have approved
// them in the next click; making them do so is ceremony.
$this->stubSignup(RoleManager::STUDENT, byStaff: true);
Functions\expect('update_user_meta')->never();
(new RegistrationLoginGate())->holdUnknownSignup(7);
}
public function testNonStudentSignupIsNotHeld(): void
{
$this->stubSignup(RoleManager::INSTRUCTOR, byStaff: false);
Functions\expect('update_user_meta')->never();
(new RegistrationLoginGate())->holdUnknownSignup(7);
}
public function testUnknownUserIdIsIgnored(): void
{
Functions\when('get_userdata')->justReturn(false);
Functions\expect('update_user_meta')->never();
(new RegistrationLoginGate())->holdUnknownSignup(0);
}
}
+36
View File
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Auth\Invite;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\StudioSettings;
@@ -29,6 +30,9 @@ class RegistrationPageTest extends TestCase
/** @var array<string, mixed> */
private array $ctx;
/** @var list<string> User meta keys cleared during the submit under test. */
private array $clearedMeta = [];
protected function setUp(): void
{
parent::setUp();
@@ -52,6 +56,20 @@ class RegistrationPageTest extends TestCase
Functions\when('wp_enqueue_script')->justReturn(null);
Functions\when('wp_localize_script')->justReturn(true);
// Both success branches clear pending meta on the account they just
// created — the invited student is approved outright, the self-signup is
// marked unconfirmed. Recorded rather than counted so a test can say
// which, without every other test having to expect the calls.
$this->clearedMeta = [];
$cleared = &$this->clearedMeta;
Functions\when('delete_user_meta')->alias(
static function (int $userId, string $key) use (&$cleared): bool {
$cleared[] = $key;
return true;
}
);
$invites = Mockery::mock(InviteRepository::class);
$policies = Mockery::mock(PolicyRepository::class);
$questions = Mockery::mock(QuestionRepository::class);
@@ -149,6 +167,24 @@ class RegistrationPageTest extends TestCase
self::assertSame('invite', $this->submit($invite, false));
}
/**
* The registration gate holds every student account created by an
* unauthenticated request, which is what a signup is — so the invite branch
* has to say that this one is different. Without it an invited student is
* logged straight in and then told they cannot book.
*/
public function testInvitedStudentIsApprovedRatherThanLeftAwaitingApproval(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
$this->stubInviteSuccess();
$invite = new Invite(email: '[email protected]', token: 'hash');
self::assertSame('invite', $this->submit($invite, false));
self::assertContains(RegistrationStatus::META_AWAITING_APPROVAL, $this->clearedMeta);
}
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
@@ -22,6 +22,12 @@ class RegistrationStatusTest extends TestCase
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));
}
@@ -63,10 +69,34 @@ class RegistrationStatusTest extends TestCase
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) {
+7 -3
View File
@@ -568,9 +568,11 @@ class BookingEndpointTest extends TestCase
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
}
public function testCancelByAnotherStudentIsForbidden(): void
public function testCancelByAnotherStudentAnswersExactlyLikeAnUnknownLesson(): void
{
// Lesson belongs to student 9; current user is 5.
// Lesson belongs to student 9; current user is 5. The refusal must be
// indistinguishable from testCancelUnknownLessonReturns404 below, or the
// pair of answers tells a student which lesson ids exist.
$lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 3, status: Lesson::STATUS_PENDING, id: 77);
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
$this->bookings->shouldNotReceive('updateStatus');
@@ -579,7 +581,8 @@ class BookingEndpointTest extends TestCase
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('forbidden', $result->get_error_code());
self::assertSame('not_found', $result->get_error_code());
self::assertSame(404, $result->error_data['not_found']['status']);
}
public function testCancelUnknownLessonReturns404(): void
@@ -590,6 +593,7 @@ class BookingEndpointTest extends TestCase
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('not_found', $result->get_error_code());
self::assertSame(404, $result->error_data['not_found']['status']);
}
public function testCancelAlreadyCancelledLessonIsIdempotent(): void
@@ -224,17 +224,19 @@ class EnrollmentEndpointTest extends TestCase
self::assertSame(403, $result->error_data['withdrawal_closed']['status']);
}
public function testWithdrawRejectsAnotherStudentsEnrolment(): void
public function testWithdrawAnswersAnotherStudentsEnrolmentExactlyLikeAnUnknownOne(): void
{
// Enrolment belongs to student 9, but the caller is student 5.
// Enrolment belongs to student 9, but the caller is student 5. The refusal
// must match testWithdrawReturnsNotFoundForUnknownEnrolment below exactly,
// or the two answers together enumerate the studio's enrolments.
$this->enrollments->shouldReceive('findById')->with(3)->andReturn(new Enrollment(8, 9, 3, Enrollment::STATUS_ACTIVE, 41, 3));
$this->enrollments->shouldReceive('updateStatus')->never();
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('forbidden', $result->get_error_code());
self::assertSame(403, $result->error_data['forbidden']['status']);
self::assertSame('not_found', $result->get_error_code());
self::assertSame(404, $result->error_data['not_found']['status']);
}
public function testWithdrawReturnsNotFoundForUnknownEnrolment(): void
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Guardian;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Booking\BookingRepository;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\Guardian\GuardianLink;
@@ -96,6 +97,33 @@ class GuardianServiceTest extends TestCase
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
}
/**
* A child is a student account created by someone who is not staff, so the
* registration gate holds it on `user_register` exactly as it holds an
* anonymous signup. There is nothing here to approve — the account is never
* signed in to, and the guardian in front of us is the approval — so the
* hold has to come off, or every child a family adds lands in the studio's
* review queue.
*/
public function testCreateChildClearsTheHoldTheRegistrationGatePutsOnIt(): void
{
// Standing in for the user_register hook, which has already run by the
// time wp_insert_user() returns.
$meta = &$this->meta;
Functions\when('wp_insert_user')->alias(
static function (array $args) use (&$meta): int {
$meta[42][RegistrationStatus::META_AWAITING_APPROVAL] = '1';
return 42;
}
);
$this->guardians->shouldReceive('insert')->once()->andReturn(7);
self::assertSame(42, $this->service->createChild(5, 'Ada', '2015'));
self::assertArrayNotHasKey(RegistrationStatus::META_AWAITING_APPROVAL, $this->meta[42]);
}
public function testCreateChildRejectsABlankName(): void
{
Functions\expect('wp_insert_user')->never();
+238
View File
@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Schema;
use Unsupervised\Schedular\Uninstaller;
class UninstallerTest extends TestCase
{
/** @var array<string, mixed> Option store the stubs read and write. */
private array $options = [];
/** @var list<string> Options deleted during the run. */
private array $deleted = [];
/** @var list<string> SQL statements sent to the database. */
private array $queries = [];
/** @var list<string> User meta keys deleted for every user. */
private array $metaDeleted = [];
/** @var list<string> Roles removed. */
private array $rolesRemoved = [];
/** @var list<string> Cron hooks cleared. */
private array $hooksCleared = [];
protected function setUp(): void
{
parent::setUp();
$options = &$this->options;
$deleted = &$this->deleted;
$metaDeleted = &$this->metaDeleted;
$rolesRemoved = &$this->rolesRemoved;
$hooksCleared = &$this->hooksCleared;
Functions\when('get_option')->alias(
static fn(string $key, mixed $default = false): mixed => $options[$key] ?? $default
);
Functions\when('update_option')->alias(
static function (string $key, mixed $value) use (&$options): bool {
$options[$key] = $value;
return true;
}
);
Functions\when('delete_option')->alias(
static function (string $key) use (&$options, &$deleted): bool {
unset($options[$key]);
$deleted[] = $key;
return true;
}
);
Functions\when('delete_transient')->justReturn(true);
Functions\when('wp_clear_scheduled_hook')->alias(
static function (string $hook) use (&$hooksCleared): int {
$hooksCleared[] = $hook;
return 0;
}
);
Functions\when('delete_metadata')->alias(
static function (string $type, int $id, string $key) use (&$metaDeleted): bool {
$metaDeleted[] = $key;
return true;
}
);
Functions\when('remove_role')->alias(
static function (string $role) use (&$rolesRemoved): void {
$rolesRemoved[] = $role;
}
);
$db = Mockery::mock(\wpdb::class);
$db->prefix = 'wp_';
$queries = &$this->queries;
$db->shouldReceive('prepare')->andReturnUsing(
static fn(string $sql, mixed ...$args): string => str_replace('%i', (string) $args[0], $sql)
);
$db->shouldReceive('query')->andReturnUsing(
static function (string $sql) use (&$queries): int {
$queries[] = $sql;
return 1;
}
);
$GLOBALS['wpdb'] = $db;
}
protected function tearDown(): void
{
unset($GLOBALS['wpdb']);
parent::tearDown();
}
/**
* A Stripe secret is a credential, not a record. It can be pasted back in
* from the Stripe dashboard in a minute, and a site that no longer has the
* code to use it has no business still holding it — so it goes whether or
* not the studio asked to keep its data.
*/
public function testStripeCredentialsAreForgottenEvenWhenTheDataIsKept(): void
{
$this->options = [
StudioSettings::OPT_SECRET => 'sk_live_secret',
StudioSettings::OPT_WEBHOOK_SECRET => 'whsec_secret',
StudioSettings::OPT_PUBLISHABLE => 'pk_live_key',
StudioSettings::OPT_MODE => 'live',
StudioSettings::OPT_HST_RATE => '13',
];
(new Uninstaller())->run();
self::assertArrayNotHasKey(StudioSettings::OPT_SECRET, $this->options);
self::assertArrayNotHasKey(StudioSettings::OPT_WEBHOOK_SECRET, $this->options);
self::assertArrayNotHasKey(StudioSettings::OPT_PUBLISHABLE, $this->options);
self::assertArrayNotHasKey(StudioSettings::OPT_MODE, $this->options);
// The studio's own settings are records, and stay.
self::assertSame('13', $this->options[StudioSettings::OPT_HST_RATE]);
}
public function testKeepingDataDropsNoTablesAndRemovesNoRoles(): void
{
$this->options = [Uninstaller::OPT_DELETE_DATA => '0'];
(new Uninstaller())->run();
self::assertSame([], $this->queries);
self::assertSame([], $this->metaDeleted);
// A site keeping its data keeps the roles its students hold, or every one
// of them is left with no capabilities until the plugin is reinstalled.
self::assertSame([], $this->rolesRemoved);
}
public function testFullPurgeDropsEveryTableTheSchemaDeclares(): void
{
$this->options = [Uninstaller::OPT_DELETE_DATA => '1'];
(new Uninstaller())->run();
self::assertCount(count(Schema::TABLES), $this->queries);
foreach (Schema::TABLES as $table) {
self::assertContains('DROP TABLE IF EXISTS wp_' . $table, $this->queries);
}
}
public function testFullPurgeClearsSettingsUserMetaAndRoles(): void
{
$this->options = [
Uninstaller::OPT_DELETE_DATA => '1',
'us_schedular_version' => '1.5.6',
StudioSettings::OPT_HST_RATE => '13',
StudioSettings::OPT_CURRENCY => 'CAD',
];
(new Uninstaller())->run();
self::assertContains('us_schedular_version', $this->deleted);
self::assertContains(StudioSettings::OPT_HST_RATE, $this->deleted);
self::assertContains(Uninstaller::OPT_DELETE_DATA, $this->deleted);
// Nothing student-shaped is left hanging off a user account.
self::assertContains('us_payment_method', $this->metaDeleted);
self::assertContains('us_child', $this->metaDeleted);
self::assertContains('us_awaiting_approval', $this->metaDeleted);
self::assertSame(
[RoleManager::STUDIO_ADMIN, RoleManager::INSTRUCTOR, RoleManager::STUDENT],
$this->rolesRemoved
);
}
/**
* Open registration switches the site's own `users_can_register` on and makes
* Student the default role. Leaving those behind would leave the site taking
* public signups into a role that is, one line later, about to stop existing.
*/
public function testCoreRegistrationSettingsAreRestoredFromTheSnapshot(): void
{
$this->options = [
'users_can_register' => '1',
'default_role' => RoleManager::STUDENT,
StudioSettings::OPT_PREV_USERS_CAN_REGISTER => '0',
StudioSettings::OPT_PREV_DEFAULT_ROLE => 'subscriber',
];
(new Uninstaller())->run();
self::assertSame('0', $this->options['users_can_register']);
self::assertSame('subscriber', $this->options['default_role']);
// The snapshot is spent.
self::assertArrayNotHasKey(StudioSettings::OPT_PREV_USERS_CAN_REGISTER, $this->options);
self::assertArrayNotHasKey(StudioSettings::OPT_PREV_DEFAULT_ROLE, $this->options);
}
public function testSiteThatNeverOpenedRegistrationKeepsItsOwnSettings(): void
{
$this->options = ['users_can_register' => '1', 'default_role' => 'contributor'];
(new Uninstaller())->run();
// No snapshot means the plugin never touched these, so nor does this.
self::assertSame('1', $this->options['users_can_register']);
self::assertSame('contributor', $this->options['default_role']);
}
public function testTheScheduledBillingEventIsAlwaysCleared(): void
{
// Deactivation clears it too, and always precedes a delete — but a site
// whose plugin files simply vanished never ran that hook, and a schedule
// pointing at code that is gone is left firing into nothing.
(new Uninstaller())->run();
self::assertSame([ScheduledBillingRunner::HOOK], $this->hooksCleared);
}
public function testSettingIsOffUntilItIsExplicitlyTurnedOn(): void
{
self::assertFalse(Uninstaller::deletesDataOnUninstall());
Uninstaller::setDeletesDataOnUninstall(true);
self::assertTrue(Uninstaller::deletesDataOnUninstall());
Uninstaller::setDeletesDataOnUninstall(false);
self::assertFalse(Uninstaller::deletesDataOnUninstall());
}
}
+68
View File
@@ -13,6 +13,17 @@ class UpdateCheckerTest extends TestCase
private const PLUGIN_FILE = 'unsupervised-schedular/unsupervised-schedular.php';
private const PACKAGE_URL = 'https://git.unsupervised.ca/attachments/abc123';
protected function setUp(): void
{
parent::setUp();
// The package URL is checked against the release host before it is
// offered to core, so every path through provideUpdate() reaches this.
Functions\when('wp_parse_url')->alias(
static fn(string $url, int $component = -1): mixed => parse_url($url, $component)
);
}
/** Stub a successful Gitea API response with the given decoded body. */
private function stubApiResponse(int $code, mixed $body): void
{
@@ -217,6 +228,63 @@ class UpdateCheckerTest extends TestCase
self::assertSame($this->noUpdatePayload(), $result);
}
/**
* A package URL is code core will download and unpack over the installed
* plugin, so an answer naming somewhere other than the release host is not
* an update — whoever gave it, and however plausible the version.
*
* @dataProvider untrustedPackageUrls
*/
public function testPackageHostedAnywhereButTheReleaseHostIsRefused(string $url): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
$this->stubApiResponse(200, $this->release('v9.9.9', [
['name' => 'unsupervised-schedular-9.9.9.zip', 'browser_download_url' => $url],
]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
// Refused exactly as a release with no zip at all: nothing to install,
// and the plugin stays in `no_update` so the toggle does not vanish.
self::assertSame($this->noUpdatePayload(), $result);
}
/** @return array<string, array{string}> */
public static function untrustedPackageUrls(): array
{
return [
'unrelated host' => ['https://evil.test/unsupervised-schedular-9.9.9.zip'],
// Would pass a naive "contains" or "ends with" check.
'lookalike prefix' => ['https://evil-git.unsupervised.ca/plugin.zip'],
'lookalike suffix' => ['https://git.unsupervised.ca.evil.test/plugin.zip'],
'subdomain' => ['https://cdn.git.unsupervised.ca/plugin.zip'],
'credentials in host' => ['https://[email protected]/plugin.zip'],
'plain http' => ['http://git.unsupervised.ca/attachments/abc123'],
'no scheme' => ['git.unsupervised.ca/attachments/abc123'],
'empty' => [''],
];
}
public function testAZipFromTheReleaseHostIsStillOfferedAfterAnUntrustedOne(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
$this->stubApiResponse(200, $this->release('v9.9.9', [
['name' => 'decoy.zip', 'browser_download_url' => 'https://evil.test/decoy.zip'],
$this->zipAsset(),
]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
// The scan does not stop at the first zip it sees — it stops at the
// first one it would actually install.
self::assertIsArray($result);
self::assertSame(self::PACKAGE_URL, $result['package']);
}
public function testMalformedApiBodyOffersNoUpdatePayload(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);