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]);
}
}