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]>
852 lines
36 KiB
PHP
852 lines
36 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Mockery;
|
|
use Unsupervised\Schedular\Auth\Invite;
|
|
use Unsupervised\Schedular\Auth\InviteRepository;
|
|
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
|
use Unsupervised\Schedular\Auth\RegistrationPage;
|
|
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
|
use Unsupervised\Schedular\Guardian\GuardianService;
|
|
use Unsupervised\Schedular\Payment\StudioSettings;
|
|
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
|
use Unsupervised\Schedular\Policy\Policy;
|
|
use Unsupervised\Schedular\Policy\PolicyAcceptance;
|
|
use Unsupervised\Schedular\Policy\PolicyRepository;
|
|
use Unsupervised\Schedular\Policy\PolicyVersion;
|
|
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
|
|
use Unsupervised\Schedular\Registration\Answer;
|
|
use Unsupervised\Schedular\Registration\AnswerRepository;
|
|
use Unsupervised\Schedular\Registration\Question;
|
|
use Unsupervised\Schedular\Registration\QuestionRepository;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
|
|
class RegistrationPageTest extends TestCase
|
|
{
|
|
/** @var array<string, mixed> */
|
|
private array $ctx;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Functions\when('wp_unslash')->alias(static fn ($v) => $v);
|
|
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);
|
|
$questions = Mockery::mock(QuestionRepository::class);
|
|
$answers = Mockery::mock(AnswerRepository::class);
|
|
$policies->shouldReceive('findForScope')->andReturn([])->byDefault();
|
|
$questions->shouldReceive('findByScope')->andReturn([])->byDefault();
|
|
$answers->shouldReceive('insert')->andReturn(1)->byDefault();
|
|
|
|
$access = Mockery::mock(GroupAccessRepository::class);
|
|
$access->shouldReceive('linkStudentByEmail')->andReturn(true)->byDefault();
|
|
|
|
$this->ctx = [
|
|
'invites' => $invites,
|
|
'policies' => $policies,
|
|
'questions' => $questions,
|
|
'answers' => $answers,
|
|
'access' => $access,
|
|
'mailer' => Mockery::mock(RegistrationMailer::class),
|
|
'settings' => Mockery::mock(StudioSettings::class),
|
|
];
|
|
|
|
$this->ctx['versions'] = Mockery::mock(PolicyVersionRepository::class);
|
|
$this->ctx['acceptances'] = Mockery::mock(AcceptanceRepository::class);
|
|
$this->ctx['guardians'] = Mockery::mock(GuardianService::class);
|
|
|
|
$this->ctx['page'] = new RegistrationPage(
|
|
$invites,
|
|
$policies,
|
|
$this->ctx['versions'],
|
|
$this->ctx['acceptances'],
|
|
$this->ctx['settings'],
|
|
$this->ctx['mailer'],
|
|
$questions,
|
|
$answers,
|
|
$access,
|
|
$this->ctx['guardians'],
|
|
);
|
|
|
|
$_POST = [];
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
$_POST = [];
|
|
$_GET = [];
|
|
$_REQUEST = [];
|
|
parent::tearDown();
|
|
}
|
|
|
|
/** Stub everything render() needs on a logged-out GET request. */
|
|
private function stubRenderContext(): void
|
|
{
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
|
|
Functions\when('wp_nonce_field')->justReturn('');
|
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
|
|
}
|
|
|
|
private function submit(?Invite $invite, bool $open): string
|
|
{
|
|
$method = new \ReflectionMethod(RegistrationPage::class, 'handleSubmit');
|
|
|
|
return (string) $method->invoke($this->ctx['page'], $invite, $open);
|
|
}
|
|
|
|
public function testInviteBranchCreatesAndLogsInTheStudent(): void
|
|
{
|
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
|
Functions\expect('wp_set_current_user')->once()->with(42);
|
|
Functions\expect('wp_set_auth_cookie')->once()->with(42);
|
|
|
|
$invite = new Invite(email: '[email protected]', token: 'hash');
|
|
|
|
self::assertSame('invite', $this->submit($invite, false));
|
|
}
|
|
|
|
public function testInviteAcceptanceLinksClassGrantForTheEmail(): void
|
|
{
|
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
|
Functions\when('wp_set_current_user')->justReturn(null);
|
|
Functions\when('wp_set_auth_cookie')->justReturn(null);
|
|
|
|
// A personal invite tied to a class grant links the new account to it.
|
|
$this->ctx['access']->shouldReceive('linkStudentByEmail')->once()->with('[email protected]', 42)->andReturn(true);
|
|
|
|
$invite = new Invite(email: '[email protected]', token: 'hash', offeringId: 8);
|
|
|
|
self::assertSame('invite', $this->submit($invite, false));
|
|
}
|
|
|
|
public function testOpenBranchCreatesPendingWithoutLoginAndEmails(): void
|
|
{
|
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
|
|
|
Functions\when('is_email')->justReturn(true);
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
// markPending internals
|
|
Functions\when('wp_generate_password')->justReturn('rawtok');
|
|
Functions\when('update_user_meta')->justReturn(true);
|
|
// confirmUrl internals
|
|
Functions\when('get_option')->justReturn(0);
|
|
Functions\when('home_url')->justReturn('http://home.test/');
|
|
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
|
|
|
|
$user = Mockery::mock(\WP_User::class);
|
|
Functions\when('get_user_by')->justReturn($user);
|
|
|
|
$this->ctx['mailer']->shouldReceive('sendConfirmation')->once()->with($user, Mockery::type('string'));
|
|
// No invite acceptance and no auto-login in the open branch.
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->never();
|
|
Functions\expect('wp_set_auth_cookie')->never();
|
|
|
|
self::assertSame('confirm', $this->submit(null, true));
|
|
}
|
|
|
|
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
|
|
{
|
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
|
|
|
Functions\when('is_email')->justReturn(true);
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
Functions\when('wp_generate_password')->justReturn('rawtok');
|
|
// confirmUrl internals
|
|
Functions\when('get_option')->justReturn(0);
|
|
Functions\when('home_url')->justReturn('http://home.test/');
|
|
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
|
|
|
|
// The auto-approve marker must be set alongside the pending metas.
|
|
$metas = [];
|
|
Functions\when('update_user_meta')->alias(static function (int $id, string $key, $value) use (&$metas): bool {
|
|
$metas[$key] = $value;
|
|
return true;
|
|
});
|
|
|
|
$user = Mockery::mock(\WP_User::class);
|
|
Functions\when('get_user_by')->justReturn($user);
|
|
|
|
$this->ctx['mailer']->shouldReceive('sendConfirmation')->once()->with($user, Mockery::type('string'));
|
|
// The link is multi-use: never marked accepted, and no auto-login.
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->never();
|
|
Functions\expect('wp_set_auth_cookie')->never();
|
|
|
|
$invite = new Invite(
|
|
email: '',
|
|
token: 'hash',
|
|
createdAt: '2024-01-01 00:00:00',
|
|
kind: Invite::KIND_GROUP,
|
|
expiresAt: '2024-02-01 23:59:59',
|
|
id: 9
|
|
);
|
|
|
|
// Registration mode is invite-only (open = false): the group link still works.
|
|
self::assertSame('confirm_group', $this->submit($invite, false));
|
|
self::assertSame('1', $metas['us_auto_approve'] ?? null);
|
|
}
|
|
|
|
public function testGroupInviteRendersEditableEmailField(): void
|
|
{
|
|
$_REQUEST = ['us_invite' => 'raw-token'];
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
|
|
Functions\when('wp_nonce_field')->justReturn('');
|
|
// Invite-only mode: only the group link grants access to the form.
|
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
|
|
|
|
$invite = new Invite(
|
|
email: '',
|
|
token: 'hash',
|
|
createdAt: '2024-01-01 00:00:00',
|
|
kind: Invite::KIND_GROUP,
|
|
expiresAt: '2024-02-01 23:59:59',
|
|
id: 9
|
|
);
|
|
$this->ctx['invites']->shouldReceive('findByToken')
|
|
->once()
|
|
->with(Invite::hashToken('raw-token'))
|
|
->andReturn($invite);
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringContainsString('<form', $html);
|
|
self::assertStringContainsString('name="email"', $html);
|
|
self::assertStringNotContainsString('readonly', $html);
|
|
}
|
|
|
|
public function testClosedModeWithoutInviteReturnsError(): void
|
|
{
|
|
$result = $this->submit(null, false);
|
|
|
|
self::assertNotSame('invite', $result);
|
|
self::assertNotSame('confirm', $result);
|
|
self::assertNotSame('', $result);
|
|
}
|
|
|
|
public function testConfirmedEmailShowsSignInLinkInsteadOfForm(): void
|
|
{
|
|
$_GET = ['us_confirmed' => '1'];
|
|
$this->stubRenderContext();
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringContainsString('us-success', $html);
|
|
self::assertStringContainsString('http://home.test/wp-login.php', $html);
|
|
self::assertStringNotContainsString('<form', $html);
|
|
}
|
|
|
|
public function testConfirmedSignInLinkUsesConfiguredLoginPage(): void
|
|
{
|
|
$_GET = ['us_confirmed' => '1'];
|
|
$this->stubRenderContext();
|
|
Functions\expect('get_permalink')->once()->with(7)->andReturn('http://home.test/sign-in/');
|
|
|
|
$html = $this->ctx['page']->render(['login_page_id' => 7]);
|
|
|
|
self::assertStringContainsString('http://home.test/sign-in/', $html);
|
|
self::assertStringNotContainsString('wp-login.php', $html);
|
|
}
|
|
|
|
public function testRegistrationFormRendersWithoutConfirmationFlag(): void
|
|
{
|
|
$this->stubRenderContext();
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringContainsString('<form', $html);
|
|
self::assertStringNotContainsString('us-success', $html);
|
|
}
|
|
|
|
public function testExpiredConfirmationStillShowsForm(): void
|
|
{
|
|
$_GET = ['us_confirmed' => 'expired'];
|
|
$this->stubRenderContext();
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringContainsString('us-error', $html);
|
|
self::assertStringContainsString('<form', $html);
|
|
}
|
|
|
|
public function testValidInviteRendersEmailPrefilledAndLocked(): void
|
|
{
|
|
$_REQUEST = ['us_invite' => 'raw-token'];
|
|
$this->stubRenderContext();
|
|
|
|
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
|
|
$this->ctx['invites']->shouldReceive('findByToken')
|
|
->once()
|
|
->with(Invite::hashToken('raw-token'))
|
|
->andReturn($invite);
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
// The invited address is shown read-only; there is no editable email input.
|
|
self::assertStringContainsString('value="[email protected]" readonly', $html);
|
|
self::assertStringNotContainsString('name="email"', $html);
|
|
}
|
|
|
|
public function testStaleInviteWithOpenRegistrationShowsEditableEmail(): void
|
|
{
|
|
$_REQUEST = ['us_invite' => 'raw-token'];
|
|
$this->stubRenderContext();
|
|
|
|
// Already-redeemed invite: not acceptable, so the open-registration form
|
|
// must collect an email rather than showing the stale locked address.
|
|
$invite = new Invite(
|
|
email: '[email protected]',
|
|
token: 'hash',
|
|
status: Invite::STATUS_ACCEPTED,
|
|
createdAt: '2024-01-01 00:00:00',
|
|
id: 9
|
|
);
|
|
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringContainsString('name="email"', $html);
|
|
self::assertStringNotContainsString('[email protected]', $html);
|
|
self::assertStringNotContainsString('readonly', $html);
|
|
}
|
|
|
|
public function testRejectsWhenARequiredPolicyIsUnaccepted(): void
|
|
{
|
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
|
|
|
Functions\when('is_email')->justReturn(true);
|
|
|
|
$policy = new Policy(title: 'Terms', slug: 'terms', currentVersionId: 3);
|
|
$version = new PolicyVersion(policyId: 1, versionNumber: 1, status: PolicyVersion::STATUS_PUBLISHED, id: 3);
|
|
|
|
$this->ctx['policies']->shouldReceive('findForScope')->andReturn([ $policy ]);
|
|
$versionRepo = (new \ReflectionProperty(RegistrationPage::class, 'versions'))->getValue($this->ctx['page']);
|
|
$versionRepo->shouldReceive('findById')->with(3)->andReturn($version);
|
|
|
|
$result = $this->submit(null, true);
|
|
|
|
self::assertNotSame('confirm', $result);
|
|
self::assertNotSame('', $result);
|
|
}
|
|
|
|
public function testRejectsWhenARequiredAccountQuestionIsUnanswered(): void
|
|
{
|
|
$_POST = [ 'password' => 'thistle-marrow-42', 'display_name' => 'Ada', 'email' => '[email protected]' ];
|
|
|
|
Functions\when('is_email')->justReturn(true);
|
|
|
|
$question = new Question(null, 'Emergency contact', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
|
|
|
// A missing required answer must be caught before any account is created.
|
|
Functions\expect('wp_insert_user')->never();
|
|
$this->ctx['answers']->shouldReceive('insert')->never();
|
|
|
|
$result = $this->submit(null, true);
|
|
|
|
self::assertNotSame('confirm', $result);
|
|
self::assertNotSame('', $result);
|
|
}
|
|
|
|
public function testRecordsAccountAnswersOnSuccess(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Ada',
|
|
'us_answers' => [ '5' => 'By a friend' ],
|
|
];
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
Functions\expect('wp_set_current_user')->once();
|
|
Functions\expect('wp_set_auth_cookie')->once();
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
|
|
|
$question = new Question(null, 'How did you hear about us?', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 5);
|
|
$this->ctx['questions']->shouldReceive('findByScope')->with(Question::SCOPE_ACCOUNT, Mockery::any())->andReturn([$question]);
|
|
|
|
// The answer is written against the new user (account scope).
|
|
$this->ctx['answers']->shouldReceive('insert')
|
|
->once()
|
|
->with(Mockery::on(static function (Answer $answer): bool {
|
|
return $answer->registrationType === Answer::REG_ACCOUNT
|
|
&& $answer->registrationId === 42
|
|
&& $answer->studentId === 42
|
|
&& $answer->questionId === 5
|
|
&& $answer->answerValue === 'By a friend';
|
|
}))
|
|
->andReturn(1);
|
|
|
|
$invite = new Invite(email: '[email protected]', token: 'hash');
|
|
|
|
self::assertSame('invite', $this->submit($invite, false));
|
|
}
|
|
|
|
public function testMaybeHandleSubmitLogsInInviteAndRedirects(): void
|
|
{
|
|
$_POST = [ 'us_register' => '1', 'password' => 'thistle-marrow-42', 'display_name' => 'Ada' ];
|
|
$_REQUEST = [ 'us_invite' => 'raw-token' ];
|
|
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('check_admin_referer')->justReturn(true);
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
Functions\when('get_permalink')->justReturn('http://home.test/register/');
|
|
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
|
|
|
|
// The cookie must be set here — during template_redirect, before output —
|
|
// which is the whole point of processing the submit outside render().
|
|
Functions\expect('wp_set_current_user')->once()->with(42);
|
|
Functions\expect('wp_set_auth_cookie')->once()->with(42);
|
|
|
|
$invite = new Invite(email: '[email protected]', token: 'hash', createdAt: '2024-01-01 00:00:00', id: 9);
|
|
$this->ctx['invites']->shouldReceive('findByToken')->once()->andReturn($invite);
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
|
|
|
|
$page = Mockery::mock(
|
|
RegistrationPage::class,
|
|
[
|
|
$this->ctx['invites'],
|
|
$this->ctx['policies'],
|
|
$this->ctx['versions'],
|
|
$this->ctx['acceptances'],
|
|
$this->ctx['settings'],
|
|
$this->ctx['mailer'],
|
|
$this->ctx['questions'],
|
|
$this->ctx['answers'],
|
|
$this->ctx['access'],
|
|
$this->ctx['guardians'],
|
|
]
|
|
)->makePartial()->shouldAllowMockingProtectedMethods();
|
|
|
|
$captured = '';
|
|
$page->shouldReceive('redirect')->once()->with(Mockery::on(static function (string $url) use (&$captured): bool {
|
|
$captured = $url;
|
|
return true;
|
|
}));
|
|
|
|
$page->maybeHandleSubmit();
|
|
|
|
self::assertStringContainsString('us_registered=invite', $captured);
|
|
}
|
|
|
|
public function testMaybeHandleSubmitStoresValidationErrorWithoutRedirecting(): void
|
|
{
|
|
// Too-short password: handleSubmit returns an error and no redirect fires.
|
|
$_POST = [ 'us_register' => '1', 'password' => 'short', 'display_name' => 'Ada' ];
|
|
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('check_admin_referer')->justReturn(true);
|
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(true);
|
|
|
|
// A redirect would call exit; reaching the assertion proves none happened.
|
|
$this->ctx['page']->maybeHandleSubmit();
|
|
|
|
$error = (new \ReflectionProperty(RegistrationPage::class, 'submitError'))->getValue($this->ctx['page']);
|
|
self::assertNotSame('', $error);
|
|
}
|
|
|
|
public function testInviteSuccessRedirectShowsLoggedInWelcome(): void
|
|
{
|
|
// After the PRG redirect the student is logged in; the us_registered flag
|
|
// distinguishes a just-completed signup from an already-logged-in visitor.
|
|
$_GET = [ 'us_registered' => 'invite' ];
|
|
Functions\when('is_user_logged_in')->justReturn(true);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
|
|
$html = $this->ctx['page']->render([]);
|
|
|
|
self::assertStringContainsString('us-success', $html);
|
|
self::assertStringContainsString('now logged in', $html);
|
|
// No page chosen: the sign-in-screen fallback is useless to someone who
|
|
// is already signed in, so no link is offered at all.
|
|
self::assertStringNotContainsString('<a href', $html);
|
|
}
|
|
|
|
public function testInviteSuccessLinksToTheChosenPage(): void
|
|
{
|
|
$_GET = [ 'us_registered' => 'invite' ];
|
|
Functions\when('is_user_logged_in')->justReturn(true);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
|
|
Functions\when('get_the_title')->justReturn('Book a Lesson');
|
|
|
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
|
|
|
self::assertStringContainsString('now logged in', $html);
|
|
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
|
// The link names its destination rather than saying "your account".
|
|
self::assertStringContainsString('Continue to Book a Lesson', $html);
|
|
}
|
|
|
|
public function testContinueLinkFallsBackToGenericWordingForAnUntitledPage(): void
|
|
{
|
|
Functions\when('is_user_logged_in')->justReturn(true);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
Functions\when('get_permalink')->justReturn('http://home.test/welcome/');
|
|
Functions\when('get_the_title')->justReturn(' ');
|
|
|
|
// An untitled page must not produce a link reading "Continue to ".
|
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
|
|
|
self::assertStringContainsString('Continue to your account', $html);
|
|
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
|
}
|
|
|
|
public function testAlreadyLoggedInVisitorIsLinkedToTheChosenPage(): void
|
|
{
|
|
// No us_registered flag: someone who simply happens to be signed in and
|
|
// lands on the registration page. They still need a way onward.
|
|
Functions\when('is_user_logged_in')->justReturn(true);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
Functions\expect('get_permalink')->once()->with(4)->andReturn('http://home.test/welcome/');
|
|
Functions\when('get_the_title')->justReturn('Book a Lesson');
|
|
|
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
|
|
|
self::assertStringContainsString('already have an account', $html);
|
|
self::assertStringContainsString('href="http://home.test/welcome/"', $html);
|
|
self::assertStringContainsString('Continue to Book a Lesson', $html);
|
|
// Not the just-registered message — that branch needs its own flag.
|
|
self::assertStringNotContainsString('us-success', $html);
|
|
}
|
|
|
|
public function testAlreadyLoggedInVisitorGetsNoLinkWithoutAChosenPage(): void
|
|
{
|
|
Functions\when('is_user_logged_in')->justReturn(true);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
Functions\when('get_permalink')->justReturn(false);
|
|
|
|
// A deleted page resolves to false, which must not become a broken link.
|
|
$html = $this->ctx['page']->render([ 'loginPageId' => 4 ]);
|
|
|
|
self::assertStringContainsString('already have an account', $html);
|
|
self::assertStringNotContainsString('<a href', $html);
|
|
|
|
self::assertStringNotContainsString('<a href', $this->ctx['page']->render([]));
|
|
}
|
|
|
|
public function testContinueUrlIsNullWithoutAResolvablePage(): void
|
|
{
|
|
Functions\when('get_permalink')->justReturn(false);
|
|
|
|
self::assertNull($this->ctx['page']->continueUrl(0));
|
|
self::assertNull($this->ctx['page']->continueUrl(4));
|
|
}
|
|
|
|
public function testIsRegistrationCompleteOnlyForFinishedStates(): void
|
|
{
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
|
|
// [ query args, logged in, finished ]
|
|
$cases = [
|
|
'invited student, now logged in' => [['us_registered' => 'invite'], true, true],
|
|
'invited student, not logged in' => [['us_registered' => 'invite'], false, false],
|
|
'email confirmed, ready' => [['us_confirmed' => 'ready'], false, true],
|
|
'email confirmed, pending review' => [['us_confirmed' => '1'], false, true],
|
|
'confirmation link expired' => [['us_confirmed' => 'expired'], false, false],
|
|
'awaiting email confirmation' => [['us_registered' => 'confirm'], false, false],
|
|
'group signup awaiting confirm' => [['us_registered' => 'confirm_group'], false, false],
|
|
'plain page view' => [[], false, false],
|
|
];
|
|
|
|
foreach ($cases as $label => [$get, $loggedIn, $expected]) {
|
|
$_GET = $get;
|
|
Functions\when('is_user_logged_in')->justReturn($loggedIn);
|
|
|
|
self::assertSame($expected, $this->ctx['page']->isRegistrationComplete(), $label);
|
|
}
|
|
}
|
|
|
|
public function testInviteOnlyMessageCanBeCustomised(): void
|
|
{
|
|
// Closed registration and no invite → the invitation-only gate shows.
|
|
Functions\when('is_user_logged_in')->justReturn(false);
|
|
Functions\when('sanitize_key')->alias(static fn ($v) => strtolower((string) $v));
|
|
Functions\when('wp_login_url')->justReturn('http://home.test/wp-login.php');
|
|
Functions\when('wp_nonce_field')->justReturn('');
|
|
$this->ctx['settings']->shouldReceive('openRegistrationEnabled')->andReturn(false);
|
|
|
|
$html = $this->ctx['page']->render([ 'inviteOnlyMessage' => 'Ask the front desk for a link.' ]);
|
|
|
|
self::assertStringContainsString('Ask the front desk for a link.', $html);
|
|
self::assertStringNotContainsString('by invitation only', $html);
|
|
}
|
|
|
|
/**
|
|
* A guardian's signup creates one login-less child per filled block, links
|
|
* them, and records each child's answers against the child rather than the
|
|
* account holder — the questions describe the student, not the parent.
|
|
*/
|
|
public function testGuardianSignupCreatesEachChildAndRecordsTheirAnswers(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_is_guardian' => '1',
|
|
'children' => [
|
|
['name' => 'Ada', 'birth_year' => '2015', 'answers' => [7 => 'Piano']],
|
|
['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => 'Violin']],
|
|
// An untouched spare block is dropped, not rejected.
|
|
['name' => ' ', 'birth_year' => '', 'answers' => []],
|
|
],
|
|
];
|
|
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
|
|
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
|
|
]);
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
|
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '2015')->andReturn(101);
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')->andReturn(102);
|
|
|
|
$recorded = [];
|
|
$this->ctx['answers']->shouldReceive('insert')->andReturnUsing(
|
|
static function (Answer $a) use (&$recorded): int {
|
|
$recorded[] = [$a->studentId, $a->answerValue];
|
|
return 1;
|
|
}
|
|
);
|
|
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
|
Functions\expect('wp_set_current_user')->once()->with(42);
|
|
Functions\expect('wp_set_auth_cookie')->once()->with(42);
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
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' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_is_guardian' => '1',
|
|
'children' => [['name' => '', 'birth_year' => '', 'answers' => []]],
|
|
];
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\expect('wp_insert_user')->never();
|
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
|
|
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
|
|
|
self::assertStringContainsString('at least one student', $result);
|
|
}
|
|
|
|
/**
|
|
* Required per-child answers are validated before any user exists, so a
|
|
* missing one never leaves a half-registered family behind.
|
|
*/
|
|
public function testGuardianSignupRejectsAChildMissingARequiredAnswer(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_is_guardian' => '1',
|
|
'children' => [
|
|
['name' => 'Ada', 'birth_year' => '', 'answers' => [7 => 'Piano']],
|
|
['name' => 'Alan', 'birth_year' => '', 'answers' => [7 => ' ']],
|
|
],
|
|
];
|
|
|
|
$this->ctx['questions']->shouldReceive('findByScope')->andReturn([
|
|
new Question(offeringId: null, label: 'Instrument', isRequired: true, scope: Question::SCOPE_ACCOUNT, id: 7),
|
|
]);
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\expect('wp_insert_user')->never();
|
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
|
|
|
self::assertStringContainsString('for each student', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
}
|
|
|
|
/**
|
|
* A family that half-created would leave the guardian unable to re-register
|
|
* and their children unconfirmed, so the whole signup is undone.
|
|
*/
|
|
public function testAFailedChildRollsBackEveryUserCreatedIncludingTheGuardian(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_is_guardian' => '1',
|
|
'children' => [
|
|
['name' => 'Ada', 'birth_year' => '', 'answers' => []],
|
|
['name' => 'Alan', 'birth_year' => '', 'answers' => []],
|
|
],
|
|
];
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
|
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Ada', '')->andReturn(101);
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->with(42, 'Alan', '')
|
|
->andReturn(new \WP_Error('link_failed', 'Nope.'));
|
|
|
|
$deleted = [];
|
|
$this->ctx['guardians']->shouldReceive('deleteUser')->andReturnUsing(
|
|
static function (int $id) use (&$deleted): void {
|
|
$deleted[] = $id;
|
|
}
|
|
);
|
|
|
|
$result = $this->submit(new Invite(email: '[email protected]', token: 'hash'), false);
|
|
|
|
self::assertStringContainsString('Could not create the account', $result);
|
|
self::assertSame([101, 42], $deleted);
|
|
}
|
|
|
|
/**
|
|
* The child is who the policy binds; the guardian is who agreed. Both are
|
|
* recorded, which is what makes the acceptance legally meaningful.
|
|
*/
|
|
public function testSignupPoliciesAreAcceptedPerChildAndAttributedToTheGuardian(): void
|
|
{
|
|
$_POST = [
|
|
'password' => 'thistle-marrow-42',
|
|
'display_name' => 'Grace',
|
|
'us_is_guardian' => '1',
|
|
'accept' => [3],
|
|
'children' => [['name' => 'Ada', 'birth_year' => '', 'answers' => []]],
|
|
];
|
|
|
|
$version = new PolicyVersion(policyId: 1, versionNumber: 1, body: 'Terms', status: PolicyVersion::STATUS_PUBLISHED, id: 3);
|
|
$this->ctx['policies']->shouldReceive('findForScope')->andReturn([
|
|
new Policy(title: 'Studio Terms', slug: 'terms', currentVersionId: 3, id: 1),
|
|
]);
|
|
$this->ctx['versions']->shouldReceive('findById')->with(3)->andReturn($version);
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->alias(static fn ($thing): bool => $thing instanceof \WP_Error);
|
|
|
|
$this->ctx['guardians']->shouldReceive('createChild')->once()->andReturn(101);
|
|
|
|
$recorded = [];
|
|
$this->ctx['acceptances']->shouldReceive('insert')->andReturnUsing(
|
|
static function (PolicyAcceptance $a) use (&$recorded): int {
|
|
$recorded[] = [$a->studentId, $a->acceptedBy];
|
|
return 1;
|
|
}
|
|
);
|
|
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
|
Functions\expect('wp_set_current_user')->once();
|
|
Functions\expect('wp_set_auth_cookie')->once();
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
|
|
// The guardian agreed for themselves as an account holder, and for the child.
|
|
self::assertSame([[42, 42], [101, 42]], $recorded);
|
|
}
|
|
|
|
public function testANonGuardianSignupIsUnchangedAndCreatesNoChildren(): void
|
|
{
|
|
$_POST = ['password' => 'thistle-marrow-42', 'display_name' => 'Ada'];
|
|
|
|
Functions\when('email_exists')->justReturn(false);
|
|
Functions\when('wp_insert_user')->justReturn(42);
|
|
Functions\when('is_wp_error')->justReturn(false);
|
|
|
|
$this->ctx['guardians']->shouldNotReceive('createChild');
|
|
$this->ctx['invites']->shouldReceive('markAccepted')->once();
|
|
Functions\expect('wp_set_current_user')->once();
|
|
Functions\expect('wp_set_auth_cookie')->once();
|
|
|
|
self::assertSame('invite', $this->submit(new Invite(email: '[email protected]', token: 'hash'), false));
|
|
}
|
|
}
|