4 Commits
Author SHA1 Message Date
thatguygriffandClaude Opus 5 ca5fac5c06 Show only the name and email, not who the account books for
CI / Tests (PHP 8.1) (pull_request) Failing after 47s
CI / Coding Standards (pull_request) Successful in 3m10s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Failing after 52s
CI / PHPStan (pull_request) Successful in 2m59s
CI / Tests (PHP 8.3) (pull_request) Failing after 2m40s
CI / Build Plugin Zip (pull_request) Skipped
The block reports who is signed in and nothing more. Dropping the "Booking
for …" line takes GuardianService with it — it was the only reason the page
had a dependency at all, so AccountPage now constructs with no arguments.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:39:21 -03:00
thatguygriffandClaude Opus 5 15d55d70d1 Add an account block showing who is signed in
[us_account], or the Account block: the signed-in visitor's name, their
email, a Sign out link, and — only when the account books for someone
besides itself — the students it books for. A parent's first question on
seeing "signed in as Grace" is whether this is the account their children's
lessons are on.

Two decisions worth naming.

Signed out with no login page chosen, the block renders nothing. Its whole
subject is the person signed in, which a stranger is not, and a bare "you
are not signed in" in a site header is noise with no way to act on it. With
a login page chosen it offers a Sign in link instead. The editor preview is
populated regardless, so the block is never an invisible box to the person
placing it.

Signing out returns to the chosen login page, or to the current page when
there is none. A block meant for a header should not also navigate someone
somewhere when they use it; the login page wins when configured, because the
page they were on may well be members-only.

The name comes from UserName::format(), so the block never exposes a
username the way display_name can.

Also brings docs/features/editor-blocks.md back in step: it still described
"four shortcodes" and had never listed the family block.

Closes #142

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:39:20 -03:00
thatguygriff d554e35d80 Merge pull request 'Validate signup email and password strength' (#155) from feature/150-signup-credential-validation into main
CI / Tests (PHP 8.2) (push) Failing after 43s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m55s
CI / Coding Standards (push) Successful in 2m56s
CI / Tests (PHP 8.3) (push) Failing after 2m44s
CI / Build Plugin Zip (push) Skipped
CI / Tests (PHP 8.1) (push) Failing after 50s
Reviewed-on: #155
2026-07-30 01:27:05 +00:00
thatguygriffandClaude Opus 5 b5b9a7ac54 Validate signup email and password strength
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.1) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m44s
CI / Build Plugin Zip (pull_request) Skipped
The password was only ever checked for length. It is now checked on both
sides, with each side doing the job it can actually do.

The browser scores it with zxcvbn, through WordPress's own
password-strength-meter script rather than a second opinion of our own, and
refuses to submit below "medium". That is the nuanced test — it knows
Tr0ub4dor&3 is weaker than it looks — but it is advice a client can decline
to take.

Auth\PasswordPolicy runs on the server and is the rule that holds. It does
not try to reproduce a strength score in PHP; it rejects the categorically
bad, which is what a server can check without shipping a dictionary: too
short, a well-known leaked password, fewer than four distinct characters, or
the user's own name or email inside it. No composition rules — NIST advises
against them, and they mostly produce predictable substitutions.

Both thresholds come from the same two constants, handed to JavaScript by
wp_localize_script, so the sides cannot drift into disagreeing about what
was accepted.

The verdict is attached to the field with setCustomValidity() rather than by
disabling a button. The form has up to three submits plus a "Next" that
already gates on checkValidity(), and an invalid field stops all of them
without any of them needing to know why.

Email validation moved ahead of the password check, since the password is
now checked against the email. A blank form therefore reports the email
first, which also matches the order the fields appear in.

Verified the browser half against a controllable scorer: each score band
blocks or allows as intended, the identity list reaches the meter, and the
gate stays open while zxcvbn's dictionary is still loading — the server
covers that window.

