Compare commits

..
27 Commits
Author SHA1 Message Date
KydoimosandClaude Opus 5 c4b2b5ccff Sign the automated version bump commit in CI
CI / Coding Standards (pull_request) Successful in 23s
CI / Tests (PHP 8.5) (pull_request) Successful in 27s
CI / Tests (PHP 8.2) (pull_request) Successful in 35s
CI / Static Analysis (pull_request) Successful in 39s
CI / Tests (PHP 8.3) (pull_request) Successful in 40s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / No Debug Code (pull_request) Successful in 35s
CI / Build Plugin Zip (pull_request) Skipped
main now requires signed commits, and Gitea refuses to merge a pull request
carrying an unsigned one, so the post-release bump PR could not be merged
without disabling the protection first.

The runner now signs that commit with a dedicated release-bot SSH key held as
an organisation Actions secret, trusted by the instance through
[repository.signing] TRUSTED_SSH_KEYS. The key Gitea signs merge commits with
lives on the server and no runner can reach it.

The step fails fast when the secret is missing, unparseable or passphrase
protected, and the commit is re-read before pushing so an unsigned one is
caught here rather than days later at merge time.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 20:25:58 -03:00
thatguygriff a4f694a966 Merge pull request 'Bump version to 1.5.7' (#198) from release/bump-1.5.7 into main
CI / Coding Standards (push) Successful in 21s
CI / Tests (PHP 8.1) (push) Successful in 30s
CI / Static Analysis (push) Successful in 36s
CI / Tests (PHP 8.2) (push) Successful in 40s
CI / Tests (PHP 8.5) (push) Successful in 25s
CI / Tests (PHP 8.3) (push) Successful in 36s
CI / No Debug Code (push) Successful in 37s
CI / Build Plugin Zip (push) Successful in 22s
Reviewed-on: #198
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-05 19:45:41 -03:00
Release Bot b9f7c96ce6 Bump version to 1.5.7 and open changelog section 2026-09-05 15:04:47 +00:00
thatguygriff f58585be0d Merge pull request 'Fix five findings from a security assessment of the plugin' (#197) from security/assessment-hardening into main
CI / Coding Standards (push) Successful in 18s
CI / No Debug Code (push) Successful in 6s
CI / Tests (PHP 8.5) (push) Successful in 33s
CI / Static Analysis (push) Successful in 37s
CI / Tests (PHP 8.1) (push) Successful in 37s
CI / Tests (PHP 8.3) (push) Successful in 37s
CI / Tests (PHP 8.2) (push) Successful in 39s
CI / Build Plugin Zip (push) Successful in 12s
Release / Build and Publish Release (push) Successful in 25s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #197
Reviewed-by: James Griffin-Allwood <[email protected]>
2026-09-05 15:00:33 +00:00
KydoimosandClaude Opus 5 ae1a62883e Fix the CI failures in the uninstaller work
CI / Coding Standards (pull_request) Successful in 28s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Successful in 38s
CI / Tests (PHP 8.3) (pull_request) Successful in 40s
CI / Tests (PHP 8.2) (pull_request) Successful in 49s
CI / Static Analysis (pull_request) Successful in 1m0s
CI / Tests (PHP 8.5) (pull_request) Successful in 57s
CI / Build Plugin Zip (pull_request) Skipped
Both were mine, and both were in code the earlier commit could not run.

The four test failures shared one cause: UninstallerTest stubbed get_option
with an arrow function, which captures by value, so every read answered from
a snapshot of the options taken at setUp — before the test set any and before
the run wrote any. Every assertion that depended on reading back what had
just been written therefore saw an empty store. The file's other stubs
already use by-reference closures; this one now does too.

The phpcs error is WordPress.DB.PreparedSQL.NotPrepared on the table drop.
The sniff cannot follow $sql across the null guard that PHPStan requires
(prepare() is nullable), and unlike the repositories — which call through a
typed $this->db property the sniff does not track at all — the uninstaller
calls the global $wpdb, so the sniff sees it. Silenced explicitly, with the
reason.

composer test (996 tests, 2871 assertions), composer lint and composer cs all
pass locally on PHP 8.4.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:55:53 -03:00
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
thatguygriff 74df3f5ba8 Merge pull request 'Bump version to 1.5.6' (#196) from release/bump-1.5.6 into main
CI / Coding Standards (push) Successful in 25s
CI / No Debug Code (push) Successful in 7s
CI / Tests (PHP 8.5) (push) Successful in 36s
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Static Analysis (push) Successful in 46s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / Tests (PHP 8.3) (push) Successful in 49s
CI / Build Plugin Zip (push) Successful in 22s
Reviewed-on: #196
2026-09-05 11:54:29 -03:00
Release Bot f8f211929c Bump version to 1.5.6 and open changelog section 2026-08-25 02:20:56 +00:00
thatguygriff 61a40f0e8d Merge pull request 'Update stripe-php from 17.6.0 to 21.2.1' (#195) from feature/194-stripe-php-21 into main
CI / Coding Standards (push) Successful in 17s
CI / No Debug Code (push) Successful in 1s
CI / Tests (PHP 8.1) (push) Successful in 32s
CI / Tests (PHP 8.5) (push) Successful in 29s
CI / Tests (PHP 8.3) (push) Successful in 42s
CI / Static Analysis (push) Successful in 52s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / Build Plugin Zip (push) Successful in 23s
Release / Build and Publish Release (push) Successful in 20s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #195
2026-08-25 02:18:36 +00:00
thatguygriffandClaude Opus 5 88a8d0ae5c Date the Stripe changelog entry by version rather than by "current"
CI / Coding Standards (pull_request) Successful in 26s
CI / Tests (PHP 8.1) (pull_request) Successful in 28s
CI / No Debug Code (pull_request) Successful in 7s
CI / Tests (PHP 8.2) (pull_request) Successful in 36s
CI / Tests (PHP 8.5) (pull_request) Successful in 34s
CI / Static Analysis (pull_request) Successful in 37s
CI / Tests (PHP 8.3) (pull_request) Successful in 36s
CI / Build Plugin Zip (pull_request) Skipped
"the current Stripe library" is only true until Stripe ships the next major.
A changelog is read long after the release it describes, so name the
versions — 17 to 21, API version 2026-07-29.dahlia — and let those date the
entry.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 23:17:33 -03:00
thatguygriffandClaude Opus 5 e2da45a1b0 Update stripe-php from 17.6.0 to 21.2.1
CI / Coding Standards (pull_request) Successful in 27s
CI / No Debug Code (pull_request) Successful in 6s
CI / Tests (PHP 8.1) (pull_request) Successful in 35s
CI / Tests (PHP 8.2) (pull_request) Successful in 34s
CI / Tests (PHP 8.5) (pull_request) Successful in 24s
CI / Tests (PHP 8.3) (pull_request) Successful in 37s
CI / Static Analysis (pull_request) Successful in 45s
CI / Build Plugin Zip (pull_request) Skipped
Four majors behind on the plugin's only production dependency. No site is
using Stripe yet and the integration has not been verified end to end, so
the pinned API version can move now at close to zero cost — later it would
mean coordinating with live payment traffic.

The library surface this plugin uses is four symbols, all in StripeGateway:
StripeClient, paymentIntents->create, Webhook::constructEvent and Event.
Checked each against the installed v21 by reflection; all present with
compatible signatures. Most of the breakage flagged across 18 to 21 is in
the V2 Events API, which this does not use — webhooks go through the V1
Webhook::constructEvent path.

The pinned Stripe API version moves to 2026-07-29.dahlia.

Verified beyond the suite, which mocks the client: a real HMAC-signed
payload round-trips through Webhook::constructEvent and returns a
Stripe\Event with the expected type and data, and a forged signature still
raises SignatureVerificationException. The 21.0.0 ErrorObject retyping to
null|string produces no new PHPStan findings.

Part of #194

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 23:15:01 -03:00
thatguygriff dfa29745df Merge pull request 'Update the dev dependencies' (#193) from chore/update-dev-dependencies into main
CI / Coding Standards (push) Successful in 13s
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 30s
CI / Tests (PHP 8.5) (push) Successful in 30s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Tests (PHP 8.3) (push) Successful in 45s
CI / Static Analysis (push) Successful in 54s
CI / Build Plugin Zip (push) Successful in 9s
Reviewed-on: #193
2026-08-25 02:10:14 +00:00
thatguygriffandClaude Opus 5 54a8b906f7 Update the dev dependencies
CI / Coding Standards (pull_request) Successful in 20s
CI / No Debug Code (pull_request) Successful in 5s
CI / Static Analysis (pull_request) Successful in 31s
CI / Tests (PHP 8.2) (pull_request) Successful in 31s
CI / Tests (PHP 8.1) (pull_request) Successful in 32s
CI / Tests (PHP 8.3) (pull_request) Successful in 32s
CI / Tests (PHP 8.5) (pull_request) Successful in 35s
CI / Build Plugin Zip (pull_request) Skipped
Routine catch-up on the test and analysis tooling. Every package here is
already allowed by the existing constraints, so composer.json does not move
and no production dependency is touched.

  mockery/mockery              1.6.12  -> 1.6.15
  phpstan/phpstan               2.2.2  -> 2.2.9
  phpunit/phpunit             10.5.63  -> 10.5.64
  hamcrest/hamcrest-php        v2.1.1  -> v3.0.0   (transitive, via mockery)
  myclabs/deep-copy            1.13.4  -> 1.14.0
  nikic/php-parser             v5.7.0  -> v5.8.0
  sebastian/recursion-context   5.0.1  -> 5.0.2

Seven patch releases of PHPStan is the one worth watching, since a static
analyser routinely gains inferences between them. It reports no new errors
here, and PHPCS and the suite are unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 23:08:54 -03:00
thatguygriff b3ed3a67d5 Merge pull request 'Pin the Composer platform to PHP 8.1 and clear the PHPCS security advisories' (#192) from fix/187-composer-platform-pin into main
CI / Coding Standards (push) Successful in 17s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.2) (push) Successful in 32s
CI / Tests (PHP 8.1) (push) Successful in 48s
CI / Static Analysis (push) Successful in 38s
CI / Tests (PHP 8.3) (push) Successful in 47s
CI / Tests (PHP 8.5) (push) Successful in 47s
CI / Build Plugin Zip (push) Successful in 21s
Reviewed-on: #192
2026-08-25 02:08:07 +00:00
thatguygriffandClaude Opus 5 908b7fcd1f Update the coding-standards tooling off three security advisories
CI / Coding Standards (pull_request) Successful in 16s
CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.3) (pull_request) Successful in 21s
CI / Tests (PHP 8.5) (pull_request) Successful in 22s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Static Analysis (pull_request) Successful in 57s
CI / Build Plugin Zip (pull_request) Skipped
composer audit reported three advisories against the PHPCS stack, two rated
high:

  squizlabs/php_codesniffer  3.13.5 -> 3.13.6  CVE-2026-67434, OS command injection
  wp-coding-standards/wpcs   3.3.0  -> 3.4.1   CVE-2026-45293, arbitrary code execution
  phpcsstandards/phpcsutils  1.2.2  -> 1.2.3   CVE-2026-65954, arbitrary code execution

All three are dev-only and none ship in the plugin — bin/build-zip.sh installs
--no-dev — but they execute against repository content on every CI run.

The existing ^3.7 and ^3.0 constraints already allowed the fixed releases, so
composer.json is unchanged. phpcsextra and the codesniffer installer come
along as transitive dependencies. Five packages move, none are added or
removed, and the updated standards report no new violations.

composer audit is now clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:50:34 -03:00
thatguygriffandClaude Opus 5 b17adf02ff Pin the Composer platform to PHP 8.1
CI / Tests (PHP 8.2) (pull_request) Successful in 26s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 29s
CI / Tests (PHP 8.5) (pull_request) Successful in 30s
CI / Coding Standards (pull_request) Successful in 36s
CI / Tests (PHP 8.1) (pull_request) Successful in 46s
CI / Static Analysis (pull_request) Successful in 49s
CI / Build Plugin Zip (pull_request) Skipped
composer.json declares "php": ">=8.1", but resolution ran against whatever
PHP the machine doing the update happened to have. Running composer update
on 8.5 could therefore produce a lock containing a package that requires 8.2
or newer, which installs fine locally and fails on the 8.1 leg of the test
matrix — a lock that is only discovered to be wrong in CI.

config.platform.php makes resolution always target the declared minimum, so
a lock that resolves at all is a lock that installs everywhere the plugin
claims to run.

No package versions change: nothing currently locked requires a PHP newer
than 8.1, so this only records platform-overrides and the new content hash.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:46:11 -03:00
thatguygriff a23490ec80 Merge pull request 'CI: run jobs in shared ci-php images, commit composer.lock' (#191) from feature/187-run-jobs-in-ci-images into main
CI / Coding Standards (push) Successful in 16s
CI / No Debug Code (push) Successful in 5s
CI / Tests (PHP 8.3) (push) Successful in 25s
CI / Tests (PHP 8.5) (push) Successful in 31s
CI / Static Analysis (push) Successful in 37s
CI / Tests (PHP 8.2) (push) Successful in 35s
CI / Tests (PHP 8.1) (push) Successful in 39s
CI / Build Plugin Zip (push) Successful in 9s
Reviewed-on: #191
2026-08-25 01:41:19 +00:00
thatguygriffandClaude Opus 5 1552bf4b5f Move the CI image definitions to the shared ci-php repository
CI / Coding Standards (pull_request) Successful in 14s
CI / Tests (PHP 8.1) (pull_request) Successful in 30s
CI / Tests (PHP 8.2) (pull_request) Successful in 30s
CI / Tests (PHP 8.3) (pull_request) Successful in 29s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.5) (pull_request) Successful in 21s
CI / Static Analysis (pull_request) Successful in 37s
CI / Build Plugin Zip (pull_request) Skipped
The image definition and its publishing workflow were only here because this
was the first project to need them. They are project-agnostic, so they now
live in Unsupervised/ci-php and any PHP project can consume the same tags.
The registry path is unchanged — Gitea container images are named
owner/image, not after the repository that pushes them — so nothing here
needs repointing.

Split the combined quality job into separate phpcs and phpstan jobs. They
were folded together to halve the number of toolchain installs per run; with
the toolchain in the image that saves nothing, and separating them means a
coding-standards failure no longer hides the static analysis result.

Rewrite the workflow comments to describe what the jobs do rather than the
setup step they replaced, and cut docs/ci.md down to what is specific to this
project — the image contents and publishing are documented in ci-php.

Closes #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:39:08 -03:00
thatguygriffandClaude Opus 5 f8762e1095 Correct the registry auth docs: the Actions token does not work
CI Images / Build CI image (PHP 8.2) (pull_request) Successful in 3s
CI Images / Build CI image (PHP 8.5) (pull_request) Successful in 8s
CI / Tests (PHP 8.3) (pull_request) Successful in 30s
CI Images / Build CI image (PHP 8.3) (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 23s
CI / Tests (PHP 8.1) (pull_request) Successful in 28s
CI / No Debug Code (pull_request) Successful in 4s
CI / Coding Standards & Static Analysis (pull_request) Successful in 43s
CI / Tests (PHP 8.5) (pull_request) Successful in 23s
CI / Build Plugin Zip (pull_request) Skipped
CI Images / Build CI image (PHP 8.1) (pull_request) Successful in 1m3s
docs/ci.md and the workflow comment both described secrets.GITHUB_TOKEN as
the working credential with REGISTRY_TOKEN as a fallback. That is backwards:
the task token is rejected by Gitea's container registry (go-gitea/gitea#23642)
and the first publish attempt failed on exactly that. REGISTRY_TOKEN is
required.

Part of #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:35:10 -03:00
thatguygriffandClaude Opus 5 c9d18fec74 Commit composer.lock so CI and releases install a fixed dependency set
CI / Tests (PHP 8.2) (pull_request) Successful in 25s
CI / Tests (PHP 8.1) (pull_request) Successful in 26s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.3) (pull_request) Successful in 32s
CI / Coding Standards & Static Analysis (pull_request) Successful in 42s
CI / Tests (PHP 8.5) (pull_request) Successful in 32s
CI / Build Plugin Zip (pull_request) Skipped
composer.lock was gitignored, so `composer install` resolved the graph afresh
on every job. CI could quietly start testing different dependency versions
than it did the day before, and two builds of the same tag could ship
different vendor trees — the run that produces the zip is the same run that
picks the versions.

Track the lock, and make the two places that consume it use it:

- the Composer cache keys hash composer.lock rather than composer.json, since
  the lock is now what actually fingerprints the dependency set;
- bin/build-zip.sh stages the lock alongside composer.json, so the --no-dev
  production install resolves to the locked versions. It already deleted both
  from the staging directory before zipping, so the shipped plugin is
  unchanged.

The current lock installs cleanly on 8.1: nothing in it requires a PHP newer
than the >=8.1 composer.json declares.

Closes #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:33:04 -03:00
thatguygriffandClaude Opus 5 ab609898d6 Run CI and release jobs inside the prebuilt CI images
CI / Coding Standards & Static Analysis (pull_request) Successful in 37s
CI / Tests (PHP 8.1) (pull_request) Successful in 21s
CI / Tests (PHP 8.2) (pull_request) Successful in 20s
CI / Tests (PHP 8.3) (pull_request) Successful in 19s
CI / Tests (PHP 8.5) (pull_request) Successful in 19s
CI / No Debug Code (pull_request) Successful in 2s
CI / Build Plugin Zip (pull_request) Skipped
Replaces setup-php with `container:` in every job that needs PHP. The images
published by ci-images.yml already carry PHP, Composer, the intl and zip
extensions and the CLI tools the workflow scripts shell out to, so nothing
installs PHP at job time any more.

That removes the apt path #178 was about: a ~145s floor on 8.3 against ~35s
on 8.1/8.2, with a tail that twice ran past the step timeout and failed the
run. All three copies of the `Keep downloaded .debs` / `Cache apt packages`
workaround go with it.

no-debug and bump-version stay on the runner image — neither needs PHP, and
no-debug wants the runner's GNU `grep --include`.

The Composer cache moves to /composer/cache, which is COMPOSER_HOME in the
image, and release.yml gains the cache step it never had.

Also corrects the image size in docs/ci.md to the 67MB measured from the
published manifest; the ~120MB in the original was an estimate.

Closes #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:25:02 -03:00
thatguygriff 4bc8e80837 Merge pull request 'CI: install GNU tar in the CI image so actions/cache works' (#190) from fix/187-ci-image-gnu-tar into main
CI Images / Build CI image (PHP 8.3) (push) Successful in 1m14s
CI Images / Build CI image (PHP 8.2) (push) Successful in 1m30s
CI Images / Build CI image (PHP 8.1) (push) Successful in 1m31s
CI Images / Build CI image (PHP 8.5) (push) Successful in 1m54s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.5) (push) Successful in 3m0s
CI / Tests (PHP 8.1) (push) Successful in 6m0s
CI / Tests (PHP 8.3) (push) Successful in 6m1s
CI / Coding Standards & Static Analysis (push) Failing after 17m19s
CI / Build Plugin Zip (push) Skipped
Reviewed-on: #190
2026-08-25 01:23:12 +00:00
thatguygriffandClaude Opus 5 28f586d207 Install GNU tar in the CI image so actions/cache works
CI Images / Build CI image (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.1) (pull_request) Successful in 55s
CI Images / Build CI image (PHP 8.2) (pull_request) Successful in 1m46s
CI Images / Build CI image (PHP 8.3) (pull_request) Successful in 1m48s
CI Images / Build CI image (PHP 8.5) (pull_request) Successful in 2m26s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m50s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m58s
CI / Coding Standards & Static Analysis (pull_request) Failing after 17m18s
CI / Build Plugin Zip (pull_request) Skipped
actions/cache shells out to `tar --posix -P`. Alpine's busybox tar rejects
both flags, so the cache step would fail in every job that runs inside these
images — which is all of them once ci.yml switches over.

coreutils does not cover this: tar is its own Alpine package. Add it, add
zstd (which actions/cache prefers over gzip when present), and assert GNU
tar in the image's smoke test so a future base-image change cannot quietly
drop it again.

Part of #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 22:15:40 -03:00
thatguygriff e097b7a0bf Merge pull request 'CI: publish prebuilt CI images to the Gitea container registry' (#188) from feature/187-ci-container-images into main
CI Images / Build CI image (PHP 8.1) (push) Failing after 5s
CI Images / Build CI image (PHP 8.2) (push) Failing after 7s
CI Images / Build CI image (PHP 8.3) (push) Failing after 6s
CI Images / Build CI image (PHP 8.5) (push) Failing after 17s
CI / No Debug Code (push) Successful in 9s
CI / Tests (PHP 8.2) (push) Successful in 52s
CI / Tests (PHP 8.3) (push) Successful in 2m47s
CI / Coding Standards & Static Analysis (push) Successful in 3m41s
CI / Tests (PHP 8.1) (push) Successful in 5m59s
CI / Tests (PHP 8.5) (push) Successful in 7m5s
CI / Build Plugin Zip (push) Successful in 6m0s
Reviewed-on: #188
2026-08-25 01:05:33 +00:00
thatguygriffandClaude Opus 5 572aaf5b49 Publish prebuilt CI images to the Gitea container registry
CI Images / Build CI image (PHP 8.2) (pull_request) Successful in 1m14s
CI Images / Build CI image (PHP 8.1) (pull_request) Successful in 1m26s
CI Images / Build CI image (PHP 8.3) (pull_request) Successful in 2m3s
CI Images / Build CI image (PHP 8.5) (pull_request) Successful in 2m9s
CI / Tests (PHP 8.2) (pull_request) Successful in 1m7s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m8s
CI / Tests (PHP 8.1) (pull_request) Successful in 5m55s
CI / Tests (PHP 8.5) (pull_request) Successful in 6m21s
CI / Coding Standards & Static Analysis (pull_request) Successful in 18m26s
CI / Build Plugin Zip (pull_request) Skipped
setup-php installs PHP 8.3+ from apt on these arm64 runners: a ~145s floor
against ~35s for 8.1/8.2, with a tail that twice ran past the step timeout
and failed the run (#178). Caching the .debs softened it without removing
the apt step, and 8.5 has the same problem.

Add a per-version CI image built on php:<version>-cli-alpine and a workflow
that publishes it to git.unsupervised.ca/unsupervised/ci-php:<version>. The
org is public, so the packages pull anonymously.

The image carries bash and nodejs because act_runner runs JavaScript actions
inside the job container, GNU coreutils/grep/sed because the workflow scripts
use `tac` and `grep --include`, and curl/jq/git/zip for release.yml and
bin/build-zip.sh. Composer 2 and the intl and zip extensions round it out.

Nothing consumes the images yet — ci.yml and release.yml switch over in a
follow-up, because a job cannot run in an image that has not been published.

Part of #187

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01D9acV1mHktGAb1uyvNmrR2
2026-08-24 21:55:28 -03:00
thatguygriff 116394f2ff Merge pull request 'Bump version to 1.5.5' (#189) from release/bump-1.5.5 into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 53s
CI / Tests (PHP 8.5) (push) Successful in 3m28s
CI / Tests (PHP 8.3) (push) Successful in 5m46s
CI / Tests (PHP 8.2) (push) Successful in 6m14s
CI / Coding Standards & Static Analysis (push) Failing after 12m16s
CI / Build Plugin Zip (push) Skipped
Reviewed-on: #189
2026-08-25 00:50:11 +00:00
Release Bot dda8386c1f Bump version to 1.5.5 and open changelog section 2026-08-24 23:50:02 +00:00
36 changed files with 4067 additions and 175 deletions
+45 -113
View File
@@ -7,56 +7,31 @@ on:
- develop
pull_request:
# Jobs that need PHP run inside the shared CI images maintained in the
# Unsupervised/ci-php repository. PHP, Composer, the intl and zip extensions
# and the GNU CLI tools are already in the image, so there is no toolchain
# setup step in any job here.
#
# The registry path is written out at each use because
# jobs.<id>.container.image cannot read the `env` context.
jobs:
# PHPCS and PHPStan share a job so the two of them draw once on Setup PHP
# rather than twice. That step is slow and intermittently fails on 8.3
# (see #178), so every job that can be folded into another is one less
# chance for a run to fall over. They run as separate steps, and PHPCS
# failing stops the job before PHPStan reports.
quality:
name: Coding Standards & Static Analysis
phpcs:
name: Coding Standards
runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
steps:
- uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php8.3-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
# COMPOSER_HOME is /composer in the image, so that is where the
# download cache lives. composer.lock is what fingerprints the
# dependency set.
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ hashFiles('composer.json') }}
path: /composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
@@ -64,15 +39,36 @@ jobs:
- name: Run PHPCS
run: composer cs
phpstan:
name: Static Analysis
runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
steps:
- uses: actions/checkout@v4
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: /composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run PHPStan
run: composer lint
test:
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:${{ matrix.php }}
strategy:
fail-fast: false
matrix:
# A version can only be added here once ci-php publishes the matching
# tag.
php:
- '8.1'
- '8.2'
@@ -81,46 +77,11 @@ jobs:
steps:
- uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php${{ matrix.php }}-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, intl
coverage: none
tools: composer:v2
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
path: /composer/cache
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
@@ -128,6 +89,8 @@ jobs:
- name: Run PHPUnit
run: composer test
# Runs on the runner image rather than a container: it needs no PHP, and it
# uses GNU grep's --include.
no-debug:
name: No Debug Code
runs-on: ubuntu-latest
@@ -144,46 +107,15 @@ jobs:
build:
name: Build Plugin Zip
runs-on: ubuntu-latest
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
# Only build a shippable artifact once changes land on main, and only
# after the quality gates pass.
needs: [quality, test, no-debug]
needs: [phpcs, phpstan, test, no-debug]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
# the .debs after install, so every job re-downloads them. Keeping them
# and restoring them from the cache server, which lives in the cluster,
# turns a WAN download into a local one.
#
# Keyed per PHP version because the install path differs by version and
# the package sets are not interchangeable: 8.3+ pulls the ~70 -dev
# packages through php-builder, while 8.1 and 8.2 come from the ondrej
# PPA as a handful of runtime packages. Sharing one key across both lets
# whichever job finishes first decide what the others restore, and 8.1 is
# always first.
#
# Only the .debs are cached, never /var/lib/apt/lists — a stale index is
# how you get 404s mid-install.
- name: Keep downloaded .debs
run: |
sudo rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
| sudo tee /etc/apt/apt.conf.d/99keep-downloaded-packages >/dev/null
- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives/*.deb
key: apt-php8.3-${{ runner.arch }}-v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
- name: Build plugin zip
run: composer build
+62 -8
View File
@@ -15,15 +15,13 @@ jobs:
release:
name: Build and Publish Release
runs-on: ubuntu-latest
# The shared CI image carries composer, curl, jq and the GNU coreutils
# the steps below shell out to. See docs/ci.md.
container:
image: git.unsupervised.ca/unsupervised/ci-php:8.3
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
# A tag that disagrees with the plugin header would make sites see a
# phantom update forever (or never see a real one), so fail fast.
- name: Verify tag matches plugin version
@@ -37,6 +35,14 @@ jobs:
fi
echo "version=${header_version}" >> "$GITHUB_OUTPUT"
# COMPOSER_HOME is /composer in the image, so that is where the
# download cache lives.
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: /composer/cache
key: composer-${{ hashFiles('composer.lock') }}
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
@@ -143,6 +149,50 @@ jobs:
{ print }
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
# main requires signed commits, and Gitea refuses to merge a pull request
# that carries an unsigned one. The key Gitea signs merge commits with
# lives on the server and is not reachable from a runner, so the bump
# commit is signed here with a dedicated release-bot key that the instance
# trusts via TRUSTED_SSH_KEYS. Generating that key, trusting it and storing
# the secret is documented in docs/ci.md.
- name: Configure signing as Release Bot
env:
SIGNING_KEY: ${{ secrets.RELEASE_BOT_SIGNING_KEY }}
run: |
if [ -z "${SIGNING_KEY}" ]; then
echo "RELEASE_BOT_SIGNING_KEY is not set - the bump commit would be unsigned and unmergeable." >&2
exit 1
fi
if ! command -v ssh-keygen > /dev/null; then
echo "ssh-keygen is missing from the runner image; git cannot make SSH signatures without it." >&2
exit 1
fi
# The secret holds an OpenSSH private key ("-----BEGIN OPENSSH PRIVATE
# KEY-----"). git signs by shelling out to ssh-keygen, which wants that
# key on disk next to the .pub it is pointed at, readable only by us,
# and rejects it unless the final newline survived the round trip.
keydir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/release-bot-signing"
install -m 700 -d "${keydir}"
printf '%s\n' "${SIGNING_KEY}" | tr -d '\r' > "${keydir}/key"
chmod 600 "${keydir}/key"
# Doubles as a format check: a truncated or re-wrapped key fails here,
# with a clearer cause than "gpg failed to sign the data" later on.
if ! ssh-keygen -y -f "${keydir}/key" < /dev/null > "${keydir}/key.pub"; then
echo "RELEASE_BOT_SIGNING_KEY is not a usable OpenSSH private key (passphrase-protected, truncated, or re-wrapped on paste)." >&2
exit 1
fi
# No Gitea account backs this address; TRUSTED_SSH_KEYS verifies the
# signature without an account lookup, so it is a label, not an identity.
git config user.name 'Release Bot'
git config user.email '[email protected]'
# Named gpg.format for historical reasons; "ssh" is what switches git
# over to signing with the SSH key above rather than a GPG key.
git config gpg.format ssh
git config user.signingkey "${keydir}/key.pub"
git config commit.gpgsign true
- name: Open pull request
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -151,10 +201,14 @@ jobs:
branch="release/bump-${next}"
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
git config user.name 'Release Bot'
git config user.email '[email protected]'
git checkout -b "${branch}"
git commit -am "Bump version to ${next} and open changelog section"
# A commit that came out unsigned would otherwise go unnoticed until
# someone tried to merge the PR, so fail here instead.
if ! git cat-file commit HEAD | grep -q '^gpgsig'; then
echo "Bump commit is unsigned; refusing to push it." >&2
exit 1
fi
git push origin "${branch}"
curl -fsS -X POST "${api}/pulls" \
-1
View File
@@ -1,5 +1,4 @@
vendor/
composer.lock
coverage/
.phpunit.result.cache
*.log
+18
View File
@@ -11,6 +11,24 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.5.7]
## [1.5.6]
### Security
- **Signing in on the front end no longer hands out a session cookie that can travel over plain HTTP.** The studio's own login form told WordPress not to work out for itself whether the site was secure, and WordPress took that as "it is not" — so on an HTTPS site every student's session cookie was issued without the flag that keeps a browser from ever sending it unencrypted. Anyone able to watch the network and provoke a single `http://` request to the site could have lifted a signed-in session with it. The form now leaves that judgement to WordPress, which is what the standard login screen has always done. Nothing changes for you; existing sessions are unaffected.
- **Plugin updates are now only accepted from the release server itself.** The update check asks the repository where to download the new version and used to take whatever answer came back. An answer that was not really the release server's — a hijacked hostname, a tampered response — could have pointed the site at any file on the internet, which WordPress would then have unpacked over the plugin. The download address must now be `https` on `git.unsupervised.ca` exactly; a lookalike, a subdomain, or an unencrypted address is refused and no update is offered. Ordinary updates are unaffected.
- **A student can no longer tell a booking that is not theirs from one that does not exist.** Cancelling someone else's lesson was already refused, but the refusal was worded differently from "no such booking" — enough for a signed-in student to work through the numbers and learn how many lessons the studio holds. Both now answer identically. Withdrawing from a group class was the same and has had the same treatment.
- **Students created by anything other than the studio's own signup form now wait for approval.** Turning on open registration switches on WordPress's site-wide "anyone can register" setting and makes Student the default role for new accounts — which is what the studio's registration page needs, but it also arms any *other* signup form the site happens to have. An account created that way arrived able to book and be billed immediately, with no email confirmed, no approval and no policies agreed to. Any student account that appears without going through the studio's own form is now held and listed under **Students → Pending Students**, exactly like a self-signup; it can sign in, but cannot book until you approve it. Students you add yourself from wp-admin, invited students, and the children a parent adds are all unaffected.
### Added
- **You can now decide what deleting the plugin takes with it, on Access → Plugin removal.** WordPress gives an uninstall nothing to ask you with, so the answer is given ahead of time. By default your records stay: delete the plugin and your lessons, enrolments, payments, credits, intake answers, policy agreements, invites and family links are still there when you reinstall, so a delete during a migration or a bit of troubleshooting costs you nothing. Ticking **Erase everything when the plugin is deleted** — which also asks you to type DELETE, because there is no undo — drops every table, setting and role the plugin made. Either way your Stripe secret key and webhook signing secret are now forgotten on deletion, where before they stayed in the database indefinitely: they take a minute to paste back in, and live keys on a site that no longer has the code to use them are worth nothing but risk. The two WordPress settings open registration borrows are put back as they were, too, so deleting the plugin can never leave the site quietly accepting signups into a role that no longer exists.
## [1.5.5]
### Changed
- **Payments moved up to version 21 of Stripe's PHP library**, from version 17. Being four major versions behind also meant asking Stripe to behave like an older version of its API; the plugin now uses API version `2026-07-29.dahlia`. Taking a card payment and handling a webhook are unchanged — the same charge is raised, the same events are honoured, and a forged webhook is still rejected. Nothing to do on your side.
## [1.5.4]
### Fixed
+4
View File
@@ -23,6 +23,10 @@ INCLUDE=(
"$SLUG.php"
"uninstall.php"
"composer.json"
# Staged so the production install below resolves to the locked versions
# rather than whatever is newest that day. Both are deleted again before
# the zip is written.
"composer.lock"
"src"
"templates"
"assets"
+4 -1
View File
@@ -5,7 +5,7 @@
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.1",
"stripe/stripe-php": "^17.0"
"stripe/stripe-php": "^21.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5",
@@ -36,6 +36,9 @@
"build": "bash bin/build-zip.sh"
},
"config": {
"platform": {
"php": "8.1"
},
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
}
Generated
+2589
View File
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
# CI
CI and release jobs do not install PHP. They run inside the shared images
maintained in [Unsupervised/ci-php](https://git.unsupervised.ca/Unsupervised/ci-php):
```
git.unsupervised.ca/unsupervised/ci-php:8.1
git.unsupervised.ca/unsupervised/ci-php:8.2
git.unsupervised.ca/unsupervised/ci-php:8.3
git.unsupervised.ca/unsupervised/ci-php:8.5
```
The `Unsupervised` org is public, so they pull anonymously — no registry
credentials in any job here. What the images contain, how they are published,
and how to add a PHP version are documented in that repository's README.
## Which job runs where
| Job | Runs in |
|---|---|
| Coding Standards (PHPCS) | `ci-php:8.3` |
| Static Analysis (PHPStan) | `ci-php:8.3` |
| Tests | `ci-php:${{ matrix.php }}` |
| Build Plugin Zip | `ci-php:8.3` |
| No Debug Code | runner image — no PHP, and it uses GNU `grep --include` |
| Open next-version bump PR (release.yml) | runner image — no PHP |
PHPCS and PHPStan are separate jobs so a coding-standards failure still lets
the static analysis result through. They run in parallel.
## Composer
`composer.lock` is committed, so every job installs the same dependency set
and two builds of the same tag ship the same vendor tree. `bin/build-zip.sh`
stages the lock into its build directory for the same reason, then removes it
before writing the zip.
The Composer download cache lives at `/composer/cache``COMPOSER_HOME` is
`/composer` in the image — and is keyed on `composer.lock`.
## Adding a PHP version to the test matrix
The image has to exist first. Add the version to the `php` matrix in
`ci-php`'s `.gitea/workflows/publish.yml` and merge, then add it to the `test`
matrix in `.gitea/workflows/ci.yml` here.
## Signing the version bump commit
`main` is a protected branch that requires signed commits, and Gitea will not
merge a pull request containing an unsigned one. The `bump-version` job in
`release.yml` therefore signs the commit it makes, using a dedicated
`release-bot` SSH key rather than the key Gitea signs merge commits with —
that one is `[repository.signing] SIGNING_KEY` on the server and no runner can
reach it. Keeping the CI key separate also means it can be rotated on its own
if the secret ever leaks.
There is deliberately no `release-bot` Gitea account. A key attached to an
account is only consulted for signature checking after it has been through the
web *Verify* flow, and that flow has no API — a bot account would need an
interactive login to be worth anything. Listing the key under
`TRUSTED_SSH_KEYS` instead makes Gitea verify commits signed with it without
any account lookup, which is all the protected branch asks for.
Set up once for the instance, and again only if the key is rotated:
1. Generate a passphrase-less key (it has to be usable unattended):
```
ssh-keygen -t ed25519 -C 'release-bot@unsupervised.ca' -f release-bot -N ''
```
2. Add the public half to `app.ini` and restart Gitea:
```ini
[repository.signing]
TRUSTED_SSH_KEYS = ssh-ed25519 AAAAC3Nza... release-bot@unsupervised.ca
```
3. Store the private half as the **organisation** Actions secret
`RELEASE_BOT_SIGNING_KEY` (Org → Settings → Actions → Secrets): the whole
`release-bot` file verbatim, `-----BEGIN OPENSSH PRIVATE KEY-----` header
and footer included — not the `.pub`, and not a GPG export. Organisation
secrets are readable as `secrets.RELEASE_BOT_SIGNING_KEY` from every
repository in the org, so no repository-level copy is needed. Delete both
local files afterwards.
Two consequences of trusting the key instance-wide are worth knowing. Any
commit signed with it verifies in *every* repository on the instance, not just
these — the trust is in the key, not in a user with permissions you can scope.
And the signature is attributed to `SIGNING_NAME` / `SIGNING_EMAIL`, not to the
`Release Bot <[email protected]>` committer the job sets; that
address backs no account and is only a label.
The job fails fast if the secret is missing or `ssh-keygen` is absent from the
runner image, and it re-reads the commit it just made to confirm a signature
is attached before pushing — an unsigned bump commit would otherwise look fine
until someone tried to merge the PR.
Nothing else in the pipeline signs anything: release tags are made by a human
through Gitea's release UI, and the merge commit is signed by the server when
the PR is merged.
+29 -1
View File
@@ -42,6 +42,34 @@ never be used to create a policy-less account (`Auth\EmailConfirmationHandler`):
- `login_init` action redirects any `action=register` request (GET **and** POST) to the registration page before any processing runs.
- `registration_errors` filter is a fail-safe that rejects `register_new_user()` outright.
### Holding signups that came from somewhere else
Blocking core's own form is not the whole story. `users_can_register=1` and
`default_role=us_student` are *site-wide* settings, so they also arm every other
route into `wp_insert_user()` a site happens to have — another plugin's signup
form, a membership add-on. An account minted that way arrives holding
`book_lesson`, with no email confirmed, no studio approval and no policy
acceptance on file, and could book and be billed immediately.
So the pending state is not decided by whichever form created the account. It is
decided once, on `user_register`, by
`Auth\RegistrationLoginGate::holdUnknownSignup()`:
| New account | Result |
|---|---|
| Not a `us_student` | untouched — instructors and everyone else are not this feature's business |
| Created by someone holding `manage_students` (including an admin adding a user in wp-admin) | left active — a deliberate act by someone who could approve it in the next click |
| Anything else | **held** via `RegistrationStatus::hold()` and queued under **Pending Students** |
A hold sets `us_awaiting_approval=1` *and* `us_email_confirmed=1`. The account was
never asked to confirm anything and has no token to answer with, so blocking its
login would strand it; what the hold withholds is `book_lesson`, until a studio
admin approves it.
The plugin's own paths all land in the last row and then say what they meant:
- a self-signup calls `RegistrationStatus::markPending()`, which replaces the hold with a real, unconfirmed pending state (it clears `us_email_confirmed` explicitly for exactly this reason);
- an invited student is approved outright by `RegistrationPage` before the auto-login — the invitation *is* the approval;
- a guardian's child is approved outright by `GuardianService::createChild()`; the account is never signed in to, and queueing every child a family adds would be nonsense.
## Account Lifecycle (self-approval)
State lives entirely in user meta (`Auth\RegistrationStatus`). Only the raw
confirmation token's SHA-256 hash is stored; the token expires after 48h
@@ -198,7 +226,7 @@ No-op when no registration page is set.
- Repository: `Unsupervised\Schedular\Auth\InviteRepository`
- Admin controllers: `Unsupervised\Schedular\Auth\RegistrationController` (invites), `Unsupervised\Schedular\Auth\RegistrationApprovalController` (pending students)
- Frontend: `Unsupervised\Schedular\Auth\RegistrationPage`
- Self-approval flow: `Auth\RegistrationStatus` (lifecycle meta), `Auth\RegistrationLoginGate` (login + booking-cap gate), `Auth\EmailConfirmationHandler` (confirm link + native-form block), `Auth\RegistrationMailer` (emails)
- Self-approval flow: `Auth\RegistrationStatus` (lifecycle meta, including `hold()`), `Auth\RegistrationLoginGate` (login gate, booking-cap gate, and the `user_register` hold), `Auth\EmailConfirmationHandler` (confirm link + native-form block), `Auth\RegistrationMailer` (emails)
- Settings toggle: `Payment\StudioSettings` (`us_registration_mode`, core-option mirror/restore)
- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository`
- Schema: `us_invites`; `us_policies.acceptance_scope`. Self-approval adds no tables — state is WordPress user meta.
+79
View File
@@ -0,0 +1,79 @@
# Feature: Data Removal
## Overview
What deleting the plugin takes with it, and how the site owner says so.
WordPress gives an uninstall no interface of its own: `uninstall.php` runs
headless, after the plugin is already gone from the screen, with no opportunity
to ask anything. So the answer is given in advance, on **Access → Plugin
removal**, and read back at uninstall time.
Two things are true at once, and the split below is how both are honoured:
- **A studio's records are irreplaceable.** Lessons taught, payments taken, what
families agreed to and when. A delete during a migration, or a
delete-and-reinstall while troubleshooting, must not be the thing that loses
them. So the data is **kept unless the owner explicitly says otherwise**.
- **Credentials are not records.** A Stripe secret and webhook signing key can be
re-pasted from the Stripe dashboard in under a minute. Live keys sitting in
`wp_options` on a site that no longer has the code to use them are nothing but
exposure. So those are **always** removed.
## Option `us_delete_data_on_uninstall`
`'1'` or `'0'` (default `'0'`). Written only from the Access page.
## Always removed
Whatever the setting says:
| What | Why |
|---|---|
| `us_stripe_secret_key`, `us_stripe_webhook_secret`, `us_stripe_publishable_key`, `us_stripe_mode` | Credentials, not records — cheap to restore, dangerous to leave |
| `us_schedular_latest_release` transient | Cached release metadata; meaningless without the plugin |
| The `us_generate_due_payments` cron event | Deactivation clears it too, but a site whose plugin files simply vanished never ran that hook |
| `users_can_register` / `default_role` | Restored from the snapshot open registration took (`us_registration_prev_*`). These are the *site's* settings, borrowed; leaving them behind would leave the site accepting public signups into a Student role that is about to stop existing. Only acts when a snapshot exists |
## Removed only on an explicit full purge
- Every table in `Schema::TABLES` — all 14, dropped by name.
- Every remaining `us_*` option, including the removal setting itself.
- Every `us_*` user meta key, for all users (`delete_metadata( 'user', 0, $key, '', true )`) — billing overrides, child markers, birth years, pending-signup state.
- The `us_studio_admin`, `us_instructor` and `us_student` roles.
Roles go **only** on a full purge. A site keeping its data is keeping its
students, and a student whose role was deleted out from under them holds no
capabilities at all until the plugin is reinstalled.
`Schema::TABLES` is the single list of tables the plugin owns; the `CREATE TABLE`
statements in `Schema::tables()` spell their own names out, so a new table has to
be added in both places or uninstalling will leave it behind.
## Admin Interface
**Access → Plugin removal** (`manage_options` — the same capability as deleting a
plugin, so the switch and the act it governs are in the same pair of hands):
- A read-only note stating what is removed regardless.
- **Erase everything when the plugin is deleted** — off by default.
Turning it **on** takes the tick *and* the word `DELETE` typed into a confirmation
box. It is the only place in the plugin where a stray click is unrecoverable, so
the checkbox alone is not enough. A refused confirmation reports why and still
saves everything else on the page — a mistyped word must not silently swallow a
capability change made in the same submit.
Turning it **off** needs nothing but unticking the box. Saving the page for some
other reason while it is already on leaves it on, without asking for the word
again.
## Implementation
- `Unsupervised\Schedular\Uninstaller` — the option, and the whole of `run()`
- `uninstall.php` — guards on `WP_UNINSTALL_PLUGIN`, loads the autoloader, calls `Uninstaller::run()`
- `Unsupervised\Schedular\Schema::TABLES` — the table list
- `Unsupervised\Schedular\Auth\AccessSettings` — the setting's UI and the typed confirmation
- `templates/admin/access.php`
## Tests
- `tests/Unit/UninstallerTest.php`
- `tests/Unit/Auth/AccessSettingsTest.php` (the confirmation rules)
## Related
- `payments.md` — the Stripe credentials this always forgets
- `account-registration.md` — the core options open registration borrows
+6 -4
View File
@@ -136,10 +136,12 @@ Self-withdrawal is bounded by the class's **withdrawal deadline** (the instructo
implicit default — a class with no deadline set stays open to withdrawal for its
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
contact the studio to withdraw." in place of the Withdraw button. The endpoint also
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
idempotent.
contact the studio to withdraw." in place of the Withdraw button. An enrolment that
does not exist and one that is not the caller's own both return the same
`404 not_found`, deliberately: two different answers would let any signed-in student
walk the id space and count the studio's enrolments, and there is nothing they could
do with either answer. A withdrawal of an already-cancelled enrolment is idempotent.
`POST /bookings/{id}/cancel` makes the same trade for the same reason.
The deadline only bounds student **self**-withdrawal. A studio admin can withdraw a
student at any time from the **student detail page** (`Auth\StudentActions::withdrawEnrollment`),
+15 -3
View File
@@ -47,9 +47,21 @@ update for a same-slug plugin and makes core fire the
`us_schedular_latest_release` transient for 6 hours.
3. Strips the leading `v` from the tag and compares against `USC_VERSION`
with `version_compare`; PHP orders `1.0.0-rc.2 < 1.0.0` correctly.
4. When newer, returns the release's first `.zip` asset as the update
package. Core takes over from there: Plugins-screen notice, one-click
update, and WP-Cron auto-updates if enabled.
4. When newer, returns the release's first `.zip` asset **whose download URL
is `https` on `git.unsupervised.ca` itself** as the update package. Core
takes over from there: Plugins-screen notice, one-click update, and
WP-Cron auto-updates if enabled.
The host check is not ceremony. Whatever this returns is downloaded and
unpacked over the installed plugin, so the URL is executable code by
another name — and it arrives in a JSON body. An answer that is not really
the release server's (a hijacked hostname, a tampered response, a repo host
handing out a package hosted somewhere else) would otherwise install
arbitrary code on every site running the plugin, silently for anyone with
auto-updates on. The host must match exactly: `evil-git.unsupervised.ca`,
`git.unsupervised.ca.evil.test` and `cdn.git.unsupervised.ca` are all
refused, and so is plain `http`. An asset that fails the check is skipped
and the scan continues, so one bad asset does not hide a good one.
5. When not newer — the site is current, or the lookup failed — returns a
`no_update` payload (installed version, empty package). This keeps the
plugin in core's `update_plugins` transient so core's `update-supported`
+51 -9
View File
@@ -3,17 +3,24 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Uninstaller;
use Unsupervised\Schedular\Val;
/**
* Site-owner toggles for whether WordPress administrators automatically receive
* the studio-admin and/or instructor capabilities.
* The site owner's page: whether WordPress administrators automatically receive
* the studio-admin and/or instructor capabilities, and what deleting the plugin
* takes with it.
*
* Both default on, preserving the out-of-the-box experience where a single
* administrator runs the studio and teaches from one account. The settings page
* is gated on `manage_options` (the core WordPress administrator capability,
* which the plugin never grants or revokes) so an administrator can always reach
* it to re-enable a grant disabling one can never lock them out.
* Both capability grants default on, preserving the out-of-the-box experience
* where a single administrator runs the studio and teaches from one account. The
* settings page is gated on `manage_options` (the core WordPress administrator
* capability, which the plugin never grants or revokes) so an administrator can
* always reach it to re-enable a grant disabling one can never lock them out.
*
* The data-removal choice lives here for the same reason: `manage_options` is
* held by exactly the people who can delete a plugin, so the switch and the act
* it governs are in the same pair of hands. {@see Uninstaller} explains what the
* two answers mean.
*/
class AccessSettings {
@@ -49,21 +56,56 @@ class AccessSettings {
wp_die( esc_html__( 'You do not have permission to manage access settings.', 'unsupervised-schedular' ) );
}
$error = '';
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_access_action' ) ) {
$this->save();
$error = $this->save();
}
$adminsAreStudioAdmins = $this->adminsAreStudioAdmins();
$adminsAreInstructors = $this->adminsAreInstructors();
$deleteDataOnUninstall = Uninstaller::deletesDataOnUninstall();
include USC_PLUGIN_DIR . 'templates/admin/access.php';
}
private function save(): void {
/**
* Persist the submitted settings, reporting why the data-removal choice was
* refused when it was. Everything else on the page saves either way: a
* mistyped confirmation must not also swallow a capability change.
*/
private function save(): string {
// Nonce is verified by the caller (renderPage) before this method runs.
// phpcs:disable WordPress.Security.NonceVerification.Missing
update_option( self::OPT_GRANT_STUDIO, isset( $_POST['grant_studio'] ) ? '1' : '0' );
update_option( self::OPT_GRANT_INSTRUCTOR, isset( $_POST['grant_instructor'] ) ? '1' : '0' );
$wanted = isset( $_POST['delete_data'] );
// Switching it off is not the dangerous direction, and needs no ceremony.
if ( ! $wanted ) {
Uninstaller::setDeletesDataOnUninstall( false );
return '';
}
// Already on and left on: this save is about something else on the page,
// so do not make them retype the word to keep a setting they already made.
if ( Uninstaller::deletesDataOnUninstall() ) {
return '';
}
// Turning it on erases records that cannot be got back, so the tick alone
// is not enough — it is one stray click, and this is the only place in the
// plugin where a stray click is unrecoverable.
$confirmed = 'delete' === sanitize_key( Val::string( wp_unslash( $_POST['delete_data_confirm'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! $confirmed ) {
return __( 'Data removal was not enabled: type DELETE in the confirmation box to turn it on. Everything else on this page was saved.', 'unsupervised-schedular' );
}
Uninstaller::setDeletesDataOnUninstall( true );
return '';
}
}
+6 -1
View File
@@ -36,7 +36,12 @@ class LoginPage {
'remember' => isset( $_POST['rememberme'] ),
];
$user = wp_signon( $credentials, false );
// The secure-cookie argument is deliberately left at its default. Only
// the empty string makes wp_signon() work it out from is_ssl(); passing
// an explicit false skips that and issues the plain, non-Secure auth
// cookie on an HTTPS site — a session that then leaks over the first
// http:// request to the domain.
$user = wp_signon( $credentials );
if ( is_wp_error( $user ) ) {
$error = esc_html__( 'Invalid username or password.', 'unsupervised-schedular' );
+47
View File
@@ -11,12 +11,59 @@ namespace Unsupervised\Schedular\Auth;
*
* Both checks key solely off the pending user meta, so invite- and
* admin-created students (which carry none of it) are unaffected.
*
* It also decides which new accounts land in that pending state to begin with
* see {@see holdUnknownSignup()}, which closes the gap left by open registration
* turning the site's own `users_can_register` on.
*/
class RegistrationLoginGate {
public function register(): void {
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
add_action( 'user_register', [ $this, 'holdUnknownSignup' ], 10, 1 );
}
/**
* Hold any student account created by an unauthenticated request that did not
* come through the studio's own signup form.
*
* Enabling open registration switches the site's `users_can_register` on and
* makes Student the default role for a new user, because that is what the
* studio's registration page needs. But those are *site-wide* settings: they
* also arm every other route into `wp_insert_user()` the site happens to have
* another plugin's signup form, a membership add-on and an account minted
* that way arrives holding `book_lesson`, with no email confirmed, no studio
* approval, and no policy acceptance on file. It could book and be billed
* immediately.
*
* So the state is decided here, at the one point every path passes through,
* rather than trusted to whichever form happened to create the account:
*
* - **Not a student** instructors and everyone else are none of this
* feature's business.
* - **Created by staff** (anyone holding `manage_students`, which includes an
* administrator adding a user from wp-admin) a deliberate act by someone
* who could have approved them anyway; approving their own creation is
* ceremony, so the account is left active.
* - **Anything else** held, and queued for review under **Pending
* Students**.
*
* The studio's own paths land in the last case and then say what they meant:
* a self-signup calls {@see RegistrationStatus::markPending()} (which replaces
* the hold with a real, unconfirmed pending state), and an invited student and
* a guardian's child are approved outright by the code that creates them.
*/
public function holdUnknownSignup( int $userId ): void {
if ( $userId <= 0 || ! RoleManager::isStudent( $userId ) ) {
return;
}
if ( current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
return;
}
RegistrationStatus::hold( $userId );
}
/**
+7
View File
@@ -431,6 +431,13 @@ class RegistrationPage {
}
if ( $inviteValid && ! $invite->isGroup() ) {
// An invited student is pre-approved by the invitation itself — the
// studio picked the address and sent the link. Clears the hold the
// registration gate puts on every student account created by an
// unauthenticated request ({@see RegistrationLoginGate::holdUnknownSignup()}),
// which would otherwise leave them signed in but unable to book.
RegistrationStatus::approve( (int) $userId );
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
// A personal invite may carry a group-class grant (invited by email);
+20
View File
@@ -58,6 +58,11 @@ class RegistrationStatus {
$rawToken = wp_generate_password( 32, false );
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
// Explicitly *un*confirmed. The account may already have been held by
// {@see hold()} on `user_register` — which counts the email as confirmed,
// having never asked for confirmation — and this signup did ask, so the
// answer has to be waited for rather than inherited.
delete_user_meta( $userId, self::META_EMAIL_CONFIRMED );
update_user_meta( $userId, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
update_user_meta(
$userId,
@@ -72,6 +77,21 @@ class RegistrationStatus {
return $rawToken;
}
/**
* Hold a student account that appeared without going through the studio's own
* signup form see {@see RegistrationLoginGate::holdUnknownSignup()}.
*
* The email counts as confirmed, because nobody ever asked for confirmation
* and there is no token to answer with: blocking the login outright would
* strand the account with no way forward. What the hold actually withholds is
* the booking capability, until a studio admin approves them from **Pending
* Students** the same queue, and the same decision, as a self-signup.
*/
public static function hold( int $userId ): void {
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
update_user_meta( $userId, self::META_EMAIL_CONFIRMED, '1' );
}
/**
* Mark the account's email confirmed and discard the (now spent) token. The
* account stays awaiting approval.
+10 -5
View File
@@ -330,14 +330,19 @@ class BookingEndpoint {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $id );
if ( null === $lesson ) {
// A booking that is not the caller's is answered exactly as one that does
// not exist. Telling the two apart — 403 here, 404 there — would let any
// signed-in student walk the id space and learn which lessons the studio
// holds, and roughly how many. There is nothing a student can do with
// either answer, so there is no reason to distinguish them.
//
// The booking form's own 403 (see resolveStudent) is a different case: the
// student id there was chosen from a list of people the caller may act for,
// so "not yours" is a correction they need, not a fact they lack.
if ( null === $lesson || ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
$slot = $this->availability->findById( $lesson->slotId );
if ( null !== $slot ) {
+5 -5
View File
@@ -202,14 +202,14 @@ class EnrollmentEndpoint {
$id = absint( Val::int( $request->get_param( 'id' ) ) );
$enrollment = $this->enrollments->findById( $id );
if ( null === $enrollment ) {
// Someone else's enrolment is answered exactly as a nonexistent one, so the
// id space cannot be walked to count the studio's enrolments. See
// {@see \Unsupervised\Schedular\Booking\BookingEndpoint::cancel()}, which
// makes the same trade for the same reason.
if ( null === $enrollment || ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
if ( ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) {
return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
$offering = $this->offerings->findById( $enrollment->offeringId );
+9
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Guardian;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Booking\BookingRepository;
@@ -103,6 +104,14 @@ class GuardianService {
$userId = (int) $userId;
update_user_meta( $userId, self::META_CHILD, '1' );
// A child is a student created by someone who is not staff, so the
// registration gate holds it on `user_register` like any other unattributed
// signup. There is nothing here to approve: the account is never signed in
// to, and the guardian in front of us is the approval. Leaving the hold on
// would put every child a family adds into the studio's review queue.
RegistrationStatus::approve( $userId );
$this->setBirthYear( $userId, $birthYear );
$linkId = $this->guardians->insert(
+27
View File
@@ -5,6 +5,33 @@ namespace Unsupervised\Schedular;
class Schema {
/**
* Every table this plugin owns, unprefixed and in creation order.
*
* The statements in {@see tables()} spell their own names out, so this list is
* what anything that needs to *name* the tables without building them reads
* {@see Uninstaller}, which drops them. Add a table below and add it here, or
* uninstalling will leave it behind.
*
* @var list<string>
*/
public const TABLES = [
'us_availability',
'us_lessons',
'us_offerings',
'us_questions',
'us_question_answers',
'us_policies',
'us_policy_versions',
'us_policy_acceptances',
'us_payments',
'us_credits',
'us_group_enrollments',
'us_invites',
'us_guardians',
'us_group_access',
];
/**
* Returns CREATE TABLE statements for dbDelta.
*
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccessSettings;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Update\UpdateChecker;
/**
* What deleting the plugin takes with it.
*
* WordPress gives an uninstall no interface of its own `uninstall.php` runs
* headless, after the plugin is already gone from the screen so the choice has
* to be made in advance, on **Access Plugin removal**, and read back here. Two
* things are true at once and the split below is how both are honoured:
*
* - A studio's records are irreplaceable. Lessons taught, payments taken, what
* families agreed to and when: a deactivate-and-reinstall, or a delete during
* a migration, must not be the thing that loses them. So the data is kept
* unless the site owner has explicitly said otherwise.
* - Credentials are not records. The Stripe secret and webhook signing key can
* be re-pasted from the Stripe dashboard in under a minute, and leaving live
* keys in `wp_options` of a site that no longer has the code to use them is
* nothing but exposure. So those go every time, choice or no choice.
*
* The two WordPress settings open registration borrows `users_can_register`
* and `default_role` are also always put back. They are the site's, not the
* plugin's, and leaving them behind would leave the site accepting public
* signups into a Student role that may no longer exist.
*/
class Uninstaller {
/**
* Whether deleting the plugin also erases everything it recorded. Off unless
* the site owner turns it on, because the mistake is unrecoverable in one
* direction only.
*/
public const OPT_DELETE_DATA = 'us_delete_data_on_uninstall';
/**
* Stripe credentials and configuration. Always removed see the class
* docblock.
*
* @var list<string>
*/
private const CREDENTIAL_OPTIONS = [
StudioSettings::OPT_PUBLISHABLE,
StudioSettings::OPT_SECRET,
StudioSettings::OPT_WEBHOOK_SECRET,
StudioSettings::OPT_MODE,
];
/**
* Every remaining option the plugin writes. Removed only on a full purge.
*
* @var list<string>
*/
private const SETTING_OPTIONS = [
'us_schedular_version',
'us_questions_offering_nullable',
'us_questions_child_required_backfilled',
StudioSettings::OPT_CURRENCY,
StudioSettings::OPT_ETRANSFER_EMAIL,
StudioSettings::OPT_HST_RATE,
StudioSettings::OPT_DEFAULT_PAYMENT_METHOD,
StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS,
StudioSettings::OPT_REGISTRATION_MODE,
RegistrationController::OPTION_PAGE,
AccessSettings::OPT_GRANT_STUDIO,
AccessSettings::OPT_GRANT_INSTRUCTOR,
self::OPT_DELETE_DATA,
];
/**
* Every user meta key the plugin writes. Removed for all users on a full
* purge, so no student is left carrying a billing override or a half-finished
* signup for a plugin that is gone.
*
* @var list<string>
*/
private const USER_META = [
GuardianService::META_CHILD,
GuardianService::META_BIRTH_YEAR,
GuardianService::META_DOB,
GuardianService::META_GUARDIAN_ONLY,
BillingMethodResolver::META_METHOD,
RegistrationStatus::META_AWAITING_APPROVAL,
RegistrationStatus::META_EMAIL_CONFIRMED,
RegistrationStatus::META_CONFIRM_TOKEN,
RegistrationStatus::META_CONFIRM_EXPIRES,
RegistrationStatus::META_AUTO_APPROVE,
];
/**
* Whether a full purge has been asked for.
*/
public static function deletesDataOnUninstall(): bool {
return '1' === Val::string( get_option( self::OPT_DELETE_DATA, '0' ) );
}
/**
* Record the site owner's answer.
*/
public static function setDeletesDataOnUninstall( bool $delete ): void {
update_option( self::OPT_DELETE_DATA, $delete ? '1' : '0' );
}
/**
* Run the uninstall. Called from `uninstall.php`, which WordPress loads in
* isolation once the plugin has been deleted.
*/
public function run(): void {
$this->forgetCredentials();
$this->restoreCoreRegistrationSettings();
// The event is cleared on deactivation too, which always precedes a
// delete — repeated here because a site can be left with a stale schedule
// if the plugin files went away without deactivating cleanly.
wp_clear_scheduled_hook( ScheduledBillingRunner::HOOK );
if ( ! self::deletesDataOnUninstall() ) {
return;
}
$this->dropTables();
$this->deleteSettings();
$this->deleteUserMeta();
$this->removeRoles();
}
/**
* Forget the Stripe credentials, always. {@see StudioSettings::clearStripeConfig()}
* is the same act from the settings page; it is not reused here because that
* object belongs to a plugin that, by this point, is no longer loaded as one.
*/
private function forgetCredentials(): void {
foreach ( self::CREDENTIAL_OPTIONS as $option ) {
delete_option( $option );
}
delete_transient( UpdateChecker::TRANSIENT );
}
/**
* Put back the two core options open registration borrowed, from the snapshot
* taken when it was switched on. Without this, deleting the plugin while open
* registration is enabled leaves the site accepting public signups into a role
* that is about to stop existing.
*
* Only acts when a snapshot exists, so a site that never enabled open
* registration keeps its own settings untouched.
*/
private function restoreCoreRegistrationSettings(): void {
$snapshot = get_option( StudioSettings::OPT_PREV_USERS_CAN_REGISTER, null );
if ( null === $snapshot ) {
return;
}
$prevRole = Val::string( get_option( StudioSettings::OPT_PREV_DEFAULT_ROLE, 'subscriber' ) );
update_option( 'users_can_register', '1' === Val::string( $snapshot ) ? '1' : '0' );
update_option( 'default_role', '' !== $prevRole ? $prevRole : 'subscriber' );
delete_option( StudioSettings::OPT_PREV_USERS_CAN_REGISTER );
delete_option( StudioSettings::OPT_PREV_DEFAULT_ROLE );
}
private function dropTables(): void {
global $wpdb;
if ( ! $wpdb instanceof \wpdb ) {
return;
}
foreach ( Schema::TABLES as $table ) {
$sql = $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table );
if ( null !== $sql ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- $sql is the prepared statement two lines up; the sniff cannot follow it across the null guard, which PHPStan requires because prepare() is nullable. The only interpolated value is a Schema::TABLES constant.
$wpdb->query( $sql );
}
}
}
private function deleteSettings(): void {
foreach ( self::SETTING_OPTIONS as $option ) {
delete_option( $option );
}
}
private function deleteUserMeta(): void {
foreach ( self::USER_META as $key ) {
delete_metadata( 'user', 0, $key, '', true );
}
}
/**
* Drop the three roles the plugin adds. Only on a full purge: a site keeping
* its data is keeping its students too, and a student whose role has been
* deleted out from under them is a user with no capabilities at all until the
* plugin is reinstalled.
*/
private function removeRoles(): void {
foreach ( [ RoleManager::STUDIO_ADMIN, RoleManager::INSTRUCTOR, RoleManager::STUDENT ] as $role ) {
remove_role( $role );
}
}
}
+43 -2
View File
@@ -178,8 +178,13 @@ class UpdateChecker {
if ( ! is_array( $asset ) ) {
continue;
}
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
$package = Val::string( $asset['browser_download_url'] ?? '' );
if ( ! str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
continue;
}
$url = Val::string( $asset['browser_download_url'] ?? '' );
if ( self::isTrustedPackage( $url ) ) {
$package = $url;
break;
}
}
@@ -194,4 +199,40 @@ class UpdateChecker {
'package' => $package,
];
}
/**
* Whether a release asset's URL may be handed to core as a plugin package.
*
* Core downloads whatever this returns and unpacks it over the installed
* plugin, so the URL is executable code by another name. It is taken from a
* JSON body, which means an answer that is not really the release server's
* a hijacked hostname, a tampered response, a compromised repo host handing
* out a package hosted elsewhere would otherwise install arbitrary code on
* every site running this plugin, silently for anyone with auto-updates on.
*
* So the package has to come from the release host itself, over TLS: the
* scheme is `https` and the parsed host equals {@see HOSTNAME} exactly. The
* comparison is on the parsed host and not on the string, because a name that
* merely *contains* the right one is the whole trick `evil-git.unsupervised.ca`
* would satisfy an endsWith check, `git.unsupervised.ca.attacker.test` a
* startsWith one, and `https://[email protected]/x.zip` reads
* like the real host to a person while resolving to someone else's.
* Subdomains are refused too: nothing but the release host publishes releases.
*
* This cannot defend against the release host serving a bad zip of its own
* nothing here can but it keeps the blast radius to that one host.
*/
private static function isTrustedPackage( string $url ): bool {
if ( '' === $url ) {
return false;
}
$parts = wp_parse_url( $url );
if ( ! is_array( $parts ) ) {
return false;
}
return 'https' === strtolower( Val::string( $parts['scheme'] ?? '' ) )
&& self::HOSTNAME === strtolower( Val::string( $parts['host'] ?? '' ) );
}
}
+54 -2
View File
@@ -6,8 +6,10 @@ if (! defined('ABSPATH')) {
}
/**
* @var bool $adminsAreStudioAdmins
* @var bool $adminsAreInstructors
* @var bool $adminsAreStudioAdmins
* @var bool $adminsAreInstructors
* @var bool $deleteDataOnUninstall
* @var string $error Why the data-removal choice was refused, if it was.
*/
?>
<div class="wrap">
@@ -19,6 +21,12 @@ if (! defined('ABSPATH')) {
</p>
</div>
<?php if ($error !== '') : ?>
<div class="notice notice-error inline">
<p><?php echo esc_html($error); ?></p>
</div>
<?php endif; ?>
<form method="post">
<?php wp_nonce_field('usc_access_action'); ?>
<input type="hidden" name="usc_action" value="save">
@@ -45,6 +53,50 @@ if (! defined('ABSPATH')) {
</td>
</tr>
</table>
<h2><?php esc_html_e('Plugin removal', 'unsupervised-schedular'); ?></h2>
<p class="description" style="max-width:46em;">
<?php esc_html_e('Deleting a plugin happens on the Plugins screen, and WordPress gives it nothing to ask you at the time — so the answer is given here, in advance.', 'unsupervised-schedular'); ?>
</p>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e('Always removed', 'unsupervised-schedular'); ?></th>
<td>
<p class="description">
<?php esc_html_e('Your Stripe secret key and webhook signing secret are forgotten whenever this plugin is deleted, whatever you choose below. They can be pasted back in from the Stripe dashboard in a minute; leaving live keys on a site that no longer has the code to use them cannot be undone as easily. The two WordPress settings open registration borrows — whether anyone can register, and the role new users get — are put back as they were, too.', 'unsupervised-schedular'); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php esc_html_e('Studio records', 'unsupervised-schedular'); ?></th>
<td>
<fieldset>
<label>
<input type="checkbox" name="delete_data" value="1" <?php checked($deleteDataOnUninstall); ?>>
<?php esc_html_e('Erase everything when the plugin is deleted', 'unsupervised-schedular'); ?>
</label>
<p class="description">
<?php esc_html_e('Off by default: deleting the plugin leaves your lessons, enrolments, payments, credits, intake answers, policy agreements, invites and family links in the database, so reinstalling picks up exactly where you left off — and a deletion during a migration or a bit of troubleshooting costs you nothing.', 'unsupervised-schedular'); ?>
</p>
<p class="description">
<strong><?php esc_html_e('Turn it on and there is no way back.', 'unsupervised-schedular'); ?></strong>
<?php esc_html_e('Every table this plugin made is dropped, every setting and student record it stored is deleted, and the Studio Admin, Instructor and Student roles are removed. WordPress does not ask twice and there is no undo. Take a database backup first if the studio has ever taken a payment.', 'unsupervised-schedular'); ?>
</p>
<?php if (! $deleteDataOnUninstall) : ?>
<p>
<label for="usc-delete-confirm"><?php esc_html_e('To turn it on, type DELETE:', 'unsupervised-schedular'); ?></label>
<input type="text" name="delete_data_confirm" id="usc-delete-confirm" value="" autocomplete="off" class="regular-text" style="max-width:12em;">
</p>
<?php else : ?>
<p class="description">
<?php esc_html_e('Currently on. Untick the box and save to turn it off again.', 'unsupervised-schedular'); ?>
</p>
<?php endif; ?>
</fieldset>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Save Access Settings', 'unsupervised-schedular')); ?>
</form>
</div>
+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();
+243
View File
@@ -0,0 +1,243 @@
<?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;
// A regular closure, not an arrow fn: arrow functions capture by value,
// so every read would answer from a snapshot of the options taken at
// setUp — before the test set any, and before the run wrote any.
Functions\when('get_option')->alias(
static function (string $key, mixed $default = false) use (&$options): mixed {
return $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);
+5 -10
View File
@@ -1,19 +1,14 @@
<?php
declare(strict_types=1);
use Unsupervised\Schedular\Uninstaller;
if (! defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
require_once plugin_dir_path(__FILE__) . 'vendor/autoload.php';
global $wpdb;
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}us_lessons");
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}us_availability");
remove_role('us_instructor');
remove_role('us_student');
delete_option('us_schedular_version');
delete_transient('us_schedular_latest_release');
// What this does and does not take with it is decided on Access → Plugin
// removal, and explained in full in Uninstaller.
(new Uninstaller())->run();
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.5.4
* Version: 1.5.7
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.5.4');
define('USC_VERSION', '1.5.7');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));