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]>
239 lines
8.6 KiB
PHP
239 lines
8.6 KiB
PHP
<?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());
|
|
}
|
|
}
|