diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6e2f1..c985f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ each change under the current top section as you work. ## [1.3.1] +### 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 - 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. - The interface now says **student** where it said "child" and **profile** where it said "family". The `[us_family]` page is headed **Your profile**, its form is **Add a student**, signup asks for a **Student's name**, and the wp-admin students list and student screen both label the relationship **Profile**. Two strings were reworded rather than swapped: the students list reads **Managed by _name_** (a bare "Student of _name_" would read as a teacher's pupil), and a managed account is described as a **managed student account** so it is not confused with the account holder. Internal names — database columns, request parameters, form field names, the `us_family` shortcode and the `us-scheduler/family` block — are unchanged, since they are contracts with existing installs and saved post content. diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 24b8cbe..55f7e5a 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -525,6 +525,30 @@ } } +/* + * 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; +} + /* Shown only in block-editor previews (see BlockPreview). */ .us-editor-note { font-size: 0.85em; diff --git a/assets/js/register.js b/assets/js/register.js index 93b8364..48226f1 100644 --- a/assets/js/register.js +++ b/assets/js/register.js @@ -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"]'); @@ -154,6 +257,7 @@ : null; enhanceGuardian(forms[i], steps); + enhancePassword(forms[i]); } }); })(); diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index 564f840..0b2d1c5 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -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 diff --git a/src/Auth/PasswordPolicy.php b/src/Auth/PasswordPolicy.php new file mode 100644 index 0000000..5dd6be3 --- /dev/null +++ b/src/Auth/PasswordPolicy.php @@ -0,0 +1,165 @@ += 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 + */ + 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', + ]; + } +} diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index dca08a2..6473950 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -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'] ?? [] ) ); diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 919ee30..42d713c 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -88,7 +88,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 + ); } } diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 0483fb2..a4f36d7 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -1,6 +1,7 @@

- + + +

diff --git a/tests/Unit/Auth/PasswordPolicyTest.php b/tests/Unit/Auth/PasswordPolicyTest.php new file mode 100644 index 0000000..f796098 --- /dev/null +++ b/tests/Unit/Auth/PasswordPolicyTest.php @@ -0,0 +1,118 @@ + */ + 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 */ + 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 */ + 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 */ + public static function identityEchoes(): array + { + return [ + 'the whole email' => ['grace@studio.test!', 'grace@studio.test', 'Grace'], + 'the local part' => ['grace-hopper-1906', 'grace@studio.test', ''], + 'the display name' => ['xxhopperxx-2019', 'someone@studio.test', 'Hopper'], + 'differing in case' => ['MyGRACEpassword', 'grace@studio.test', ''], + ]; + } + + /** + * 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', 'bo@studio.test', 'Bo')); + } + + public function testAnEmptyIdentityIsNotTreatedAsContainedInEverything(): void + { + self::assertNull(PasswordPolicy::validate('bramble-thicket', '', '')); + } +} diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index b356959..28136a7 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -37,10 +37,14 @@ 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); Functions\when('current_time')->justReturn('2024-01-01 00:00:00'); 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); @@ -110,7 +114,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); @@ -127,7 +131,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); @@ -147,7 +151,7 @@ class RegistrationPageTest extends TestCase public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void { - $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; Functions\when('is_email')->justReturn(true); Functions\when('email_exists')->justReturn(false); @@ -174,7 +178,7 @@ class RegistrationPageTest extends TestCase public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void { - $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; Functions\when('is_email')->justReturn(true); Functions\when('email_exists')->justReturn(false); @@ -342,7 +346,7 @@ class RegistrationPageTest extends TestCase public function testRejectsWhenARequiredPolicyIsUnaccepted(): void { - $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; Functions\when('is_email')->justReturn(true); @@ -361,7 +365,7 @@ class RegistrationPageTest extends TestCase public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void { - $_POST = [ 'password' => 'password123', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; + $_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => 'new@b.test' ]; Functions\when('is_email')->justReturn(true); @@ -381,7 +385,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' ], ]; @@ -415,7 +419,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); @@ -614,7 +618,7 @@ class RegistrationPageTest extends TestCase public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void { $_POST = [ - 'password' => 'password123', + 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ @@ -652,10 +656,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' => 'grace@studio.test', + '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: 'grace@studio.test', token: 'hash'), false) + ); + } + + /** @return array */ + 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' => []]], @@ -677,7 +732,7 @@ class RegistrationPageTest extends TestCase public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void { $_POST = [ - 'password' => 'password123', + 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ @@ -704,7 +759,7 @@ class RegistrationPageTest extends TestCase public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void { $_POST = [ - 'password' => 'password123', + 'password' => 'thistle-marrow-42', 'display_name' => 'Grace', 'us_is_guardian' => '1', 'children' => [ @@ -741,7 +796,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], @@ -780,7 +835,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);