Files
unsupervised-scheduler/tests/Unit/Update/UpdateCheckerTest.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

303 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Update;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Tests\Unit\TestCase;
use Unsupervised\Schedular\Update\UpdateChecker;
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
{
Functions\when('wp_remote_get')->justReturn(['response' => ['code' => $code]]);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('wp_remote_retrieve_response_code')->justReturn($code);
Functions\when('wp_remote_retrieve_body')->justReturn(json_encode($body));
}
private function release(string $tag, array $assets): array
{
return ['tag_name' => $tag, 'assets' => $assets];
}
private function zipAsset(string $name = 'unsupervised-schedular-9.9.9.zip'): array
{
return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL];
}
/**
* The payload provideUpdate() returns when no newer release is offered.
* Core files this under the transient's `no_update` list, which is what
* makes the "Enable auto-updates" toggle appear. USC_VERSION is 1.0.0 in
* the test bootstrap.
*/
private function noUpdatePayload(): array
{
return [
'slug' => 'unsupervised-schedular',
'version' => '1.0.0',
'url' => UpdateChecker::REPO_URL,
'package' => '',
];
}
public function testRegisterHooksHostnameFilter(): void
{
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
Filters\expectAdded('plugin_row_meta')->once();
(new UpdateChecker())->register();
}
public function testFilterRowMetaReplacesViewDetailsLinkForThisPlugin(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
$meta = [
'Version 1.0.0',
'<a href="https://wordpress.org/plugin-install.php?...&plugin=unsupervised-schedular" class="thickbox open-plugin-details-modal">View details</a>',
];
$result = (new UpdateChecker())->filterRowMeta($meta, self::PLUGIN_FILE);
// Core's thickbox modal link is gone.
foreach ($result as $item) {
self::assertStringNotContainsString('open-plugin-details-modal', $item);
}
// Replaced with a new-tab link to the Gitea release tag for USC_VERSION (1.0.0 in tests).
self::assertStringContainsString(
UpdateChecker::REPO_URL . '/releases/tag/v1.0.0',
end($result)
);
self::assertStringContainsString('target="_blank"', end($result));
}
public function testFilterRowMetaLeavesOtherPluginsUntouched(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
$meta = ['<a href="#" class="thickbox open-plugin-details-modal">View details</a>'];
$result = (new UpdateChecker())->filterRowMeta($meta, 'other-plugin/other-plugin.php');
self::assertSame($meta, $result);
}
public function testOffersUpdateWhenReleaseIsNewer(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
// USC_VERSION is 1.0.0 in the test bootstrap.
$this->stubApiResponse(200, $this->release('v9.9.9', [$this->zipAsset()]));
Functions\expect('set_transient')->once()->with(
UpdateChecker::TRANSIENT,
['version' => '9.9.9', 'package' => self::PACKAGE_URL],
\Mockery::type('int')
);
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertSame(
[
'slug' => 'unsupervised-schedular',
'version' => '9.9.9',
'url' => UpdateChecker::REPO_URL,
'package' => self::PACKAGE_URL,
],
$result
);
}
public function testReturnsUpdateUnchangedForOtherPlugins(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\expect('wp_remote_get')->never();
$result = (new UpdateChecker())->provideUpdate(false, [], 'other-plugin/other-plugin.php');
self::assertFalse($result);
}
public function testNoUpdatePayloadWhenReleaseIsNotNewer(): 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('v1.0.0', [$this->zipAsset('unsupervised-schedular-1.0.0.zip')]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
// Current version → core files this under `no_update` so the
// auto-update toggle stays visible; no package to install.
self::assertSame($this->noUpdatePayload(), $result);
}
public function testUsesCachedReleaseWithoutHittingApi(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(['version' => '2.0.0', 'package' => self::PACKAGE_URL]);
Functions\expect('wp_remote_get')->never();
Functions\expect('set_transient')->never();
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertIsArray($result);
self::assertSame('2.0.0', $result['version']);
}
public function testApiFailureIsCachedAndStillReportsUpdateSupport(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('wp_remote_get')->justReturn('irrelevant');
Functions\when('is_wp_error')->justReturn(true);
// A failed lookup is cached too, so a down Gitea is not re-polled
// on every admin page load.
Functions\expect('set_transient')->once()->with(
UpdateChecker::TRANSIENT,
['version' => '', 'package' => ''],
\Mockery::type('int')
);
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
// Even with the lookup failed we still return the `no_update` payload,
// so the auto-update toggle does not flicker away during a Gitea blip.
self::assertSame($this->noUpdatePayload(), $result);
}
public function testNon200ResponseReturnsNoUpdatePayload(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
$this->stubApiResponse(404, ['message' => 'Not Found']);
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertSame($this->noUpdatePayload(), $result);
}
public function testPicksFirstZipAssetAndSkipsOthers(): 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' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'],
$this->zipAsset(),
]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertIsArray($result);
self::assertSame(self::PACKAGE_URL, $result['package']);
}
public function testReleaseWithoutZipAssetOffersNoUpdatePayload(): 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' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'],
]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
// No installable package means no update to offer, but we still keep
// the plugin in `no_update` so the toggle shows.
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);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
Functions\when('wp_remote_get')->justReturn(['response' => ['code' => 200]]);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
Functions\when('wp_remote_retrieve_body')->justReturn('not json');
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertSame($this->noUpdatePayload(), $result);
}
}