Closes #150

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 22:13:31 -03:00
20 changed files with 927 additions and 28 deletions
+6
View File
@@ -13,6 +13,12 @@ each change under the current top section as you work.
## [1.3.1]
### Added
- An **Account** block (`[us_account]`) showing who is signed in — their name and their email — and a **Sign out** link. Signing out returns to the login page chosen in the block, or to the page the visitor was already on when none is set, so putting it in a site header does not also move people somewhere. To a signed-out visitor it shows a **Sign in** link when a login page is chosen, and nothing at all when one is not: a panel about who is signed in has nothing to tell a stranger, and a notice they cannot act on is just clutter in a header.
### Security
- Signup now checks the password properly. The form scores it as you type with the same zxcvbn meter wp-admin uses and will not submit a weak one, and the server refuses — regardless of what the browser allowed — anything shorter than 8 characters, one of the well-known leaked passwords, one built from barely any distinct characters, or one containing your own name or email address. Composition rules ("must contain a symbol") are deliberately not imposed: they mostly produce predictable substitutions. Email addresses are validated on the server on every signup path, with a clear message when one is already registered.
### Changed
- A student's **name and birth year are now required**, marked in the form the same way a required registration question is and enforced on the server whichever way they were submitted. On signup the requirement applies only once the parent/guardian box is ticked, so registering for yourself is unaffected. A student block you have started filling in is now reported back to you rather than silently dropped when the name is missing — only a completely untouched spare block is still ignored.
- Signup and the profile page now ask for a **birth year** rather than a full date of birth — a four-digit year between 1900 and the current year, with anything else discarded rather than stored. Students added before this change keep showing a birth year, derived from the date already on file; that old full date is then dropped the first time the record is saved, so the studio ends up holding only what it now asks for. No bulk purge runs, so a site wanting the remaining old dates gone should clear the `us_date_of_birth` user meta directly.
+47
View File
@@ -525,6 +525,53 @@
}
}
/*
* The live password verdict under the signup field. Colour is a reinforcement,
* not the message — the text says what is wrong on its own, so this still reads
* correctly to anyone who cannot separate the hues.
*/
.us-password-strength {
display: block;
margin-top: 4px;
font-size: 0.85em;
}
.us-password-strength.is-short,
.us-password-strength.is-weak {
color: #c00;
}
.us-password-strength.is-medium {
color: #7a5c00;
}
.us-password-strength.is-strong {
color: #1a7d2e;
}
/*
* The account panel: who is signed in, and the way out. Sized to sit in a
* header or sidebar, so the rules stay minimal and inherit the theme's type —
* a block that lands in a site header should look like it belongs there.
*/
.us-account p {
margin: 0 0 4px;
}
.us-account-name {
font-weight: 600;
}
.us-account-email {
display: block;
font-size: 0.9em;
opacity: 0.75;
}
.us-account-actions {
margin-top: 8px;
}
/* Shown only in block-editor previews (see BlockPreview). */
.us-editor-note {
font-size: 0.85em;
+22
View File
@@ -307,6 +307,28 @@
})
),
},
{
name: 'us-scheduler/account',
title: __('Account', 'unsupervised-schedular'),
description: __('Shows the name and email of whoever is signed in, with a sign out link. Renders nothing for signed-out visitors unless a login page is chosen.', 'unsupervised-schedular'),
icon: 'admin-users',
keywords: ['account', 'sign out', 'log out', 'signed in', 'profile'],
shortcode: 'us_account',
attributes: {
loginPageId: { type: 'number', default: 0 },
},
inspector: (attributes, setAttributes) => el(
PanelBody,
{ title: __('Signing in and out', 'unsupervised-schedular') },
el(PageSelect, {
label: __('Login page', 'unsupervised-schedular'),
help: __('Where signing out returns to, and where signed-out visitors are offered a link to sign in. Without one, signing out returns to the current page and signed-out visitors see nothing.', 'unsupervised-schedular'),
defaultLabel: __('Stay on the current page', 'unsupervised-schedular'),
value: attributes.loginPageId,
onChange: (loginPageId) => setAttributes({ loginPageId }),
})
),
},
];
blocks.forEach((def) => {
+104
View File
@@ -14,10 +14,113 @@
* block. Ticking the box also takes the guardian's *own* question panel out
* of play — in guardian mode the questions are asked per child, so the
* server ignores those answers and the browser must not demand them.
* 3. **Password strength.** The password is scored with zxcvbn (via WordPress's
* own `wp.passwordStrength`) and a weak one is refused. The server applies
* its own, coarser rule regardless — see `Auth\PasswordPolicy`.
*/
(function () {
'use strict';
var PASSWORD = window.usSchedulerPassword || {};
/**
* Gate the form on password strength.
*
* The verdict is attached to the field with `setCustomValidity()` rather than
* by disabling the submit button: the form has up to three submits (the plain
* one, the guardian-mode early one, and step two's) plus a "Next" that
* already gates on `checkValidity()`, and an invalid field blocks all of them
* at once without any of them having to know why.
*/
function enhancePassword(form) {
var field = form.querySelector('#us-reg-pass');
var output = form.querySelector('#us-reg-pass-strength');
var strings = PASSWORD.strings || {};
if (!field || !PASSWORD.minScore) {
return;
}
// What the password must not simply repeat back. Mirrors the identity
// check PasswordPolicy makes server-side.
function identity() {
var out = [];
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
for (var i = 0; i < sources.length; i++) {
var value = (sources[i].value || '').trim();
if (value) {
out.push(value);
if (value.indexOf('@') > 0) {
out.push(value.split('@')[0]);
}
}
}
return out;
}
function assess() {
var value = field.value || '';
if (!value) {
report('', '');
return;
}
if (value.length < (PASSWORD.minLength || 8)) {
report(strings.short, 'short');
return;
}
// zxcvbn's dictionary is fetched after load, and wp.passwordStrength
// reports -1 until it arrives. Say nothing and allow the submit in that
// window — the server still checks, and the next keystroke re-runs this
// once the dictionary is in.
if (!window.wp || !window.wp.passwordStrength || typeof window.zxcvbn === 'undefined') {
report('', '');
return;
}
var score = window.wp.passwordStrength.meter(value, identity(), '');
if (score < 0) {
report('', '');
return;
}
if (score >= 3) {
report(strings.strong, 'strong');
} else if (score >= PASSWORD.minScore) {
report(strings.medium, 'medium');
} else {
report(score <= 0 ? strings.veryWeak : strings.weak, 'weak');
}
}
/** Show the verdict, and make it the field's validity at the same time. */
function report(message, level) {
var acceptable = '' === level || 'medium' === level || 'strong' === level;
if (output) {
output.textContent = message || '';
output.className = 'us-password-strength' + (level ? ' is-' + level : '');
}
field.setCustomValidity(acceptable ? '' : message || '');
}
field.addEventListener('input', assess);
field.addEventListener('blur', assess);
// The identity check depends on these, so a password typed first and an
// email typed second is still caught.
var sources = form.querySelectorAll('#us-reg-email, #us-reg-name');
for (var i = 0; i < sources.length; i++) {
sources[i].addEventListener('change', assess);
}
}
function enhanceSteps(form) {
var step1 = form.querySelector('[data-step="1"]');
var step2 = form.querySelector('[data-step="2"]');
@@ -168,6 +271,7 @@
: null;
enhanceGuardian(forms[i], steps);
enhancePassword(forms[i]);
}
});
})();
+34
View File
@@ -77,6 +77,40 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| `accepted_at` | DATETIME | When accepted; NULL while pending / for group links |
| `expires_at` | DATETIME | Explicit expiry (end of the chosen day); set on every group link, NULL for personal invites (which expire 14 days after creation) |
## Email and password validation
Both are checked on the server on every signup path, and the browser is given a
matching but *stricter* job so a bad password is caught before submitting.
**Email**`type="email"` and `required` in the markup, `is_email()` on the
server, then `email_exists()` for "an account already exists for this email". A
personal invite fixes the address and the server always uses the invite's own
value, so a tampered field is ignored rather than validated.
**Password**`Auth\PasswordPolicy` is the authority. It deliberately does
*not* try to reproduce a strength score in PHP; it rejects the categorically
bad, which is what a server can check without shipping a dictionary:
- shorter than `PasswordPolicy::MIN_LENGTH` (8 — NIST SP 800-63B's floor;
composition rules like "must contain a symbol" are deliberately **not** used,
as they push people towards predictable substitutions),
- one of the well-known leaked passwords,
- built from fewer than four distinct characters (`aaaaaaaa`, `abababab`),
- containing the user's own display name, email, or the part before the `@`.
The nuance happens in the browser. `register.js` scores the password with
zxcvbn through WordPress's own `password-strength-meter` script and refuses to
submit below `PasswordPolicy::MIN_SCORE` (2 of 4 — "medium"; enough to stop a
guessable password without demanding a passphrase to book a piano lesson). The
thresholds reach JavaScript via `wp_localize_script()` from the same constants
the server enforces, so the two cannot drift apart.
The verdict is applied with `setCustomValidity()` on the password field rather
than by disabling a button: the form has up to three submits plus a "Next" that
already gates on `checkValidity()`, and an invalid field stops all of them
without any needing to know why. zxcvbn's dictionary loads asynchronously, so
the gate stays open until it arrives — the server is the check that always runs.
## Registration Questions (signup step two)
When the studio has configured **account-scope** registration questions
(**Offerings → Questions → "Account signup"**, see `registration-questions.md`), the
+29 -4
View File
@@ -1,8 +1,8 @@
# Editor Blocks
Gutenberg dynamic-block wrappers for the plugin's four front-end shortcodes,
so the pages can be previewed and styled inside the block editor instead of
appearing as grey shortcode text.
Gutenberg dynamic-block wrappers for the plugin's front-end shortcodes, so the
pages can be previewed and styled inside the block editor instead of appearing
as grey shortcode text.
## Blocks
@@ -12,6 +12,8 @@ appearing as grey shortcode text.
| `us-scheduler/student-login` | `[us_student_login]` | `Auth\LoginPage::render()` |
| `us-scheduler/student-register` | `[us_student_register]` | `Auth\RegistrationPage::render()` |
| `us-scheduler/group-classes` | `[us_group_classes]` | `GroupClass\GroupClassPage::render()` |
| `us-scheduler/family` | `[us_family]` | `Guardian\FamilyPage::render()` |
| `us-scheduler/account` | `[us_account]` | `Auth\AccountPage::render()` |
The shortcodes remain registered for back-compat; blocks and shortcodes share
the same page objects (constructed once in `Plugin::boot()`), so front-end
@@ -21,7 +23,7 @@ transform.
## Block options
Four blocks have sidebar (inspector) options:
Most blocks have sidebar (inspector) options:
| Block | Attribute | Default | Effect |
|---|---|---|---|
@@ -34,6 +36,8 @@ Four blocks have sidebar (inspector) options:
| `us-scheduler/student-login` | `autoRedirect` (boolean) | `false` | Send logged-in visitors straight to the booking page instead of showing the link. Does nothing until a booking page is chosen. |
| `us-scheduler/student-register` | `loginPageId` (number) | `0` | Page students continue to once registration finishes — the "Sign in to your account" link after they confirm their email, and the "Continue to your account" link an invited student gets on the spot. `0` = the WordPress login screen for the confirmation link, and no link at all for the (already signed-in) invited student. Shortcode equivalent: `[us_student_register login_page_id="…"]`. |
| `us-scheduler/student-register` | `autoRedirect` (boolean) | `false` | Send students straight to that page instead of showing the link. Does nothing until a page is chosen — there is no login-screen fallback here. |
| `us-scheduler/family` | `loginPageId` (number) | `0` | Where visitors who are not signed in are sent to log in. Shortcode equivalent: `[us_family login_page_id="…"]`. |
| `us-scheduler/account` | `loginPageId` (number) | `0` | Where signing out returns to, and where a signed-out visitor is offered a **Sign in** link. `0` = signing out returns to the current page, and a signed-out visitor sees **nothing at all** — see below. Shortcode equivalent: `[us_account login_page_id="…"]`. |
| `us-scheduler/group-classes` | `offeringId` (number) | `0` | Restrict the page to a single group class, for embedding on a page dedicated to that class. The class description is then omitted — only the schedule, instructor, price and enrolment controls are shown, so the surrounding page's own copy is not repeated. `0` = browse all classes, descriptions included. Shortcode equivalent: `[us_group_classes offering="…"]`. |
The page selects list all published pages; if a chosen page is later deleted,
@@ -105,6 +109,10 @@ placeholder content:
- **Login** — the real `templates/frontend/login-page.php` template (it has
no request-state dependencies).
- **Registration** — a disabled sample of the `.us-register-form` fields.
- **Account** — a populated sample panel. Deliberately populated whatever the
editor user's own state: on the published page a signed-out visitor may see
nothing at all, and an empty box tells the person placing the block nothing
about where it will sit.
Each preview starts with a `.us-editor-note` paragraph explaining what the
published page shows instead. The note class only appears in editor previews.
@@ -118,5 +126,22 @@ published page shows instead. The note class only appears in editor previews.
and fallbacks.
- `tests/Unit/Auth/LoginPageTest.php` — logged-in booking-link targets and
fallbacks.
- `tests/Unit/Auth/AccountPageTest.php` — what each visitor sees, the
sign-out redirect target, and the signed-out empty render.
- `tests/Unit/BlockPreviewTest.php` — preview markup mirrors the live CSS
classes/ids and includes the editor note.
## The account block's signed-out behaviour
`us-scheduler/account` is the one block that can render **nothing**. It is meant
for a header, sidebar or account page, and its whole subject is the person
signed in — which a stranger is not. A bare "you are not signed in" in a site
header is noise that cannot be acted on, so:
- **No login page chosen** → empty string for signed-out visitors.
- **Login page chosen** → a single **Sign in** link.
Signed in, it shows the display name (`Auth\UserName::format()`, so a username
is never exposed), the account email, and a **Sign out** link — deliberately
nothing else. Signing out returns to the chosen login page, or to the current page when there
is none, so a header sign-out does not also navigate the visitor somewhere.
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
use Unsupervised\Schedular\Val;
/**
* Who is signed in, and the way out.
*
* Meant for a header, sidebar or account page — somewhere it sits alongside
* other content rather than being the whole of it. That shapes the two
* decisions below.
*/
class AccountPage {
/**
* Renders the account shortcode/block output.
*
* Signed out, this renders a sign-in link when a login page is configured and
* **nothing at all** when one is not. A block whose whole job is "you are
* signed in as X" has nothing to say to a stranger, and a bare "you are not
* signed in" in a site header is noise with no way to act on it. The editor
* preview shows the populated state regardless, so the block is never
* invisible to the person placing it.
*
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
* shortcode attributes (`login_page_id`).
*/
public function render( array $atts ): string {
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
$loginUrl = $this->pageUrl( $loginPageId );
wp_enqueue_style( 'us-scheduler' );
if ( ! is_user_logged_in() ) {
if ( null === $loginUrl ) {
return '';
}
return sprintf(
'<div class="us-account us-account-out"><a class="us-account-signin" href="%s">%s</a></div>',
esc_url( $loginUrl ),
esc_html__( 'Sign in', 'unsupervised-schedular' )
);
}
// Always a WP_User here — is_user_logged_in() above rules out the
// id-0 placeholder wp_get_current_user() returns for a visitor.
$user = wp_get_current_user();
$name = UserName::format( $user, get_current_user_id() );
$email = $user->user_email;
// Back to where they were, so signing out of a header link does not also
// navigate them somewhere. The login page is the better landing spot when
// one is configured, since the current page may be members-only.
$logoutUrl = wp_logout_url( $loginUrl ?? (string) get_permalink() );
ob_start();
include USC_PLUGIN_DIR . 'templates/frontend/account-page.php';
return (string) ob_get_clean();
}
/**
* Permalink of a configured page, or null when none is chosen or the chosen
* page has since been deleted.
*/
private function pageUrl( int $pageId ): ?string {
if ( $pageId <= 0 ) {
return null;
}
$url = get_permalink( $pageId );
return is_string( $url ) ? $url : null;
}
}
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* What counts as an acceptable signup password.
*
* The check is deliberately split across the two sides, because the two sides
* can do different things:
*
* - **The browser** runs zxcvbn (WordPress ships it as `password-strength-meter`)
* and gates the submit button on {@see MIN_SCORE}. That is the nuanced test —
* it knows that `Tr0ub4dor&3` is weaker than `correct horse battery staple` —
* but it is only advice, because anything in a browser can be turned off.
* - **This class** runs on the server and is the rule that actually holds. It
* cannot score a password the way zxcvbn does without shipping a dictionary,
* so it does not pretend to: it rejects the categorically bad — too short,
* the user's own name or email, a password from the well-known lists, or one
* built from almost no distinct characters.
*
* Neither half is sufficient alone, which is the point. A password that clears
* both is not guaranteed strong; one that fails either is definitely not.
*/
class PasswordPolicy {
/**
* Minimum length. NIST SP 800-63B puts the floor at 8 and explicitly advises
* against composition rules ("must contain a symbol") on the grounds that they
* push people towards predictable substitutions. Length plus the checks below
* does more for less annoyance.
*/
public const MIN_LENGTH = 8;
/**
* The zxcvbn score the browser demands before it will let the form submit,
* on WordPress's 0-4 scale: 0-1 weak, 2 medium, 3-4 strong. Two rejects the
* passwords a stranger would guess while still accepting an ordinary
* memorable one — a studio signup form is not a bank.
*/
public const MIN_SCORE = 2;
/**
* How much of the user's own identity has to appear in the password before it
* is refused. Short enough to catch a name inside a longer password, long
* enough that a two- or three-letter coincidence does not trip it.
*/
private const IDENTITY_FRAGMENT_LENGTH = 4;
/** Fewest distinct characters a password may be built from. */
private const MIN_DISTINCT_CHARACTERS = 4;
/**
* Why this password is unacceptable, or null when it passes.
*
* `$email` and `$displayName` are what the same submission is claiming as an
* identity, so they can be checked against the password before either exists
* as a user.
*/
public static function validate( string $password, string $email = '', string $displayName = '' ): ?string {
// Not trimmed: a leading or trailing space is a legitimate character, and
// silently changing what someone typed would lock them out later.
if ( strlen( $password ) < self::MIN_LENGTH ) {
return sprintf(
/* translators: %d: minimum number of characters. */
__( 'Please choose a password of at least %d characters.', 'unsupervised-schedular' ),
self::MIN_LENGTH
);
}
$lower = strtolower( $password );
if ( in_array( $lower, self::commonPasswords(), true ) ) {
return __( 'That password is one of the most commonly used ones. Please choose something less guessable.', 'unsupervised-schedular' );
}
if ( count( array_unique( str_split( $lower ) ) ) < self::MIN_DISTINCT_CHARACTERS ) {
return __( 'Please choose a password built from more than a few repeated characters.', 'unsupervised-schedular' );
}
if ( self::echoesIdentity( $lower, $email, $displayName ) ) {
return __( 'Please choose a password that does not contain your name or email address.', 'unsupervised-schedular' );
}
return null;
}
/**
* Whether the password contains the user's display name, their email address,
* or the part of it before the `@` — the first things anyone guessing would
* try, and the reason "grace2019" is worse than its length suggests.
*/
private static function echoesIdentity( string $lowerPassword, string $email, string $displayName ): bool {
$email = strtolower( trim( $email ) );
$localPart = '' !== $email ? (string) strstr( $email . '@', '@', true ) : '';
$fragments = [ $email, $localPart, strtolower( trim( $displayName ) ) ];
foreach ( $fragments as $fragment ) {
if ( strlen( $fragment ) >= self::IDENTITY_FRAGMENT_LENGTH && str_contains( $lowerPassword, $fragment ) ) {
return true;
}
}
return false;
}
/**
* Passwords common enough that a guess costs nothing. Only entries at least
* {@see MIN_LENGTH} long are worth listing — anything shorter is already
* refused — so this is the long tail of the usual leaked-password lists
* rather than the whole of it. zxcvbn in the browser covers the rest.
*
* @return list<string>
*/
private static function commonPasswords(): array {
return [
'password',
'password1',
'password12',
'password123',
'passw0rd',
'p@ssword',
'p@ssw0rd',
'12345678',
'123456789',
'1234567890',
'123123123',
'qwertyui',
'qwertyuiop',
'qwerty123',
'qwerty12',
'1qaz2wsx',
'zaq12wsx',
'iloveyou',
'princess',
'sunshine',
'football',
'baseball',
'basketball',
'superman',
'batman123',
'trustno1',
'welcome1',
'welcome123',
'letmein1',
'letmein123',
'admin123',
'administrator',
'abc12345',
'abcd1234',
'monkey123',
'dragon123',
'michael1',
'jennifer',
'starwars',
'computer',
'whatever',
'freedom1',
'changeme',
'secret123',
'login123',
];
}
}
+31 -4
View File
@@ -124,6 +124,28 @@ class RegistrationPage {
// needed whenever the form itself is on screen.
if ( $canRegister && '' === $successType ) {
wp_enqueue_script( 'us-scheduler-register' );
// The browser gate reads the same numbers the server enforces, so the
// two cannot drift into disagreeing about what it accepted.
wp_localize_script(
'us-scheduler-register',
'usSchedulerPassword',
[
'minLength' => PasswordPolicy::MIN_LENGTH,
'minScore' => PasswordPolicy::MIN_SCORE,
'strings' => [
'short' => sprintf(
/* translators: %d: minimum number of characters. */
__( 'At least %d characters, please.', 'unsupervised-schedular' ),
PasswordPolicy::MIN_LENGTH
),
'veryWeak' => __( 'Too weak — a stranger could guess this.', 'unsupervised-schedular' ),
'weak' => __( 'Still too weak. Try a longer phrase.', 'unsupervised-schedular' ),
'medium' => __( 'Good enough.', 'unsupervised-schedular' ),
'strong' => __( 'Strong password.', 'unsupervised-schedular' ),
],
]
);
}
ob_start();
@@ -248,10 +270,6 @@ class RegistrationPage {
$password = Val::string( wp_unslash( $_POST['password'] ?? '' ) );
$displayName = sanitize_text_field( Val::string( wp_unslash( $_POST['display_name'] ?? '' ) ) );
if ( strlen( $password ) < 8 ) {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
}
// The email is fixed by a personal invite; group-link signups and
// self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) {
@@ -263,6 +281,15 @@ class RegistrationPage {
}
}
// After the email, so the password can be checked against it. The browser
// scores the password with zxcvbn and refuses to submit a weak one, but
// that is advice a client can decline to take — this is the check that
// holds. See PasswordPolicy for why the two halves differ.
$passwordError = PasswordPolicy::validate( $password, $email, $displayName );
if ( null !== $passwordError ) {
return esc_html( $passwordError );
}
$policyForms = $this->signupPolicies();
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is coerced to a positive int in the array_map callback; slashes cannot survive integer coercion.
$accepted = array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) ( $_POST['accept'] ?? [] ) );
+18
View File
@@ -210,6 +210,24 @@ class BlockPreview {
);
}
/**
* Sample account panel. Shown populated whatever the editor's own login
* state, since on the published page a signed-out visitor may see nothing at
* all and an empty box tells the person placing the block nothing.
*/
public static function account(): string {
return sprintf(
'<div class="us-account">%s'
. '<p class="us-account-who"><span class="us-account-name">%s</span>'
. '<span class="us-account-email">%s</span></p>'
. '<p class="us-account-actions"><a class="us-account-signout" href="#">%s</a></p></div>',
self::note( __( 'Editor preview — each visitor sees their own account here.', 'unsupervised-schedular' ) ),
esc_html__( 'Grace Hopper', 'unsupervised-schedular' ),
esc_html__( '[email protected]', 'unsupervised-schedular' ),
esc_html__( 'Sign out', 'unsupervised-schedular' )
);
}
private static function note( string $text ): string {
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
}
+20
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
@@ -30,6 +31,7 @@ class BlockRegistrar {
private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage,
private FamilyPage $familyPage,
private AccountPage $accountPage,
) {}
public function register(): void {
@@ -148,6 +150,15 @@ class BlockRegistrar {
],
],
],
'us-scheduler/account' => [
'render' => [ $this, 'renderAccount' ],
'attributes' => [
'loginPageId' => [
'type' => 'number',
'default' => 0,
],
],
],
];
}
@@ -195,6 +206,15 @@ class BlockRegistrar {
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
}
/**
* Renders the account (who is signed in) block.
*
* @param array<string, mixed> $attributes Block attributes.
*/
public function renderAccount( array $attributes = [] ): string {
return $this->isEditorPreview() ? BlockPreview::account() : $this->accountPage->render( $attributes );
}
/**
* Renders the family (manage-children) block.
*
+4 -2
View File
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationLoginGate;
use Unsupervised\Schedular\Auth\RegistrationMailer;
@@ -99,6 +100,7 @@ class Plugin {
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
$groupClassPage = new GroupClassPage( $guardians );
$familyPage = new FamilyPage( $guardians, $questions, $answers );
$accountPage = new AccountPage();
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
@@ -110,7 +112,7 @@ class Plugin {
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage, $accountPage ) )->register();
}
}
+19 -2
View File
@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
@@ -18,6 +19,7 @@ class ShortcodeRegistrar {
private RegistrationPage $registrationPage,
private GroupClassPage $groupClassPage,
private FamilyPage $familyPage,
private AccountPage $accountPage,
) {}
public function register(): void {
@@ -26,6 +28,7 @@ class ShortcodeRegistrar {
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
add_shortcode( 'us_account', self::shortcode( [ $this->accountPage, 'render' ] ) );
// Process registration submissions before output so the invite branch's
// auth cookie is actually sent (render() runs too late, during the_content).
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
@@ -88,7 +91,21 @@ class ShortcodeRegistrar {
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
// Progressive enhancement for the two-step registration form (no dependencies).
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
/*
* Progressive enhancement for the two-step registration form.
*
* `password-strength-meter` is WordPress's own wrapper around zxcvbn, so
* the signup form scores a password exactly the way wp-admin does rather
* than inventing a second opinion. It pulls in `zxcvbn-async`, which
* fetches the (large) dictionary only once the page has loaded — hence
* the guard in register.js for the window where it is not there yet.
*/
wp_register_script(
'us-scheduler-register',
USC_PLUGIN_URL . 'assets/js/register.js',
[ 'password-strength-meter' ],
USC_VERSION,
true
);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/**
* @var string $name Display name of the signed-in visitor.
* @var string $email Their account email.
* @var string $logoutUrl Nonced sign-out URL, already carrying its redirect.
*/
?>
<div class="us-account">
<p class="us-account-who">
<span class="us-account-name"><?php echo esc_html($name); ?></span>
<?php if ($email !== '') : ?>
<span class="us-account-email"><?php echo esc_html($email); ?></span>
<?php endif; ?>
</p>
<p class="us-account-actions">
<a class="us-account-signout" href="<?php echo esc_url($logoutUrl); ?>"><?php esc_html_e('Sign out', 'unsupervised-schedular'); ?></a>
</p>
</div>
+10 -1
View File
@@ -1,6 +1,7 @@
<?php
declare(strict_types=1);
use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Registration\Question;
use Unsupervised\Schedular\Registration\QuestionField;
@@ -67,7 +68,15 @@ if (! defined('ABSPATH')) {
</p>
<p>
<label for="us-reg-pass"><?php esc_html_e('Password', 'unsupervised-schedular'); ?></label>
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="8" required>
<input type="password" name="password" id="us-reg-pass" autocomplete="new-password" minlength="<?php echo esc_attr((string) PasswordPolicy::MIN_LENGTH); ?>" required aria-describedby="us-reg-pass-strength">
<?php
/*
* Filled in by register.js. `aria-live` announces the verdict as
* it changes, and it starts empty so nothing is announced — or
* takes up space — before anything has been typed.
*/
?>
<span class="us-password-strength" id="us-reg-pass-strength" role="status" aria-live="polite"></span>
</p>
<fieldset class="us-guardian">
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class AccountPageTest extends TestCase
{
private AccountPage $page;
protected function setUp(): void
{
parent::setUp();
$this->page = new AccountPage();
Functions\when('is_user_logged_in')->justReturn(true);
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('get_permalink')->alias(
static fn (int $id = 0): string => $id > 0
? 'https://studio.test/sign-in/'
: 'https://studio.test/current/'
);
Functions\when('wp_logout_url')->alias(
static fn (string $redirect): string => 'https://studio.test/wp-login.php?action=logout&redirect_to=' . rawurlencode($redirect)
);
Functions\when('wp_get_current_user')->justReturn($this->user('Grace', 'Hopper', '[email protected]'));
}
private function user(string $first, string $last, string $email): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->ID = 5;
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = '';
$user->user_email = $email;
return $user;
}
public function testShowsTheSignedInNameAndEmail(): void
{
$html = $this->page->render([]);
self::assertStringContainsString('Grace Hopper', $html);
self::assertStringContainsString('[email protected]', $html);
self::assertStringContainsString('Sign out', $html);
}
public function testSigningOutReturnsToTheConfiguredLoginPage(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/sign-in/'),
$this->page->render(['loginPageId' => 9])
);
}
/**
* With no page chosen, signing out from a header link should leave the
* visitor where they were rather than navigating them somewhere.
*/
public function testSigningOutReturnsToTheCurrentPageWhenNoLoginPageIsSet(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/current/'),
$this->page->render([])
);
}
public function testTheShortcodeAttributeNameIsAccepted(): void
{
self::assertStringContainsString(
rawurlencode('https://studio.test/sign-in/'),
$this->page->render(['login_page_id' => 9])
);
}
/**
* A block whose whole job is "you are signed in as X" has nothing to say to
* a stranger, and a bare notice in a site header cannot be acted on.
*/
public function testRendersNothingForASignedOutVisitorWithNoLoginPage(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
self::assertSame('', $this->page->render([]));
}
public function testOffersASignInLinkToASignedOutVisitorWhenAPageIsChosen(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
$html = $this->page->render(['loginPageId' => 9]);
self::assertStringContainsString('https://studio.test/sign-in/', $html);
self::assertStringContainsString('Sign in', $html);
self::assertStringNotContainsString('Sign out', $html);
}
/**
* A page can be deleted after it has been chosen in the block, which
* get_permalink() reports as false.
*/
public function testTreatsADeletedLoginPageAsNoneChosen(): void
{
Functions\when('is_user_logged_in')->justReturn(false);
Functions\when('get_permalink')->justReturn(false);
self::assertSame('', $this->page->render(['loginPageId' => 9]));
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Unsupervised\Schedular\Auth\PasswordPolicy;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class PasswordPolicyTest extends TestCase
{
public function testAcceptsAnOrdinaryMemorablePassword(): void
{
self::assertNull(PasswordPolicy::validate('thistle-marrow-42', '[email protected]', 'Grace Hopper'));
}
/**
* A leading or trailing space is a character like any other. Trimming it
* would accept a password the user could then never type back.
*/
public function testCountsSurroundingSpaceAsPartOfThePassword(): void
{
self::assertNull(PasswordPolicy::validate(' spaced-out-phrase '));
// Seven characters counting both spaces: one short, and still one short
// after the spaces are counted rather than stripped.
self::assertNotNull(PasswordPolicy::validate(' short '));
}
/**
* @dataProvider tooShort
*/
public function testRejectsAPasswordShorterThanTheMinimum(string $password): void
{
self::assertStringContainsString('at least', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function tooShort(): array
{
return [
'empty' => [''],
'one short' => ['sevench'],
'a few chars' => ['abc'],
];
}
/**
* @dataProvider commonPasswords
*/
public function testRejectsAWellKnownPassword(string $password): void
{
self::assertStringContainsString('commonly used', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function commonPasswords(): array
{
return [
'password123' => ['password123'],
'shouting' => ['PASSWORD123'],
'mixed case' => ['PassWord123'],
'a keyboard walk' => ['qwertyuiop'],
'digits in a row' => ['123456789'],
'the classic' => ['iloveyou'],
];
}
/**
* @dataProvider tooFewDistinctCharacters
*/
public function testRejectsAPasswordBuiltFromAlmostNoDistinctCharacters(string $password): void
{
self::assertStringContainsString('repeated characters', (string) PasswordPolicy::validate($password));
}
/** @return array<string, array{string}> */
public static function tooFewDistinctCharacters(): array
{
return [
'one character' => ['aaaaaaaaaa'],
'two alternating' => ['abababababab'],
'three' => ['abcabcabcabc'],
];
}
/**
* @dataProvider identityEchoes
*/
public function testRejectsAPasswordContainingTheUsersOwnDetails(string $password, string $email, string $name): void
{
self::assertStringContainsString('name or email', (string) PasswordPolicy::validate($password, $email, $name));
}
/** @return array<string, array{string, string, string}> */
public static function identityEchoes(): array
{
return [
'the whole email' => ['[email protected]!', '[email protected]', 'Grace'],
'the local part' => ['grace-hopper-1906', '[email protected]', ''],
'the display name' => ['xxhopperxx-2019', '[email protected]', 'Hopper'],
'differing in case' => ['MyGRACEpassword', '[email protected]', ''],
];
}
/**
* A two- or three-letter overlap with a name is coincidence, not a weakness,
* and refusing it would be baffling to the person typing.
*/
public function testShortIdentityFragmentsDoNotTripTheCheck(): void
{
self::assertNull(PasswordPolicy::validate('bramble-thicket', '[email protected]', 'Bo'));
}
public function testAnEmptyIdentityIsNotTreatedAsContainedInEverything(): void
{
self::assertNull(PasswordPolicy::validate('bramble-thicket', '', ''));
}
}
+69 -14
View File
@@ -37,6 +37,9 @@ class RegistrationPageTest extends TestCase
Functions\when('sanitize_text_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_textarea_field')->alias(static fn ($v) => $v);
Functions\when('sanitize_email')->alias(static fn ($v) => $v);
// Reached on every submit now that the email is validated before the
// password, so the password can be checked against it.
Functions\when('is_email')->alias(static fn (string $v): bool => (bool) preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $v));
Functions\when('absint')->alias(static fn ($v) => (int) $v);
// The birth-year check reads current_time('Y'), so answer that format
// properly rather than leaving it to cast out of the datetime string.
@@ -45,6 +48,7 @@ class RegistrationPageTest extends TestCase
);
Functions\when('wp_enqueue_style')->justReturn(null);
Functions\when('wp_enqueue_script')->justReturn(null);
Functions\when('wp_localize_script')->justReturn(true);
$invites = Mockery::mock(InviteRepository::class);
$policies = Mockery::mock(PolicyRepository::class);
@@ -114,7 +118,7 @@ class RegistrationPageTest extends TestCase
public function testInviteBranchCreatesAndLogsInTheStudent(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
@@ -131,7 +135,7 @@ class RegistrationPageTest extends TestCase
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
@@ -151,7 +155,7 @@ class RegistrationPageTest extends TestCase
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
@@ -178,7 +182,7 @@ class RegistrationPageTest extends TestCase
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
Functions\when('email_exists')->justReturn(false);
@@ -346,7 +350,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
@@ -365,7 +369,7 @@ class RegistrationPageTest extends TestCase
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
{
$_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => '[email protected]' ];
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
Functions\when('is_email')->justReturn(true);
@@ -385,7 +389,7 @@ class RegistrationPageTest extends TestCase
public function testRecordsAccountAnswersOnSuccess(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Ada',
'us_answers' => [ '5' => 'By a friend' ],
];
@@ -419,7 +423,7 @@ class RegistrationPageTest extends TestCase
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
{
$_POST = [ 'us_register' => '1', 'password' => 'password123', 'display_name' => 'Ada' ];
$_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
$_REQUEST = [ 'us_invite' => 'raw-token' ];
Functions\when('is_user_logged_in')->justReturn(false);
@@ -618,7 +622,7 @@ class RegistrationPageTest extends TestCase
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
@@ -656,10 +660,61 @@ class RegistrationPageTest extends TestCase
self::assertSame([[101, 'Piano'], [102, 'Violin']], $recorded);
}
/**
* The browser gates on zxcvbn, but that is advice a client can decline to
* take. Nothing is created for a password the server refuses.
*
* @dataProvider refusedPasswords
*/
public function testSignupRefusesAPasswordThePolicyRejects(string $password, string $expected): void
{
$_POST = [
'email' => '[email protected]',
'password' => $password,
'display_name' => 'Grace Hopper',
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString(
$expected,
$this->submit(new Invite(email: '[email protected]', token: 'hash'), false)
);
}
/** @return array<string, array{string, string}> */
public static function refusedPasswords(): array
{
return [
'too short' => ['abc123', 'at least'],
'a known password' => ['password123', 'commonly used'],
'barely any variety' => ['ababababab', 'repeated characters'],
'their own name' => ['grace-hopper-1906', 'name or email'],
];
}
public function testSignupRefusesAnAddressThatIsNotAnEmail(): void
{
$_POST = [
'email' => 'not-an-email',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
];
Functions\when('email_exists')->justReturn(false);
Functions\expect('wp_insert_user')->never();
self::assertStringContainsString(
'valid email address',
$this->submit(null, true)
);
}
public function testGuardianSignupWithNoChildrenIsRejected(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
@@ -743,7 +798,7 @@ class RegistrationPageTest extends TestCase
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
@@ -770,7 +825,7 @@ class RegistrationPageTest extends TestCase
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'children' => [
@@ -807,7 +862,7 @@ class RegistrationPageTest extends TestCase
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
{
$_POST = [
'password' => 'password123',
'password' => 'thistle-marrow-42',
'display_name' => 'Grace',
'us_is_guardian' => '1',
'accept' => [3],
@@ -846,7 +901,7 @@ class RegistrationPageTest extends TestCase
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
{
$_POST = ['password' => 'password123', 'display_name' => 'Ada'];
$_POST = ['password' => 'thistle-marrow-42', 'display_name' => 'Ada'];
Functions\when('email_exists')->justReturn(false);
Functions\when('wp_insert_user')->justReturn(42);
+6
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\BlockRegistrar;
@@ -43,6 +44,7 @@ class BlockRegistrarTest extends TestCase
private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage;
private FamilyPage&Mockery\MockInterface $familyPage;
private AccountPage&Mockery\MockInterface $accountPage;
private TestableBlockRegistrar $registrar;
protected function setUp(): void
@@ -54,6 +56,7 @@ class BlockRegistrarTest extends TestCase
$this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
$this->familyPage = Mockery::mock(FamilyPage::class);
$this->accountPage = Mockery::mock(AccountPage::class);
// Most requests are not a just-finished registration; the tests that
// exercise that path override this.
@@ -67,6 +70,7 @@ class BlockRegistrarTest extends TestCase
$this->registrationPage,
$this->groupClassPage,
$this->familyPage,
$this->accountPage,
);
}
@@ -116,6 +120,7 @@ class BlockRegistrarTest extends TestCase
'us-scheduler/student-register',
'us-scheduler/group-classes',
'us-scheduler/family',
'us-scheduler/account',
],
array_keys($registered)
);
@@ -213,6 +218,7 @@ class BlockRegistrarTest extends TestCase
$this->registrationPage,
$this->groupClassPage,
$this->familyPage,
$this->accountPage,
);
$this->bookingPage->shouldReceive('render')->once()->with([])->andReturn('live');
+5 -1
View File
@@ -6,6 +6,7 @@ namespace Unsupervised\Schedular\Tests\Unit;
use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\AccountPage;
use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
@@ -20,6 +21,7 @@ class ShortcodeRegistrarTest extends TestCase
private RegistrationPage&Mockery\MockInterface $registrationPage;
private GroupClassPage&Mockery\MockInterface $groupClassPage;
private FamilyPage&Mockery\MockInterface $familyPage;
private AccountPage&Mockery\MockInterface $accountPage;
private ShortcodeRegistrar $registrar;
/** @var array<string, callable> */
@@ -37,6 +39,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrationPage = Mockery::mock(RegistrationPage::class);
$this->groupClassPage = Mockery::mock(GroupClassPage::class);
$this->familyPage = Mockery::mock(FamilyPage::class);
$this->accountPage = Mockery::mock(AccountPage::class);
$this->registrar = new ShortcodeRegistrar(
$this->bookingPage,
@@ -44,6 +47,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrationPage,
$this->groupClassPage,
$this->familyPage,
$this->accountPage,
);
$shortcodes = &$this->shortcodes;
@@ -66,7 +70,7 @@ class ShortcodeRegistrarTest extends TestCase
$this->registrar->register();
self::assertSame(
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family'],
['us_booking', 'us_student_login', 'us_student_register', 'us_group_classes', 'us_family', 'us_account'],
array_keys($this->shortcodes)
);
}