Compare commits
27
Commits
v1.5.3
..
ae1a62883e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1a62883e
|
||
|
|
1847159e31
|
||
|
|
74df3f5ba8
|
||
|
|
f8f211929c | ||
|
|
61a40f0e8d | ||
|
|
88a8d0ae5c
|
||
|
|
e2da45a1b0
|
||
|
|
dfa29745df | ||
|
|
54a8b906f7
|
||
|
|
b3ed3a67d5 | ||
|
|
908b7fcd1f
|
||
|
|
b17adf02ff
|
||
|
|
a23490ec80 | ||
|
|
1552bf4b5f
|
||
|
|
f8762e1095
|
||
|
|
c9d18fec74
|
||
|
|
ab609898d6
|
||
|
|
4bc8e80837 | ||
|
|
28f586d207
|
||
|
|
e097b7a0bf | ||
|
|
572aaf5b49
|
||
|
|
116394f2ff | ||
|
|
dda8386c1f | ||
|
|
7278309bf5 | ||
|
|
5ce42f0003
|
||
|
|
76530878b5 | ||
|
|
6031a75012 |
+45
-113
@@ -7,56 +7,31 @@ on:
|
|||||||
- develop
|
- develop
|
||||||
pull_request:
|
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:
|
jobs:
|
||||||
# PHPCS and PHPStan share a job so the two of them draw once on Setup PHP
|
phpcs:
|
||||||
# rather than twice. That step is slow and intermittently fails on 8.3
|
name: Coding Standards
|
||||||
# (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
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.unsupervised.ca/unsupervised/ci-php:8.3
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# setup-php installs PHP 8.3+ through php-builder, which apt-installs ~70
|
# COMPOSER_HOME is /composer in the image, so that is where the
|
||||||
# -dev packages before unpacking the build (#178). Ubuntu's image deletes
|
# download cache lives. composer.lock is what fingerprints the
|
||||||
# the .debs after install, so every job re-downloads them. Keeping them
|
# dependency set.
|
||||||
# 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: Cache Composer packages
|
- name: Cache Composer packages
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache
|
path: /composer/cache
|
||||||
key: composer-${{ hashFiles('composer.json') }}
|
key: composer-${{ hashFiles('composer.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: composer install --prefer-dist --no-progress --no-interaction
|
run: composer install --prefer-dist --no-progress --no-interaction
|
||||||
@@ -64,15 +39,36 @@ jobs:
|
|||||||
- name: Run PHPCS
|
- name: Run PHPCS
|
||||||
run: composer cs
|
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
|
- name: Run PHPStan
|
||||||
run: composer lint
|
run: composer lint
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests (PHP ${{ matrix.php }})
|
name: Tests (PHP ${{ matrix.php }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.unsupervised.ca/unsupervised/ci-php:${{ matrix.php }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
# A version can only be added here once ci-php publishes the matching
|
||||||
|
# tag.
|
||||||
php:
|
php:
|
||||||
- '8.1'
|
- '8.1'
|
||||||
- '8.2'
|
- '8.2'
|
||||||
@@ -81,46 +77,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Cache Composer packages
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.composer/cache
|
path: /composer/cache
|
||||||
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.json') }}
|
key: ${{ matrix.php }}-composer-${{ hashFiles('composer.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: composer install --prefer-dist --no-progress --no-interaction
|
run: composer install --prefer-dist --no-progress --no-interaction
|
||||||
@@ -128,6 +89,8 @@ jobs:
|
|||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: composer test
|
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:
|
no-debug:
|
||||||
name: No Debug Code
|
name: No Debug Code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -144,46 +107,15 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build Plugin Zip
|
name: Build Plugin Zip
|
||||||
runs-on: ubuntu-latest
|
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
|
# Only build a shippable artifact once changes land on main, and only
|
||||||
# after the quality gates pass.
|
# 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'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Build plugin zip
|
||||||
run: composer build
|
run: composer build
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,13 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Build and Publish Release
|
name: Build and Publish Release
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
# 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.
|
# phantom update forever (or never see a real one), so fail fast.
|
||||||
- name: Verify tag matches plugin version
|
- name: Verify tag matches plugin version
|
||||||
@@ -37,6 +35,14 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "version=${header_version}" >> "$GITHUB_OUTPUT"
|
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
|
- name: Install dependencies
|
||||||
run: composer install --prefer-dist --no-progress --no-interaction
|
run: composer install --prefer-dist --no-progress --no-interaction
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
vendor/
|
vendor/
|
||||||
composer.lock
|
|
||||||
coverage/
|
coverage/
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -11,6 +11,29 @@ 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
|
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.
|
each change under the current top section as you work.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Adding students to a group class now checks that each one is actually a student.** **Add students directly** and **Make available** acted on whatever ids the form posted without confirming they named students at all, so a stale page — or a tampered submission — could put an instructor, an administrator, or an account that had since been deleted onto a class roster, raising a real payment against them. Both controls now skip anything that is not a student, and the "%d student(s) added" count tells you how many actually went through. Children and students awaiting approval are unaffected: they are students, and adding them is what these controls are for.
|
||||||
|
- **A refused booking no longer empties the Book a lesson for a student form.** Whatever the reason it came back — the time taken while you were typing, a weekly reservation asked for on a time that does not repeat — the panel reopens with the student, time, lesson type, both ticks and your note exactly as you left them, so a correction is one field, not five. A booking that goes through still leaves an empty form behind for the next one.
|
||||||
|
- **Booking a lesson for a child, or for a student you have not approved yet, no longer fails with "Choose a student to book for."** The **Book a lesson for a student** panel offered every student it could see, but then refused a good half of them: a parent's child and a self-signup still awaiting approval both appeared in the list, and both were rejected on submit — with an error that read as though no student had been chosen, and which cleared the form. Neither of those accounts is allowed to book *in their own name* (a child's is never signed in to at all, and an unapproved signup waits for you), and the panel was mistakenly applying that same restriction to the studio booking on their behalf, which is precisely the case it was built for. Anyone the panel offers can now be booked for.
|
||||||
|
|
||||||
## [1.5.3]
|
## [1.5.3]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ INCLUDE=(
|
|||||||
"$SLUG.php"
|
"$SLUG.php"
|
||||||
"uninstall.php"
|
"uninstall.php"
|
||||||
"composer.json"
|
"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"
|
"src"
|
||||||
"templates"
|
"templates"
|
||||||
"assets"
|
"assets"
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,7 @@
|
|||||||
"license": "GPL-2.0-or-later",
|
"license": "GPL-2.0-or-later",
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.1",
|
"php": ">=8.1",
|
||||||
"stripe/stripe-php": "^17.0"
|
"stripe/stripe-php": "^21.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^10.5",
|
"phpunit/phpunit": "^10.5",
|
||||||
@@ -36,6 +36,9 @@
|
|||||||
"build": "bash bin/build-zip.sh"
|
"build": "bash bin/build-zip.sh"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
"platform": {
|
||||||
|
"php": "8.1"
|
||||||
|
},
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2589
File diff suppressed because it is too large
Load Diff
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# 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.
|
||||||
@@ -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.
|
- `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.
|
- `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)
|
## Account Lifecycle (self-approval)
|
||||||
State lives entirely in user meta (`Auth\RegistrationStatus`). Only the raw
|
State lives entirely in user meta (`Auth\RegistrationStatus`). Only the raw
|
||||||
confirmation token's SHA-256 hash is stored; the token expires after 48h
|
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`
|
- Repository: `Unsupervised\Schedular\Auth\InviteRepository`
|
||||||
- Admin controllers: `Unsupervised\Schedular\Auth\RegistrationController` (invites), `Unsupervised\Schedular\Auth\RegistrationApprovalController` (pending students)
|
- Admin controllers: `Unsupervised\Schedular\Auth\RegistrationController` (invites), `Unsupervised\Schedular\Auth\RegistrationApprovalController` (pending students)
|
||||||
- Frontend: `Unsupervised\Schedular\Auth\RegistrationPage`
|
- 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)
|
- Settings toggle: `Payment\StudioSettings` (`us_registration_mode`, core-option mirror/restore)
|
||||||
- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository`
|
- Reuses `Policy\PolicyRepository`, `Policy\PolicyVersionRepository`, `Policy\AcceptanceRepository`
|
||||||
- Schema: `us_invites`; `us_policies.acceptance_scope`. Self-approval adds no tables — state is WordPress user meta.
|
- Schema: `us_invites`; `us_policies.acceptance_scope`. Self-approval adds no tables — state is WordPress user meta.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
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
|
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
|
||||||
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
|
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
|
contact the studio to withdraw." in place of the Withdraw button. An enrolment that
|
||||||
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
|
does not exist and one that is not the caller's own both return the same
|
||||||
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
|
`404 not_found`, deliberately: two different answers would let any signed-in student
|
||||||
idempotent.
|
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
|
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`),
|
student at any time from the **student detail page** (`Auth\StudentActions::withdrawEnrollment`),
|
||||||
@@ -192,6 +194,17 @@ controls beneath it:
|
|||||||
a **pending** invite, the grant is attached to that invite and **no second link is
|
a **pending** invite, the grant is attached to that invite and **no second link is
|
||||||
sent**. An address that already has an account is treated as **Make available** instead.
|
sent**. An address that already has an account is treated as **Make available** instead.
|
||||||
|
|
||||||
|
Both student-picking controls vet every posted id with `Auth\RoleManager::isStudent()`
|
||||||
|
before acting on it — the same predicate the picker is built from, and the same one
|
||||||
|
`Booking\AdminBooking` guards a staff booking with. A posted id naming an instructor,
|
||||||
|
an administrator, or an account deleted since the page was drawn is skipped rather
|
||||||
|
than enrolled, so nothing can put a non-student on a roster or raise a payment
|
||||||
|
against one. Being a student is a matter of the **role**, not the `book_lesson`
|
||||||
|
capability, so a guardian's child and a signup still awaiting approval are both
|
||||||
|
fully enrollable — neither may enrol *themselves*, which is exactly what the studio
|
||||||
|
adding them is for. The reported count is what was actually added, so a skipped id
|
||||||
|
shows up as a smaller number.
|
||||||
|
|
||||||
When an email-invited person completes registration, `RegistrationPage` links their new
|
When an email-invited person completes registration, `RegistrationPage` links their new
|
||||||
account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only
|
account to the grant (`GroupAccessRepository::linkStudentByEmail`), so the invite-only
|
||||||
class becomes enrollable for them — they choose whether to enrol.
|
class becomes enrollable for them — they choose whether to enrol.
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ lesson. The times offered are the open slots of the next eight weeks — every
|
|||||||
instructor's on the studio **Scheduler**, only the instructor's own on **My
|
instructor's on the studio **Scheduler**, only the instructor's own on **My
|
||||||
Lessons**, which `AdminBooking::book()` re-checks rather than trusting the
|
Lessons**, which `AdminBooking::book()` re-checks rather than trusting the
|
||||||
posted slot id. The result is reported as a notice above the panel saying what
|
posted slot id. The result is reported as a notice above the panel saying what
|
||||||
was booked and what it left owing; a refusal reopens the panel with the reason.
|
was booked and what it left owing; a refusal reopens the panel with the reason
|
||||||
|
and every field as it was submitted, so only the mistake needs correcting. A
|
||||||
|
booking that succeeds clears the form, so the next one does not inherit it.
|
||||||
|
|
||||||
It is the same booking a student makes — `LessonBooker` claims the slot(s),
|
It is the same booking a student makes — `LessonBooker` claims the slot(s),
|
||||||
writes the lesson row(s), and raises the payment exactly as `POST /bookings`
|
writes the lesson row(s), and raises the payment exactly as `POST /bookings`
|
||||||
does — and differs in three deliberate ways:
|
does — and differs in four deliberate ways:
|
||||||
|
|
||||||
1. **No intake answers or policy acceptances are recorded at booking time.**
|
1. **No intake answers or policy acceptances are recorded at booking time.**
|
||||||
Those are the student's to give; staff ticking the boxes for them would be an
|
Those are the student's to give; staff ticking the boxes for them would be an
|
||||||
@@ -113,6 +115,17 @@ does — and differs in three deliberate ways:
|
|||||||
whole series) is `confirmed` at once. Without the tick a pending payment is
|
whole series) is `confirmed` at once. Without the tick a pending payment is
|
||||||
raised at the lesson type's price, per-occurrence for a weekly reservation,
|
raised at the lesson type's price, per-occurrence for a weekly reservation,
|
||||||
and the lesson confirms when it settles like any other.
|
and the lesson confirms when it settles like any other.
|
||||||
|
4. **It can book for a student who cannot book at all.** The guard is
|
||||||
|
`Auth\RoleManager::isStudent()` — the student *role*, not the `book_lesson`
|
||||||
|
capability — so it covers a guardian's child and a self-signup still awaiting
|
||||||
|
approval alike, and is shared with the group-class **Add students directly**
|
||||||
|
and **Make available** controls so the two paths cannot drift. Both hold the role;
|
||||||
|
both have `book_lesson` withheld (`Guardian\ChildLoginGate`,
|
||||||
|
`Auth\RegistrationLoginGate`) so that neither can book in their own name.
|
||||||
|
That restriction is on them, not on the studio acting for them — and for a
|
||||||
|
child, whose account is never signed in to, it is the only route to a lesson
|
||||||
|
besides their guardian's. The picker and the guard therefore accept exactly
|
||||||
|
the same set, so nothing offered in the panel can be refused as ineligible.
|
||||||
|
|
||||||
A weekly reservation needs a time that actually repeats: asked for one on a
|
A weekly reservation needs a time that actually repeats: asked for one on a
|
||||||
one-off slot, the form refuses (`not_weekly`) rather than quietly booking a
|
one-off slot, the form refuses (`not_weekly`) rather than quietly booking a
|
||||||
|
|||||||
@@ -47,9 +47,21 @@ update for a same-slug plugin and makes core fire the
|
|||||||
`us_schedular_latest_release` transient for 6 hours.
|
`us_schedular_latest_release` transient for 6 hours.
|
||||||
3. Strips the leading `v` from the tag and compares against `USC_VERSION`
|
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.
|
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
|
4. When newer, returns the release's first `.zip` asset **whose download URL
|
||||||
package. Core takes over from there: Plugins-screen notice, one-click
|
is `https` on `git.unsupervised.ca` itself** as the update package. Core
|
||||||
update, and WP-Cron auto-updates if enabled.
|
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
|
5. When not newer — the site is current, or the lookup failed — returns a
|
||||||
`no_update` payload (installed version, empty package). This keeps the
|
`no_update` payload (installed version, empty package). This keeps the
|
||||||
plugin in core's `update_plugins` transient so core's `update-supported`
|
plugin in core's `update_plugins` transient so core's `update-supported`
|
||||||
|
|||||||
@@ -3,17 +3,24 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular\Auth;
|
namespace Unsupervised\Schedular\Auth;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Uninstaller;
|
||||||
use Unsupervised\Schedular\Val;
|
use Unsupervised\Schedular\Val;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site-owner toggles for whether WordPress administrators automatically receive
|
* The site owner's page: whether WordPress administrators automatically receive
|
||||||
* the studio-admin and/or instructor capabilities.
|
* 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
|
* Both capability grants default on, preserving the out-of-the-box experience
|
||||||
* administrator runs the studio and teaches from one account. The settings page
|
* where a single administrator runs the studio and teaches from one account. The
|
||||||
* is gated on `manage_options` (the core WordPress administrator capability,
|
* settings page is gated on `manage_options` (the core WordPress administrator
|
||||||
* which the plugin never grants or revokes) so an administrator can always reach
|
* capability, which the plugin never grants or revokes) so an administrator can
|
||||||
* it to re-enable a grant — disabling one can never lock them out.
|
* 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 {
|
class AccessSettings {
|
||||||
|
|
||||||
@@ -49,21 +56,56 @@ class AccessSettings {
|
|||||||
wp_die( esc_html__( 'You do not have permission to manage access settings.', 'unsupervised-schedular' ) );
|
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' ) ) {
|
if ( isset( $_POST['usc_action'] ) && check_admin_referer( 'usc_access_action' ) ) {
|
||||||
$this->save();
|
$error = $this->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$adminsAreStudioAdmins = $this->adminsAreStudioAdmins();
|
$adminsAreStudioAdmins = $this->adminsAreStudioAdmins();
|
||||||
$adminsAreInstructors = $this->adminsAreInstructors();
|
$adminsAreInstructors = $this->adminsAreInstructors();
|
||||||
|
$deleteDataOnUninstall = Uninstaller::deletesDataOnUninstall();
|
||||||
|
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/access.php';
|
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.
|
// Nonce is verified by the caller (renderPage) before this method runs.
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||||
update_option( self::OPT_GRANT_STUDIO, isset( $_POST['grant_studio'] ) ? '1' : '0' );
|
update_option( self::OPT_GRANT_STUDIO, isset( $_POST['grant_studio'] ) ? '1' : '0' );
|
||||||
update_option( self::OPT_GRANT_INSTRUCTOR, isset( $_POST['grant_instructor'] ) ? '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
|
// 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 '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ class LoginPage {
|
|||||||
'remember' => isset( $_POST['rememberme'] ),
|
'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 ) ) {
|
if ( is_wp_error( $user ) ) {
|
||||||
$error = esc_html__( 'Invalid username or password.', 'unsupervised-schedular' );
|
$error = esc_html__( 'Invalid username or password.', 'unsupervised-schedular' );
|
||||||
|
|||||||
@@ -11,12 +11,59 @@ namespace Unsupervised\Schedular\Auth;
|
|||||||
*
|
*
|
||||||
* Both checks key solely off the pending user meta, so invite- and
|
* Both checks key solely off the pending user meta, so invite- and
|
||||||
* admin-created students (which carry none of it) are unaffected.
|
* 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 {
|
class RegistrationLoginGate {
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
|
add_filter( 'wp_authenticate_user', [ $this, 'blockUnconfirmed' ], 10, 1 );
|
||||||
add_filter( 'user_has_cap', [ $this, 'withholdBookingWhilePending' ], 10, 4 );
|
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 );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -431,6 +431,13 @@ class RegistrationPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
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 );
|
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||||
|
|
||||||
// A personal invite may carry a group-class grant (invited by email);
|
// A personal invite may carry a group-class grant (invited by email);
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ class RegistrationStatus {
|
|||||||
$rawToken = wp_generate_password( 32, false );
|
$rawToken = wp_generate_password( 32, false );
|
||||||
|
|
||||||
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
|
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, self::META_CONFIRM_TOKEN, self::hashToken( $rawToken ) );
|
||||||
update_user_meta(
|
update_user_meta(
|
||||||
$userId,
|
$userId,
|
||||||
@@ -72,6 +77,21 @@ class RegistrationStatus {
|
|||||||
return $rawToken;
|
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
|
* Mark the account's email confirmed and discard the (now spent) token. The
|
||||||
* account stays awaiting approval.
|
* account stays awaiting approval.
|
||||||
|
|||||||
@@ -60,6 +60,27 @@ class RoleManager {
|
|||||||
self::CAP_EXPORT_PAYMENTS,
|
self::CAP_EXPORT_PAYMENTS,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a user account is a student the studio may act for.
|
||||||
|
*
|
||||||
|
* Deliberately the role and not the `book_lesson` capability: that capability
|
||||||
|
* is withheld from a guardian's child ({@see \Unsupervised\Schedular\Guardian\ChildLoginGate})
|
||||||
|
* and from a self-signup still awaiting approval
|
||||||
|
* ({@see \Unsupervised\Schedular\Auth\RegistrationLoginGate}), so that neither
|
||||||
|
* can book or enrol *in their own name*. Staff booking or enrolling on their
|
||||||
|
* behalf is the case those restrictions exist to leave open — and for a child,
|
||||||
|
* whose account is never signed in to, it is the only route there is.
|
||||||
|
*
|
||||||
|
* Use this for every "may the studio register this person?" check, so the
|
||||||
|
* pickers staff choose from and the guards that vet their choice cannot drift
|
||||||
|
* into offering someone who is then refused.
|
||||||
|
*/
|
||||||
|
public static function isStudent( int $userId ): bool {
|
||||||
|
$user = $userId > 0 ? get_userdata( $userId ) : false;
|
||||||
|
|
||||||
|
return $user instanceof \WP_User && in_array( self::STUDENT, (array) $user->roles, true );
|
||||||
|
}
|
||||||
|
|
||||||
public function __construct( private AccessSettings $access = new AccessSettings() ) {}
|
public function __construct( private AccessSettings $access = new AccessSettings() ) {}
|
||||||
|
|
||||||
public function register(): void {
|
public function register(): void {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use Unsupervised\Schedular\Val;
|
|||||||
* flow.
|
* flow.
|
||||||
*
|
*
|
||||||
* It reuses `LessonBooker` — the same offering rules, the same atomic slot claim,
|
* It reuses `LessonBooker` — the same offering rules, the same atomic slot claim,
|
||||||
* the same billing — and differs from a student's own booking in exactly three
|
* the same billing — and differs from a student's own booking in exactly four
|
||||||
* ways, each deliberate:
|
* ways, each deliberate:
|
||||||
*
|
*
|
||||||
* 1. **No intake questions or policy acceptances are recorded.** They are the
|
* 1. **No intake questions or policy acceptances are recorded.** They are the
|
||||||
@@ -30,6 +30,10 @@ use Unsupervised\Schedular\Val;
|
|||||||
* slot of the instructor's, including one only reachable past a deadline.
|
* slot of the instructor's, including one only reachable past a deadline.
|
||||||
* 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which
|
* 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which
|
||||||
* skips the payment entirely and confirms the lesson at once.
|
* skips the payment entirely and confirms the lesson at once.
|
||||||
|
* 4. **It can book for a student who cannot book at all** — a guardian's child,
|
||||||
|
* or someone still awaiting approval. Both hold the student role but have
|
||||||
|
* `book_lesson` withheld so that neither can book in their own name; that is
|
||||||
|
* a limit on them, never on the studio acting for them.
|
||||||
*/
|
*/
|
||||||
class AdminBooking {
|
class AdminBooking {
|
||||||
|
|
||||||
@@ -54,7 +58,10 @@ class AdminBooking {
|
|||||||
* @return string|\WP_Error Success notice, or why nothing was booked.
|
* @return string|\WP_Error Success notice, or why nothing was booked.
|
||||||
*/
|
*/
|
||||||
public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error {
|
public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error {
|
||||||
if ( $studentId <= 0 || ! user_can( $studentId, RoleManager::CAP_BOOK_LESSON ) ) {
|
// The student role, not the `book_lesson` capability — see
|
||||||
|
// {@see RoleManager::isStudent()} for why a child and an unapproved signup
|
||||||
|
// must both be bookable for.
|
||||||
|
if ( ! RoleManager::isStudent( $studentId ) ) {
|
||||||
return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) );
|
return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,8 +257,10 @@ class AdminBooking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everyone who can be booked for, by name — students and the children a
|
* Everyone who can be booked for, by name — every holder of the student role,
|
||||||
* guardian books for alike, since both hold `book_lesson`.
|
* which is exactly the set {@see book()} accepts. That deliberately includes
|
||||||
|
* the children a guardian books for and students still awaiting approval:
|
||||||
|
* neither may book in their own name, both may be booked for.
|
||||||
*
|
*
|
||||||
* @return list<array{id: int, name: string}>
|
* @return list<array{id: int, name: string}>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -330,14 +330,19 @@ class BookingEndpoint {
|
|||||||
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
||||||
$lesson = $this->bookings->findById( $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 ] );
|
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 ) {
|
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
|
||||||
$slot = $this->availability->findById( $lesson->slotId );
|
$slot = $this->availability->findById( $lesson->slotId );
|
||||||
if ( null !== $slot ) {
|
if ( null !== $slot ) {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class LessonController {
|
|||||||
*
|
*
|
||||||
* @param list<array<string, mixed>> $rows
|
* @param list<array<string, mixed>> $rows
|
||||||
*/
|
*/
|
||||||
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice and $error are read by the included template.
|
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice is read by the included template.
|
||||||
private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void {
|
private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void {
|
||||||
// View-state query params only (which view, which week) — nothing is
|
// View-state query params only (which view, which week) — nothing is
|
||||||
// mutated from them, so no nonce applies.
|
// mutated from them, so no nonce applies.
|
||||||
@@ -193,6 +193,11 @@ class LessonController {
|
|||||||
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
|
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
|
||||||
$bookForm = $this->adminBooking->formData( $onlyInstructorId );
|
$bookForm = $this->adminBooking->formData( $onlyInstructorId );
|
||||||
|
|
||||||
|
// A refused booking is shown again as it was typed — losing five fields to a
|
||||||
|
// single mistake is what made the panel infuriating to correct. A successful
|
||||||
|
// one starts empty, so the next booking does not inherit the last one's.
|
||||||
|
$bookValues = '' !== $error ? $this->submittedBooking() : $this->emptyBooking();
|
||||||
|
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
|
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,23 +234,59 @@ class LessonController {
|
|||||||
* @return array{string, string}
|
* @return array{string, string}
|
||||||
*/
|
*/
|
||||||
private function bookForStudent( int $onlyInstructorId ): array {
|
private function bookForStudent( int $onlyInstructorId ): array {
|
||||||
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
$submitted = $this->submittedBooking();
|
||||||
|
|
||||||
$result = $this->adminBooking->book(
|
$result = $this->adminBooking->book(
|
||||||
absint( Val::int( $_POST['student_id'] ?? 0 ) ),
|
$submitted['student_id'],
|
||||||
absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
|
$submitted['slot_id'],
|
||||||
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
|
$submitted['offering_id'],
|
||||||
isset( $_POST['recurrence_weekly'] ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
|
$submitted['weekly'] ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
|
||||||
isset( $_POST['no_charge'] ),
|
$submitted['no_charge'],
|
||||||
sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
|
$submitted['notes'],
|
||||||
$onlyInstructorId
|
$onlyInstructorId
|
||||||
);
|
);
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
|
||||||
|
|
||||||
return $result instanceof \WP_Error
|
return $result instanceof \WP_Error
|
||||||
? [ '', $result->get_error_message() ]
|
? [ '', $result->get_error_message() ]
|
||||||
: [ $result, '' ];
|
: [ $result, '' ];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The book-for-a-student form exactly as submitted. Read in one place so what
|
||||||
|
* gets booked and what the form shows again after a refusal cannot drift apart
|
||||||
|
* on a field name.
|
||||||
|
*
|
||||||
|
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
|
||||||
|
*/
|
||||||
|
private function submittedBooking(): array {
|
||||||
|
// phpcs:disable WordPress.Security.NonceVerification.Missing -- read only after handleFormAction() has verified the nonce: to book, or to re-render (escaped) a form it refused.
|
||||||
|
return [
|
||||||
|
'student_id' => absint( Val::int( $_POST['student_id'] ?? 0 ) ),
|
||||||
|
'slot_id' => absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
|
||||||
|
'offering_id' => absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
|
||||||
|
'weekly' => isset( $_POST['recurrence_weekly'] ),
|
||||||
|
'no_charge' => isset( $_POST['no_charge'] ),
|
||||||
|
'notes' => sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
|
||||||
|
];
|
||||||
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An untouched book-for-a-student form.
|
||||||
|
*
|
||||||
|
* @return array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string}
|
||||||
|
*/
|
||||||
|
private function emptyBooking(): array {
|
||||||
|
return [
|
||||||
|
'student_id' => 0,
|
||||||
|
'slot_id' => 0,
|
||||||
|
'offering_id' => 0,
|
||||||
|
'weekly' => false,
|
||||||
|
'no_charge' => false,
|
||||||
|
'notes' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a per-lesson payment override. When $onlyOwn, the payment must belong
|
* Apply a per-lesson payment override. When $onlyOwn, the payment must belong
|
||||||
* to the current instructor.
|
* to the current instructor.
|
||||||
|
|||||||
@@ -202,14 +202,14 @@ class EnrollmentEndpoint {
|
|||||||
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
$id = absint( Val::int( $request->get_param( 'id' ) ) );
|
||||||
$enrollment = $this->enrollments->findById( $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 ] );
|
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 ) {
|
if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) {
|
||||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||||
|
|
||||||
|
|||||||
@@ -640,7 +640,15 @@ class GroupClassController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The de-duplicated positive student ids posted from a multi-select.
|
* The de-duplicated student ids posted from a multi-select, keeping only ids
|
||||||
|
* that are actually students.
|
||||||
|
*
|
||||||
|
* The select is built from {@see studentOptions()}, but nothing stops a posted
|
||||||
|
* id naming an instructor, an administrator, or an account deleted since the
|
||||||
|
* page was drawn — and enrolling one would write a roster row, and bill it,
|
||||||
|
* against someone who is not in the class. Vetting here covers both actions at
|
||||||
|
* once, and against the same {@see RoleManager::isStudent()} the picker uses,
|
||||||
|
* so a child or an unapproved signup is still perfectly enrollable.
|
||||||
*
|
*
|
||||||
* @return list<int>
|
* @return list<int>
|
||||||
*/
|
*/
|
||||||
@@ -650,7 +658,7 @@ class GroupClassController {
|
|||||||
$raw = (array) ( $_POST['student_ids'] ?? [] );
|
$raw = (array) ( $_POST['student_ids'] ?? [] );
|
||||||
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
|
$ids = array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $raw ) );
|
||||||
|
|
||||||
return array_values( array_unique( $ids ) );
|
return array_values( array_filter( array_unique( $ids ), RoleManager::isStudent( ... ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Unsupervised\Schedular\Guardian;
|
namespace Unsupervised\Schedular\Guardian;
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Auth\UserName;
|
use Unsupervised\Schedular\Auth\UserName;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
@@ -103,6 +104,14 @@ class GuardianService {
|
|||||||
$userId = (int) $userId;
|
$userId = (int) $userId;
|
||||||
|
|
||||||
update_user_meta( $userId, self::META_CHILD, '1' );
|
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 );
|
$this->setBirthYear( $userId, $birthYear );
|
||||||
|
|
||||||
$linkId = $this->guardians->insert(
|
$linkId = $this->guardians->insert(
|
||||||
|
|||||||
@@ -5,6 +5,33 @@ namespace Unsupervised\Schedular;
|
|||||||
|
|
||||||
class Schema {
|
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.
|
* Returns CREATE TABLE statements for dbDelta.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -178,8 +178,13 @@ class UpdateChecker {
|
|||||||
if ( ! is_array( $asset ) ) {
|
if ( ! is_array( $asset ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
|
if ( ! str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
|
||||||
$package = Val::string( $asset['browser_download_url'] ?? '' );
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = Val::string( $asset['browser_download_url'] ?? '' );
|
||||||
|
if ( self::isTrustedPackage( $url ) ) {
|
||||||
|
$package = $url;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,4 +199,40 @@ class UpdateChecker {
|
|||||||
'package' => $package,
|
'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'] ?? '' ) );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ if (! defined('ABSPATH')) {
|
|||||||
/**
|
/**
|
||||||
* @var bool $adminsAreStudioAdmins
|
* @var bool $adminsAreStudioAdmins
|
||||||
* @var bool $adminsAreInstructors
|
* @var bool $adminsAreInstructors
|
||||||
|
* @var bool $deleteDataOnUninstall
|
||||||
|
* @var string $error Why the data-removal choice was refused, if it was.
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -19,6 +21,12 @@ if (! defined('ABSPATH')) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($error !== '') : ?>
|
||||||
|
<div class="notice notice-error inline">
|
||||||
|
<p><?php echo esc_html($error); ?></p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<?php wp_nonce_field('usc_access_action'); ?>
|
<?php wp_nonce_field('usc_access_action'); ?>
|
||||||
<input type="hidden" name="usc_action" value="save">
|
<input type="hidden" name="usc_action" value="save">
|
||||||
@@ -45,6 +53,50 @@ if (! defined('ABSPATH')) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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')); ?>
|
<?php submit_button(esc_html__('Save Access Settings', 'unsupervised-schedular')); ?>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ if (! defined('ABSPATH')) {
|
|||||||
* @var string $notice
|
* @var string $notice
|
||||||
* @var string $error
|
* @var string $error
|
||||||
* @var array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>} $bookForm
|
* @var array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>} $bookForm
|
||||||
|
* @var array{student_id: int, slot_id: int, offering_id: int, weekly: bool, no_charge: bool, notes: string} $bookValues
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
@@ -54,7 +55,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<select name="student_id" id="usc-book-student" required>
|
<select name="student_id" id="usc-book-student" required>
|
||||||
<option value=""><?php esc_html_e('Choose a student', 'unsupervised-schedular'); ?></option>
|
<option value=""><?php esc_html_e('Choose a student', 'unsupervised-schedular'); ?></option>
|
||||||
<?php foreach ($bookForm['students'] as $student) : ?>
|
<?php foreach ($bookForm['students'] as $student) : ?>
|
||||||
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
|
<option value="<?php echo esc_attr((string) $student['id']); ?>" <?php selected($bookValues['student_id'], $student['id']); ?>><?php echo esc_html($student['name']); ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -65,7 +66,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<select name="slot_id" id="usc-book-slot" required style="max-width:100%;">
|
<select name="slot_id" id="usc-book-slot" required style="max-width:100%;">
|
||||||
<option value=""><?php esc_html_e('Choose an open time', 'unsupervised-schedular'); ?></option>
|
<option value=""><?php esc_html_e('Choose an open time', 'unsupervised-schedular'); ?></option>
|
||||||
<?php foreach ($bookForm['slots'] as $slot) : ?>
|
<?php foreach ($bookForm['slots'] as $slot) : ?>
|
||||||
<option value="<?php echo esc_attr((string) $slot['id']); ?>"><?php echo esc_html($slot['label']); ?></option>
|
<option value="<?php echo esc_attr((string) $slot['id']); ?>" <?php selected($bookValues['slot_id'], $slot['id']); ?>><?php echo esc_html($slot['label']); ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
@@ -76,7 +77,7 @@ if (! defined('ABSPATH')) {
|
|||||||
<select name="offering_id" id="usc-book-offering" style="max-width:100%;">
|
<select name="offering_id" id="usc-book-offering" style="max-width:100%;">
|
||||||
<option value="0"><?php esc_html_e('Use the time\'s own lesson type', 'unsupervised-schedular'); ?></option>
|
<option value="0"><?php esc_html_e('Use the time\'s own lesson type', 'unsupervised-schedular'); ?></option>
|
||||||
<?php foreach ($bookForm['offerings'] as $offering) : ?>
|
<?php foreach ($bookForm['offerings'] as $offering) : ?>
|
||||||
<option value="<?php echo esc_attr((string) $offering['id']); ?>"><?php echo esc_html($offering['label']); ?></option>
|
<option value="<?php echo esc_attr((string) $offering['id']); ?>" <?php selected($bookValues['offering_id'], $offering['id']); ?>><?php echo esc_html($offering['label']); ?></option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<p class="description"><?php esc_html_e('A time already tied to a lesson type is booked as that type; a general time needs one chosen here.', 'unsupervised-schedular'); ?></p>
|
<p class="description"><?php esc_html_e('A time already tied to a lesson type is booked as that type; a general time needs one chosen here.', 'unsupervised-schedular'); ?></p>
|
||||||
@@ -86,12 +87,12 @@ if (! defined('ABSPATH')) {
|
|||||||
<th scope="row"><?php esc_html_e('Options', 'unsupervised-schedular'); ?></th>
|
<th scope="row"><?php esc_html_e('Options', 'unsupervised-schedular'); ?></th>
|
||||||
<td>
|
<td>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="recurrence_weekly" value="1">
|
<input type="checkbox" name="recurrence_weekly" value="1" <?php checked($bookValues['weekly']); ?>>
|
||||||
<?php esc_html_e('Reserve this time weekly for the rest of the term', 'unsupervised-schedular'); ?>
|
<?php esc_html_e('Reserve this time weekly for the rest of the term', 'unsupervised-schedular'); ?>
|
||||||
</label>
|
</label>
|
||||||
<p class="description"><?php esc_html_e('Only for a time that repeats weekly. Billed upfront as one payment.', 'unsupervised-schedular'); ?></p>
|
<p class="description"><?php esc_html_e('Only for a time that repeats weekly. Billed upfront as one payment.', 'unsupervised-schedular'); ?></p>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="no_charge" value="1">
|
<input type="checkbox" name="no_charge" value="1" <?php checked($bookValues['no_charge']); ?>>
|
||||||
<?php esc_html_e('No charge — book it free and confirm it now', 'unsupervised-schedular'); ?>
|
<?php esc_html_e('No charge — book it free and confirm it now', 'unsupervised-schedular'); ?>
|
||||||
</label>
|
</label>
|
||||||
<p class="description"><?php esc_html_e('For a make-up or goodwill lesson. Otherwise a pending payment is raised at the lesson type\'s price.', 'unsupervised-schedular'); ?></p>
|
<p class="description"><?php esc_html_e('For a make-up or goodwill lesson. Otherwise a pending payment is raised at the lesson type\'s price.', 'unsupervised-schedular'); ?></p>
|
||||||
@@ -99,7 +100,7 @@ if (! defined('ABSPATH')) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row"><label for="usc-book-notes"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></label></th>
|
<th scope="row"><label for="usc-book-notes"><?php esc_html_e('Notes', 'unsupervised-schedular'); ?></label></th>
|
||||||
<td><input type="text" name="notes" id="usc-book-notes" class="regular-text" maxlength="500"></td>
|
<td><input type="text" name="notes" id="usc-book-notes" class="regular-text" maxlength="500" value="<?php echo esc_attr($bookValues['notes']); ?>"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
|||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Unsupervised\Schedular\Auth\AccessSettings;
|
use Unsupervised\Schedular\Auth\AccessSettings;
|
||||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||||
|
use Unsupervised\Schedular\Uninstaller;
|
||||||
|
|
||||||
class AccessSettingsTest extends TestCase
|
class AccessSettingsTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -31,4 +32,108 @@ class AccessSettingsTest extends TestCase
|
|||||||
self::assertFalse($settings->adminsAreStudioAdmins());
|
self::assertFalse($settings->adminsAreStudioAdmins());
|
||||||
self::assertTrue($settings->adminsAreInstructors());
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class LoginPageTest extends TestCase
|
|||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
unset($_POST['us_login']);
|
unset($_POST['us_login'], $_POST['log'], $_POST['pwd']);
|
||||||
parent::tearDown();
|
parent::tearDown();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,4 +91,37 @@ class LoginPageTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame('https://example.com/book/', $this->page->bookingUrl(9));
|
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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use Unsupervised\Schedular\Auth\Invite;
|
|||||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||||
|
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||||
@@ -29,6 +30,9 @@ class RegistrationPageTest extends TestCase
|
|||||||
/** @var array<string, mixed> */
|
/** @var array<string, mixed> */
|
||||||
private array $ctx;
|
private array $ctx;
|
||||||
|
|
||||||
|
/** @var list<string> User meta keys cleared during the submit under test. */
|
||||||
|
private array $clearedMeta = [];
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
@@ -52,6 +56,20 @@ class RegistrationPageTest extends TestCase
|
|||||||
Functions\when('wp_enqueue_script')->justReturn(null);
|
Functions\when('wp_enqueue_script')->justReturn(null);
|
||||||
Functions\when('wp_localize_script')->justReturn(true);
|
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);
|
$invites = Mockery::mock(InviteRepository::class);
|
||||||
$policies = Mockery::mock(PolicyRepository::class);
|
$policies = Mockery::mock(PolicyRepository::class);
|
||||||
$questions = Mockery::mock(QuestionRepository::class);
|
$questions = Mockery::mock(QuestionRepository::class);
|
||||||
@@ -149,6 +167,24 @@ class RegistrationPageTest extends TestCase
|
|||||||
self::assertSame('invite', $this->submit($invite, false));
|
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
|
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
|
||||||
{
|
{
|
||||||
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'birth_year' => '1990' ];
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ class RegistrationStatusTest extends TestCase
|
|||||||
Functions\expect('update_user_meta')
|
Functions\expect('update_user_meta')
|
||||||
->once()
|
->once()
|
||||||
->with(7, RegistrationStatus::META_CONFIRM_EXPIRES, \Mockery::type('string'));
|
->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));
|
self::assertSame('rawtoken', RegistrationStatus::markPending(7));
|
||||||
}
|
}
|
||||||
@@ -63,10 +69,34 @@ class RegistrationStatusTest extends TestCase
|
|||||||
Functions\expect('update_user_meta')
|
Functions\expect('update_user_meta')
|
||||||
->once()
|
->once()
|
||||||
->with(7, RegistrationStatus::META_AUTO_APPROVE, '1');
|
->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));
|
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
|
public function testIsAutoApproveReadsMeta(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
|
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
|
||||||
|
|||||||
@@ -155,4 +155,48 @@ class RoleManagerTest extends TestCase
|
|||||||
|
|
||||||
(new RoleManager())->createRoles();
|
(new RoleManager())->createRoles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The predicate every staff-side "register this person" path shares. It is
|
||||||
|
* deliberately the role and not `book_lesson`, so the two accounts that have
|
||||||
|
* that capability withheld — a guardian's child and an unapproved signup — are
|
||||||
|
* still people the studio can act for.
|
||||||
|
*/
|
||||||
|
public function testIsStudentAcceptsAnyHolderOfTheStudentRole(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::STUDENT]));
|
||||||
|
|
||||||
|
self::assertTrue(RoleManager::isStudent(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsStudentRejectsSomeoneWhoIsNotAStudent(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::INSTRUCTOR]));
|
||||||
|
|
||||||
|
self::assertFalse(RoleManager::isStudent(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsStudentRejectsAnAccountThatNoLongerExists(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_userdata')->justReturn(false);
|
||||||
|
|
||||||
|
self::assertFalse(RoleManager::isStudent(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsStudentRejectsNoOneChosenWithoutLookingAnyoneUp(): void
|
||||||
|
{
|
||||||
|
Functions\expect('get_userdata')->never();
|
||||||
|
|
||||||
|
self::assertFalse(RoleManager::isStudent(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<string> $roles */
|
||||||
|
private function userWithRoles(array $roles): \WP_User
|
||||||
|
{
|
||||||
|
$user = \Mockery::mock(\WP_User::class);
|
||||||
|
$user->ID = 5;
|
||||||
|
$user->roles = $roles;
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
|||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||||
use Unsupervised\Schedular\Booking\AdminBooking;
|
use Unsupervised\Schedular\Booking\AdminBooking;
|
||||||
@@ -36,10 +37,10 @@ class AdminBookingTest extends TestCase
|
|||||||
Functions\when('mysql2date')->alias(
|
Functions\when('mysql2date')->alias(
|
||||||
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
|
static fn (string $format, string $date): string => date($format, (int) strtotime($date))
|
||||||
);
|
);
|
||||||
Functions\when('get_userdata')->justReturn(false);
|
// The picker offers holders of the student role, and that is what the guard
|
||||||
|
// accepts; it is exercised on its own below.
|
||||||
|
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe'));
|
||||||
Functions\when('get_users')->justReturn([]);
|
Functions\when('get_users')->justReturn([]);
|
||||||
// Everyone offered in the picker can book; the guard is exercised on its own.
|
|
||||||
Functions\when('user_can')->justReturn(true);
|
|
||||||
// The staff member doing the booking; stamped on the lesson as booked_by.
|
// The staff member doing the booking; stamped on the lesson as booked_by.
|
||||||
Functions\when('get_current_user_id')->justReturn(3);
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
|
||||||
@@ -175,9 +176,9 @@ class AdminBookingTest extends TestCase
|
|||||||
self::assertSame('slot_taken', $result->get_error_code());
|
self::assertSame('slot_taken', $result->get_error_code());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testSomeoneWhoCannotBookLessonsIsRefused(): void
|
public function testSomeoneWhoIsNotAStudentIsRefused(): void
|
||||||
{
|
{
|
||||||
Functions\when('user_can')->justReturn(false);
|
Functions\when('get_userdata')->alias(fn (int $id): \WP_User => $this->user($id, 'Jane Doe', [RoleManager::INSTRUCTOR]));
|
||||||
$this->availability->shouldReceive('findById')->never();
|
$this->availability->shouldReceive('findById')->never();
|
||||||
|
|
||||||
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||||
@@ -186,6 +187,69 @@ class AdminBookingTest extends TestCase
|
|||||||
self::assertSame('invalid_student', $result->get_error_code());
|
self::assertSame('invalid_student', $result->get_error_code());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testAnAccountThatNoLongerExistsIsRefused(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_userdata')->justReturn(false);
|
||||||
|
$this->availability->shouldReceive('findById')->never();
|
||||||
|
|
||||||
|
$result = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_student', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNoStudentChosenIsRefusedWithoutLookingAnyoneUp(): void
|
||||||
|
{
|
||||||
|
Functions\expect('get_userdata')->never();
|
||||||
|
$this->availability->shouldReceive('findById')->never();
|
||||||
|
|
||||||
|
$result = $this->admin->book(0, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||||
|
|
||||||
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
|
self::assertSame('invalid_student', $result->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A child holds the student role but never `book_lesson` — withheld so the
|
||||||
|
* account cannot book in its own name. The studio booking for them is the only
|
||||||
|
* route a child has to a lesson, so it must not be blocked by that.
|
||||||
|
*/
|
||||||
|
public function testBooksForAGuardiansChildWhoCannotBookThemselves(): void
|
||||||
|
{
|
||||||
|
Functions\when('user_can')->justReturn(false);
|
||||||
|
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||||
|
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||||
|
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||||
|
$this->bookings->shouldReceive('insert')->once()->andReturn(100);
|
||||||
|
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
||||||
|
|
||||||
|
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||||
|
|
||||||
|
self::assertIsString($notice);
|
||||||
|
self::assertStringContainsString('30 min piano', $notice);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same for a self-signup the studio has not approved yet: the front desk can
|
||||||
|
* still get them onto the calendar while the paperwork catches up.
|
||||||
|
*/
|
||||||
|
public function testBooksForAStudentStillAwaitingApproval(): void
|
||||||
|
{
|
||||||
|
Functions\when('user_can')->justReturn(false);
|
||||||
|
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot());
|
||||||
|
$this->offerings->shouldReceive('findById')->with(3)->andReturn($this->offering());
|
||||||
|
$this->availability->shouldReceive('claim')->once()->with(7)->andReturn(true);
|
||||||
|
$this->bookings->shouldReceive('insert')->once()->with(Mockery::on(
|
||||||
|
static fn (Lesson $l): bool => 42 === $l->studentId
|
||||||
|
))->andReturn(100);
|
||||||
|
$this->payments->shouldReceive('createForRegistration')->once()->andReturn($this->pendingPayment());
|
||||||
|
|
||||||
|
$notice = $this->admin->book(42, 7, 3, Lesson::RECURRENCE_SINGLE, false, '');
|
||||||
|
|
||||||
|
self::assertIsString($notice);
|
||||||
|
self::assertStringContainsString('pending payment', $notice);
|
||||||
|
}
|
||||||
|
|
||||||
public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void
|
public function testATiedTimeCannotBeBookedAsADifferentLessonType(): void
|
||||||
{
|
{
|
||||||
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
$this->availability->shouldReceive('findById')->with(7)->andReturn($this->slot(offeringId: 3));
|
||||||
@@ -299,10 +363,12 @@ class AdminBookingTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function user(int $id, string $name): \WP_User
|
/** @param list<string> $roles */
|
||||||
|
private function user(int $id, string $name, array $roles = [RoleManager::STUDENT]): \WP_User
|
||||||
{
|
{
|
||||||
$user = Mockery::mock(\WP_User::class);
|
$user = Mockery::mock(\WP_User::class);
|
||||||
$user->ID = $id;
|
$user->ID = $id;
|
||||||
|
$user->roles = $roles;
|
||||||
$user->first_name = '';
|
$user->first_name = '';
|
||||||
$user->last_name = '';
|
$user->last_name = '';
|
||||||
$user->nickname = $name;
|
$user->nickname = $name;
|
||||||
|
|||||||
@@ -568,9 +568,11 @@ class BookingEndpointTest extends TestCase
|
|||||||
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
|
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);
|
$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->shouldReceive('findById')->with(77)->andReturn($lesson);
|
||||||
$this->bookings->shouldNotReceive('updateStatus');
|
$this->bookings->shouldNotReceive('updateStatus');
|
||||||
@@ -579,7 +581,8 @@ class BookingEndpointTest extends TestCase
|
|||||||
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
|
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
|
||||||
|
|
||||||
self::assertInstanceOf(\WP_Error::class, $result);
|
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
|
public function testCancelUnknownLessonReturns404(): void
|
||||||
@@ -590,6 +593,7 @@ class BookingEndpointTest extends TestCase
|
|||||||
|
|
||||||
self::assertInstanceOf(\WP_Error::class, $result);
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
self::assertSame('not_found', $result->get_error_code());
|
self::assertSame('not_found', $result->get_error_code());
|
||||||
|
self::assertSame(404, $result->error_data['not_found']['status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCancelAlreadyCancelledLessonIsIdempotent(): void
|
public function testCancelAlreadyCancelledLessonIsIdempotent(): void
|
||||||
|
|||||||
@@ -316,6 +316,54 @@ class LessonControllerTest extends TestCase
|
|||||||
self::assertStringContainsString('name="usc_action" value="book_for_student"', $html);
|
self::assertStringContainsString('name="usc_action" value="book_for_student"', $html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refusal used to clear all five fields, so one mistake meant retyping the
|
||||||
|
* whole form — and the panel is only ever reopened *because* something was
|
||||||
|
* refused.
|
||||||
|
*/
|
||||||
|
public function testARefusedBookingComesBackWithEveryFieldStillFilledIn(): void
|
||||||
|
{
|
||||||
|
$this->postBooking();
|
||||||
|
$this->offerPanel();
|
||||||
|
|
||||||
|
$this->adminBooking->shouldReceive('book')->once()->andReturn(
|
||||||
|
new \WP_Error('slot_taken', 'That time has already been booked.')
|
||||||
|
);
|
||||||
|
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('That time has already been booked.', $html);
|
||||||
|
// Attribute spacing in the template is not what is under test here.
|
||||||
|
$tags = (string) preg_replace('/\s+/', ' ', $html);
|
||||||
|
|
||||||
|
// The panel is reopened, showing the student, time and lesson type as posted.
|
||||||
|
self::assertStringContainsString(' open>', $html);
|
||||||
|
self::assertStringContainsString('value="42" selected=\'selected\'', $tags);
|
||||||
|
self::assertStringContainsString('value="7" selected=\'selected\'', $tags);
|
||||||
|
self::assertStringContainsString('value="3" selected=\'selected\'', $tags);
|
||||||
|
// Both ticks and the note survive too.
|
||||||
|
self::assertSame(2, substr_count($html, "checked='checked'"));
|
||||||
|
self::assertStringContainsString('value="Make-up lesson"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASuccessfulBookingLeavesAnEmptyFormForTheNextOne(): void
|
||||||
|
{
|
||||||
|
$this->postBooking();
|
||||||
|
$this->offerPanel();
|
||||||
|
|
||||||
|
$this->adminBooking->shouldReceive('book')->once()->andReturn('Booked.');
|
||||||
|
$this->bookings->shouldReceive('findAllUpcoming')->once()->andReturn([]);
|
||||||
|
|
||||||
|
$html = $this->render();
|
||||||
|
|
||||||
|
self::assertStringContainsString('Booked.', $html);
|
||||||
|
// Nothing carried over, or the next booking silently inherits this one's.
|
||||||
|
self::assertStringNotContainsString("selected='selected'", $html);
|
||||||
|
self::assertStringNotContainsString("checked='checked'", $html);
|
||||||
|
self::assertStringContainsString('value=""', $html);
|
||||||
|
}
|
||||||
|
|
||||||
public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void
|
public function testTheStudioSchedulerBooksAgainstAnyInstructorsTime(): void
|
||||||
{
|
{
|
||||||
$this->postBooking();
|
$this->postBooking();
|
||||||
@@ -390,6 +438,16 @@ class LessonControllerTest extends TestCase
|
|||||||
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
Functions\when('absint')->alias(static fn ($v): int => abs((int) $v));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The panel with one of each choice, so a re-selected value has somewhere to land. */
|
||||||
|
private function offerPanel(): void
|
||||||
|
{
|
||||||
|
$this->adminBooking->shouldReceive('formData')->once()->andReturn([
|
||||||
|
'students' => [['id' => 42, 'name' => 'Ada Lovelace']],
|
||||||
|
'offerings' => [['id' => 3, 'label' => '30 min piano (30 min)']],
|
||||||
|
'slots' => [['id' => 7, 'label' => 'Wed Jul 1, 2026 10:00 AM (30 min)', 'weekly' => false]],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void
|
public function testAStaffBookedLessonOffersTheRecordIntakeForm(): void
|
||||||
{
|
{
|
||||||
$_GET['lesson_id'] = '1';
|
$_GET['lesson_id'] = '1';
|
||||||
|
|||||||
@@ -224,17 +224,19 @@ class EnrollmentEndpointTest extends TestCase
|
|||||||
self::assertSame(403, $result->error_data['withdrawal_closed']['status']);
|
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('findById')->with(3)->andReturn(new Enrollment(8, 9, 3, Enrollment::STATUS_ACTIVE, 41, 3));
|
||||||
$this->enrollments->shouldReceive('updateStatus')->never();
|
$this->enrollments->shouldReceive('updateStatus')->never();
|
||||||
|
|
||||||
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
$result = $this->endpoint->withdraw(new \WP_REST_Request(['id' => 3]));
|
||||||
|
|
||||||
self::assertInstanceOf(\WP_Error::class, $result);
|
self::assertInstanceOf(\WP_Error::class, $result);
|
||||||
self::assertSame('forbidden', $result->get_error_code());
|
self::assertSame('not_found', $result->get_error_code());
|
||||||
self::assertSame(403, $result->error_data['forbidden']['status']);
|
self::assertSame(404, $result->error_data['not_found']['status']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testWithdrawReturnsNotFoundForUnknownEnrolment(): void
|
public function testWithdrawReturnsNotFoundForUnknownEnrolment(): void
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Brain\Monkey\Functions;
|
|||||||
use Mockery;
|
use Mockery;
|
||||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||||
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||||
@@ -88,6 +89,7 @@ class GroupClassControllerTest extends TestCase
|
|||||||
[$first, $last] = array_pad(explode(' ', $full, 2), 2, '');
|
[$first, $last] = array_pad(explode(' ', $full, 2), 2, '');
|
||||||
|
|
||||||
$user = Mockery::mock(\WP_User::class);
|
$user = Mockery::mock(\WP_User::class);
|
||||||
|
$user->roles = [RoleManager::STUDENT];
|
||||||
$user->first_name = $first;
|
$user->first_name = $first;
|
||||||
$user->last_name = $last;
|
$user->last_name = $last;
|
||||||
$user->nickname = $full;
|
$user->nickname = $full;
|
||||||
@@ -406,6 +408,8 @@ class GroupClassControllerTest extends TestCase
|
|||||||
Functions\when('wp_unslash')->returnArg();
|
Functions\when('wp_unslash')->returnArg();
|
||||||
Functions\when('sanitize_email')->returnArg();
|
Functions\when('sanitize_email')->returnArg();
|
||||||
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
|
Functions\when('absint')->alias(static fn ($v) => abs((int) $v));
|
||||||
|
// Every posted id is vetted as a student before it is enrolled or granted.
|
||||||
|
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
|
||||||
|
|
||||||
// Render tail: no classes/enrolments to draw so the assertion targets the notice.
|
// Render tail: no classes/enrolments to draw so the assertion targets the notice.
|
||||||
$this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]);
|
$this->offerings->shouldReceive('findAll')->with(3, Offering::KIND_GROUP_CLASS)->andReturn([]);
|
||||||
@@ -544,6 +548,64 @@ class GroupClassControllerTest extends TestCase
|
|||||||
$this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]);
|
$this->audit->shouldReceive('acceptances')->with($enrollment)->andReturn([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multi-select is built from the studio's students, but a posted id is just
|
||||||
|
* a number: it could name an instructor, an administrator, or an account
|
||||||
|
* deleted since the page was drawn. Enrolling one would put a non-student on
|
||||||
|
* the roster and raise a payment against them.
|
||||||
|
*/
|
||||||
|
public function testAddDirectIgnoresAnIdThatIsNotAStudent(): void
|
||||||
|
{
|
||||||
|
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
|
||||||
|
$this->stubActionContext();
|
||||||
|
|
||||||
|
$instructor = Mockery::mock(\WP_User::class);
|
||||||
|
$instructor->roles = [RoleManager::INSTRUCTOR];
|
||||||
|
Functions\when('get_userdata')->justReturn($instructor);
|
||||||
|
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
|
||||||
|
$this->enrollments->shouldReceive('insert')->never();
|
||||||
|
$this->paymentService->shouldReceive('createForRegistration')->never();
|
||||||
|
$this->access->shouldReceive('markEnrolled')->never();
|
||||||
|
|
||||||
|
$html = $this->renderInstructor();
|
||||||
|
|
||||||
|
self::assertStringContainsString('0 student(s) added to the class.', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddDirectIgnoresAnAccountThatNoLongerExists(): void
|
||||||
|
{
|
||||||
|
$_POST = ['usc_action' => 'add_direct', 'offering_id' => 8, 'student_ids' => [5]];
|
||||||
|
$this->stubActionContext();
|
||||||
|
Functions\when('get_userdata')->justReturn(false);
|
||||||
|
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering(100.0));
|
||||||
|
$this->enrollments->shouldReceive('insert')->never();
|
||||||
|
$this->paymentService->shouldReceive('createForRegistration')->never();
|
||||||
|
|
||||||
|
$html = $this->renderInstructor();
|
||||||
|
|
||||||
|
self::assertStringContainsString('0 student(s) added to the class.', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGrantAccessIgnoresAnIdThatIsNotAStudent(): void
|
||||||
|
{
|
||||||
|
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
|
||||||
|
$this->stubActionContext();
|
||||||
|
|
||||||
|
$instructor = Mockery::mock(\WP_User::class);
|
||||||
|
$instructor->roles = [RoleManager::INSTRUCTOR];
|
||||||
|
Functions\when('get_userdata')->justReturn($instructor);
|
||||||
|
|
||||||
|
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
|
||||||
|
$this->access->shouldReceive('insert')->never();
|
||||||
|
$this->mailer->shouldReceive('sendClassAccessGranted')->never();
|
||||||
|
|
||||||
|
$html = $this->renderInstructor();
|
||||||
|
|
||||||
|
self::assertStringContainsString('0 student(s) granted access.', $html);
|
||||||
|
}
|
||||||
|
|
||||||
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
|
public function testGrantAccessCreatesGrantAndEmailsStudent(): void
|
||||||
{
|
{
|
||||||
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
|
$_POST = ['usc_action' => 'grant_access', 'offering_id' => 8, 'student_ids' => [5]];
|
||||||
@@ -555,6 +617,7 @@ class GroupClassControllerTest extends TestCase
|
|||||||
$this->access->shouldReceive('insert')->once()->andReturn(1);
|
$this->access->shouldReceive('insert')->once()->andReturn(1);
|
||||||
|
|
||||||
$user = Mockery::mock(\WP_User::class);
|
$user = Mockery::mock(\WP_User::class);
|
||||||
|
$user->roles = [RoleManager::STUDENT];
|
||||||
$user->user_email = '[email protected]';
|
$user->user_email = '[email protected]';
|
||||||
Functions\when('get_userdata')->justReturn($user);
|
Functions\when('get_userdata')->justReturn($user);
|
||||||
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true);
|
$this->mailer->shouldReceive('sendClassAccessGranted')->once()->with($user, 'Private Choir')->andReturn(true);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Tests\Unit\Guardian;
|
|||||||
|
|
||||||
use Brain\Monkey\Functions;
|
use Brain\Monkey\Functions;
|
||||||
use Mockery;
|
use Mockery;
|
||||||
|
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||||
use Unsupervised\Schedular\Guardian\GuardianLink;
|
use Unsupervised\Schedular\Guardian\GuardianLink;
|
||||||
@@ -96,6 +97,33 @@ class GuardianServiceTest extends TestCase
|
|||||||
self::assertSame('2015', $this->meta[42][GuardianService::META_BIRTH_YEAR]);
|
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
|
public function testCreateChildRejectsABlankName(): void
|
||||||
{
|
{
|
||||||
Functions\expect('wp_insert_user')->never();
|
Functions\expect('wp_insert_user')->never();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,17 @@ class UpdateCheckerTest extends TestCase
|
|||||||
private const PLUGIN_FILE = 'unsupervised-schedular/unsupervised-schedular.php';
|
private const PLUGIN_FILE = 'unsupervised-schedular/unsupervised-schedular.php';
|
||||||
private const PACKAGE_URL = 'https://git.unsupervised.ca/attachments/abc123';
|
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. */
|
/** Stub a successful Gitea API response with the given decoded body. */
|
||||||
private function stubApiResponse(int $code, mixed $body): void
|
private function stubApiResponse(int $code, mixed $body): void
|
||||||
{
|
{
|
||||||
@@ -217,6 +228,63 @@ class UpdateCheckerTest extends TestCase
|
|||||||
self::assertSame($this->noUpdatePayload(), $result);
|
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
|
public function testMalformedApiBodyOffersNoUpdatePayload(): void
|
||||||
{
|
{
|
||||||
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
|
||||||
|
|||||||
+5
-10
@@ -1,19 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Unsupervised\Schedular\Uninstaller;
|
||||||
|
|
||||||
if (! defined('WP_UNINSTALL_PLUGIN')) {
|
if (! defined('WP_UNINSTALL_PLUGIN')) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
require_once plugin_dir_path(__FILE__) . 'vendor/autoload.php';
|
require_once plugin_dir_path(__FILE__) . 'vendor/autoload.php';
|
||||||
|
|
||||||
global $wpdb;
|
// What this does and does not take with it is decided on Access → Plugin
|
||||||
|
// removal, and explained in full in Uninstaller.
|
||||||
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}us_lessons");
|
(new Uninstaller())->run();
|
||||||
$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');
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: Unsupervised Scheduler
|
* Plugin Name: Unsupervised Scheduler
|
||||||
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
|
||||||
* Description: Instructor/student lesson scheduling for WordPress.
|
* Description: Instructor/student lesson scheduling for WordPress.
|
||||||
* Version: 1.5.3
|
* Version: 1.5.6
|
||||||
* Requires at least: 6.2
|
* Requires at least: 6.2
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Unsupervised
|
* Author: Unsupervised
|
||||||
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define('USC_VERSION', '1.5.3');
|
define('USC_VERSION', '1.5.6');
|
||||||
define('USC_PLUGIN_FILE', __FILE__);
|
define('USC_PLUGIN_FILE', __FILE__);
|
||||||
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|||||||
Reference in New Issue
Block a user