Add multi-use group invite links with expiry and auto-approval on email confirmation #83

Merged
thatguygriff merged 1 commits from feature/group-invite-links into main 2026-07-22 13:49:15 +00:00
15 changed files with 437 additions and 29 deletions
+21 -5
View File
@@ -9,6 +9,12 @@ anyone may sign up, confirm their email, and then be approved by a studio admin
before the account can be used. Both modes coexist — invites keep working when
open registration is on.
A studio admin can also generate a **group invite link** — a multi-use, tokenised
link with an explicit expiry date (e.g. for a newsletter). Anyone with the link
may register while it is valid, regardless of the registration mode: they supply
their own email, must confirm it, and are then **approved automatically**
group-link signups never enter the Pending Students queue.
## Registration Modes
Stored in the `us_registration_mode` option (default `invite`), toggled from
**Studio Settings → Registration**:
@@ -59,14 +65,16 @@ confirmation token's SHA-256 hash is stored; the token expires after 48h
| Column | Type | Notes |
|--------------------|------------------|--------------------------------------------------------|
| `id` | BIGINT UNSIGNED | Primary key |
| `email` | VARCHAR(191) | Invited email address |
| `email` | VARCHAR(191) | Invited email address; empty string for group links |
| `token` | VARCHAR(64) | SHA-256 hash of the token embedded in the registration link (raw token is never stored) |
| `role` | VARCHAR(32) | Role granted on acceptance (default `us_student`) |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` |
| `kind` | VARCHAR(10) | `personal` (single-use, per email) or `group` (multi-use link) |
| `status` | VARCHAR(20) | `pending` / `accepted` / `revoked` (group links stay `pending` until revoked/expired) |
| `invited_by` | BIGINT UNSIGNED | WordPress user ID of the studio admin who invited |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending |
| `accepted_user_id` | BIGINT UNSIGNED | The created user's ID once accepted; NULL while pending / for group links |
| `created_at` | DATETIME | Insertion time |
| `accepted_at` | DATETIME | When accepted; NULL while pending |
| `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) |
## Policy Acceptance Scope
Policies declare **when** they must be accepted via `us_policies.acceptance_scope`:
@@ -88,11 +96,19 @@ recorded in `us_policy_acceptances` with `registration_type = account` and
4. The applicant opens the emailed `?us_confirm=<token>` link → email confirmed, studio admins notified.
5. Studio admin approves under **Students → Pending Students** → pending flags cleared, student emailed; they can now log in and book. Rejection deletes the account.
## Flow (group invite link)
1. Studio admin opens **Invites** and generates a **group link**, choosing the expiry date (required; the link stops working at the end of that day). The link is shown **once**, like personal invite links.
2. Anyone opens the link while it is pending and unexpired — in **any** registration mode — and the form collects an **editable email**, display name, password, and the signup policies.
3. On submit the account is created pending with the auto-approve marker (`RegistrationStatus::markPending($userId, autoApprove: true)`, meta `us_auto_approve`) and a confirmation email is sent. The invite row is **not** marked accepted — the link remains usable by others.
4. Opening the `?us_confirm=<token>` link confirms the email and **approves the account immediately** (`EmailConfirmationHandler`): no admin heads-up, no Pending Students entry; the student gets the "approved" email and the page shows a "ready to use" notice (`?us_confirmed=ready`) with a sign-in link.
5. The link can be revoked at any time from the Invites page.
## Admin Interface
**Invites** in wp-admin (`manage_students`, studio admin only):
- Select the **registration page** (the page hosting `[us_student_register]`), stored in the `us_registration_page_id` option; invitation links point there (falling back to the home page if unset)
- Invite an email (creates a pending invite; the link is displayed once, at creation only)
- List pending invites (email + invited date); revoke an invite
- Generate a **group invite link** with a required expiry date (link displayed once)
- List pending invites (email or "Group link", created + expiry dates); revoke an invite
**Pending Students** — submenu under Students (`manage_students`), only relevant in `self_approval` mode:
- "Awaiting approval" (email confirmed) — approve or reject
+15
View File
@@ -55,6 +55,21 @@ class EmailConfirmationHandler {
RegistrationStatus::confirmEmail( $userId );
$user = get_user_by( 'id', $userId );
// Group invite link signups skip the admin review queue: confirming the
// email approves the account on the spot, so the student can sign in
// immediately instead of waiting for a studio admin.
if ( RegistrationStatus::isAutoApprove( $userId ) ) {
RegistrationStatus::approve( $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendApproved( $user );
}
wp_safe_redirect( add_query_arg( 'us_confirmed', 'ready', $base ) );
exit;
}
if ( $user instanceof \WP_User ) {
$this->mailer->notifyAdminsPending( $user );
}
+32 -5
View File
@@ -11,6 +11,12 @@ class Invite {
public const STATUS_ACCEPTED = 'accepted';
public const STATUS_REVOKED = 'revoked';
/** Single-use invite addressed to one email. */
public const KIND_PERSONAL = 'personal';
/** Multi-use shareable link (e.g. for a newsletter) with an explicit expiry. */
public const KIND_GROUP = 'group';
/**
* All valid invite statuses.
*
@@ -43,6 +49,8 @@ class Invite {
public readonly ?int $acceptedUserId = null,
public readonly ?string $acceptedAt = null,
public readonly ?string $createdAt = null,
public readonly string $kind = self::KIND_PERSONAL,
public readonly ?string $expiresAt = null,
public readonly ?int $id = null,
) {}
@@ -56,27 +64,44 @@ class Invite {
acceptedUserId: Val::intOrNull( $row->accepted_user_id ),
acceptedAt: Val::stringOrNull( $row->accepted_at ),
createdAt: Val::stringOrNull( $row->created_at ?? null ),
kind: '' !== Val::string( $row->kind ?? '' ) ? Val::string( $row->kind ) : self::KIND_PERSONAL,
expiresAt: Val::stringOrNull( $row->expires_at ?? null ),
id: Val::int( $row->id ),
);
}
public function isGroup(): bool {
return self::KIND_GROUP === $this->kind;
}
public function isPending(): bool {
return self::STATUS_PENDING === $this->status;
}
/**
* Whether the invite was created more than {@see EXPIRY_DAYS} ago, measured
* against the supplied current `Y-m-d H:i:s` timestamp. An invite with no
* known creation time is treated as not expired.
* Whether the invite has expired, measured against the supplied current
* `Y-m-d H:i:s` timestamp. An explicit `expires_at` (set on every group
* link) wins; otherwise a personal invite expires {@see EXPIRY_DAYS} after
* creation. An invite with neither timestamp is treated as not expired.
*/
public function isExpired( string $now ): bool {
$current = strtotime( $now );
if ( false === $current ) {
return false;
}
if ( null !== $this->expiresAt ) {
$expires = strtotime( $this->expiresAt );
return false !== $expires && $current > $expires;
}
if ( null === $this->createdAt ) {
return false;
}
$created = strtotime( $this->createdAt );
$current = strtotime( $now );
if ( false === $created || false === $current ) {
if ( false === $created ) {
return false;
}
@@ -101,10 +126,12 @@ class Invite {
'email' => $this->email,
'token' => $this->token,
'role' => $this->role,
'kind' => $this->kind,
'status' => $this->status,
'invited_by' => $this->invitedBy,
'accepted_user_id' => $this->acceptedUserId,
'accepted_at' => $this->acceptedAt,
'expires_at' => $this->expiresAt,
];
}
}
+3 -1
View File
@@ -18,13 +18,15 @@ class InviteRepository {
'email' => $invite->email,
'token' => $invite->token,
'role' => $invite->role,
'kind' => $invite->kind,
'status' => $invite->status,
'invited_by' => $invite->invitedBy,
'accepted_user_id' => $invite->acceptedUserId,
'created_at' => current_time( 'mysql' ),
'accepted_at' => $invite->acceptedAt,
'expires_at' => $invite->expiresAt,
],
[ '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' ]
[ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
+37
View File
@@ -67,6 +67,26 @@ class RegistrationController {
}
}
if ( 'group_invite' === $action ) {
$expiresAt = $this->normalizeExpiry( sanitize_text_field( Val::string( wp_unslash( $_POST['expires_at'] ?? '' ) ) ) );
if ( null !== $expiresAt ) {
$rawToken = wp_generate_password( 32, false );
$this->invites->insert(
new Invite(
email: '',
token: Invite::hashToken( $rawToken ),
invitedBy: get_current_user_id(),
kind: Invite::KIND_GROUP,
expiresAt: $expiresAt,
)
);
return $this->registrationLink( $rawToken );
}
}
if ( 'revoke' === $action ) {
$inviteId = absint( Val::int( $_POST['invite_id'] ?? 0 ) );
if ( $inviteId > 0 ) {
@@ -78,6 +98,23 @@ class RegistrationController {
return '';
}
/**
* Validate a submitted group-link expiry date (strict `Y-m-d`, today or
* later) and expand it to the end of that day; null when invalid or past.
*/
private function normalizeExpiry( string $date ): ?string {
$day = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
if ( false === $day || $day->format( 'Y-m-d' ) !== $date ) {
return null;
}
if ( $date < Val::string( current_time( 'Y-m-d' ) ) ) {
return null;
}
return $date . ' 23:59:59';
}
/**
* Build the registration URL for a raw invite token.
*/
+19 -8
View File
@@ -19,6 +19,12 @@ class RegistrationPage {
/** Success signal: a self-signup was created and must confirm their email. */
private const RESULT_CONFIRM = 'confirm';
/**
* Success signal: a group-link signup was created and must confirm their
* email — confirming approves the account immediately (no admin review).
*/
private const RESULT_CONFIRM_GROUP = 'confirm_group';
public function __construct(
private InviteRepository $invites,
private PolicyRepository $policies,
@@ -56,7 +62,7 @@ class RegistrationPage {
if ( isset( $_POST['us_register'] ) && check_admin_referer( 'us_student_register' ) ) {
$result = $this->handleSubmit( $invite, $open );
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM ], true ) ) {
if ( in_array( $result, [ self::RESULT_INVITE, self::RESULT_CONFIRM, self::RESULT_CONFIRM_GROUP ], true ) ) {
$successType = $result;
} else {
$error = $result;
@@ -128,8 +134,9 @@ class RegistrationPage {
return esc_html__( 'Please choose a password of at least 8 characters.', 'unsupervised-schedular' );
}
// The email is fixed by the invite when there is one; self-signups supply it.
if ( $inviteValid ) {
// The email is fixed by a personal invite; group-link signups and
// self-signups supply their own.
if ( $inviteValid && ! $invite->isGroup() ) {
$email = $invite->email;
} else {
$email = sanitize_email( Val::string( wp_unslash( $_POST['email'] ?? '' ) ) );
@@ -169,7 +176,7 @@ class RegistrationPage {
$this->recordAcceptances( $policyForms, (int) $userId );
if ( $inviteValid ) {
if ( $inviteValid && ! $invite->isGroup() ) {
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
wp_set_current_user( (int) $userId );
@@ -178,15 +185,19 @@ class RegistrationPage {
return self::RESULT_INVITE;
}
// Self-approval: hold the account pending, email a confirmation link, and
// do NOT log the user in — they must confirm and be approved first.
$rawToken = RegistrationStatus::markPending( (int) $userId );
// Group-link signups and self-signups both stay pending until they
// confirm their email; the group link is multi-use so it is never marked
// accepted. A group signup auto-approves on confirmation — no admin
// review — while a self-signup then waits for studio approval.
$autoApprove = $inviteValid && $invite->isGroup();
$rawToken = RegistrationStatus::markPending( (int) $userId, $autoApprove );
$user = get_user_by( 'id', (int) $userId );
if ( $user instanceof \WP_User ) {
$this->mailer->sendConfirmation( $user, $this->confirmUrl( $rawToken ) );
}
return self::RESULT_CONFIRM;
return $autoApprove ? self::RESULT_CONFIRM_GROUP : self::RESULT_CONFIRM;
}
/**
+22 -1
View File
@@ -27,6 +27,12 @@ class RegistrationStatus {
public const META_CONFIRM_TOKEN = 'us_email_confirm_token';
public const META_CONFIRM_EXPIRES = 'us_email_confirm_expires';
/**
* Set on accounts created via a group invite link: confirming the email
* approves the account immediately instead of queueing it for admin review.
*/
public const META_AUTO_APPROVE = 'us_auto_approve';
/**
* Hours a self-signup email-confirmation link stays valid after the account
* is created. Limits the window in which a leaked link can be redeemed.
@@ -45,8 +51,10 @@ class RegistrationStatus {
/**
* Put a freshly created user into the pending state and issue an email
* confirmation token. Returns the raw token to embed in the emailed link.
* With `$autoApprove` (group invite links) confirming the email approves
* the account immediately — no admin review step.
*/
public static function markPending( int $userId ): string {
public static function markPending( int $userId, bool $autoApprove = false ): string {
$rawToken = wp_generate_password( 32, false );
update_user_meta( $userId, self::META_AWAITING_APPROVAL, '1' );
@@ -57,6 +65,10 @@ class RegistrationStatus {
gmdate( 'Y-m-d H:i:s', time() + self::EMAIL_CONFIRM_EXPIRY_HOURS * 3600 )
);
if ( $autoApprove ) {
update_user_meta( $userId, self::META_AUTO_APPROVE, '1' );
}
return $rawToken;
}
@@ -78,12 +90,21 @@ class RegistrationStatus {
delete_user_meta( $userId, self::META_AWAITING_APPROVAL );
delete_user_meta( $userId, self::META_CONFIRM_TOKEN );
delete_user_meta( $userId, self::META_CONFIRM_EXPIRES );
delete_user_meta( $userId, self::META_AUTO_APPROVE );
}
public static function isAwaitingApproval( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AWAITING_APPROVAL, true ) );
}
/**
* Whether confirming this account's email should approve it immediately
* (group invite link signups).
*/
public static function isAutoApprove( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_AUTO_APPROVE, true ) );
}
public static function emailConfirmed( int $userId ): bool {
return '1' === Val::string( get_user_meta( $userId, self::META_EMAIL_CONFIRMED, true ) );
}
+2
View File
@@ -186,11 +186,13 @@ class Schema {
email VARCHAR(191) NOT NULL,
token VARCHAR(64) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'us_student',
kind VARCHAR(10) NOT NULL DEFAULT 'personal',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
invited_by BIGINT UNSIGNED DEFAULT NULL,
accepted_user_id BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL,
accepted_at DATETIME DEFAULT NULL,
expires_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY token (token),
KEY email (email),
+24 -3
View File
@@ -66,6 +66,23 @@ if (! defined('ABSPATH')) {
<?php submit_button(esc_html__('Generate Invitation Link', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Group Invite Link', 'unsupervised-schedular'); ?></h2>
<p class="description"><?php esc_html_e('Generate a shareable link (e.g. for a newsletter). Anyone with the link can register until it expires: they enter their own email and must confirm it, but no admin approval is needed afterwards.', 'unsupervised-schedular'); ?></p>
<form method="post">
<?php wp_nonce_field('usc_invite_action'); ?>
<input type="hidden" name="usc_action" value="group_invite">
<table class="form-table">
<tr>
<th><label for="expires_at"><?php esc_html_e('Expires on', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="expires_at" id="expires_at" required min="<?php echo esc_attr((string) current_time('Y-m-d')); ?>">
<p class="description"><?php esc_html_e('The link stops working at the end of this day.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
</table>
<?php submit_button(esc_html__('Generate Group Link', 'unsupervised-schedular')); ?>
</form>
<h2><?php esc_html_e('Pending Invites', 'unsupervised-schedular'); ?></h2>
<?php if (empty($pendingInvites)) : ?>
@@ -74,8 +91,9 @@ if (! defined('ABSPATH')) {
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Email', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Invited', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Invite', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Created', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Expires', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Actions', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
@@ -84,7 +102,7 @@ if (! defined('ABSPATH')) {
<?php foreach ($pendingInvites as $invite) : ?>
<tr>
<td>
<?php echo esc_html($invite->email); ?>
<?php echo $invite->isGroup() ? esc_html__('Group link', 'unsupervised-schedular') : esc_html($invite->email); ?>
<?php if ($invite->isExpired($now)) : ?>
<span class="us-invite-expired" style="color:#b32d2e;">— <?php esc_html_e('expired', 'unsupervised-schedular'); ?></span>
<?php endif; ?>
@@ -92,6 +110,9 @@ if (! defined('ABSPATH')) {
<td>
<?php echo esc_html((string) $invite->createdAt); ?>
</td>
<td>
<?php echo esc_html($invite->expiresAt !== null ? (string) mysql2date('M j, Y', $invite->expiresAt) : '—'); ?>
</td>
<td>
<form method="post" style="display:inline;">
<?php wp_nonce_field('usc_invite_action'); ?>
+8 -3
View File
@@ -11,8 +11,8 @@ if (! defined('ABSPATH')) {
* @var string $token Raw invite token from the request (only its hash is stored).
* @var bool $canRegister
* @var bool $open Whether open (self-approval) registration is enabled.
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email).
* @var string $confirmResult '' | '1' (email confirmed) | 'expired'.
* @var string $successType '' | 'invite' (created + logged in) | 'confirm' (check email) | 'confirm_group' (check email; auto-approved on confirm).
* @var string $confirmResult '' | '1' (email confirmed, awaiting approval) | 'ready' (confirmed + auto-approved) | 'expired'.
* @var string $loginUrl Where the post-confirmation sign-in link points.
* @var string $error
* @var list<array{policy: \Unsupervised\Schedular\Policy\Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
@@ -23,6 +23,11 @@ if (! defined('ABSPATH')) {
<p class="us-success"><?php esc_html_e('Your account has been created and you are now logged in.', 'unsupervised-schedular'); ?></p>
<?php elseif ($successType === 'confirm') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, a studio admin will review and approve your account.', 'unsupervised-schedular'); ?></p>
<?php elseif ($successType === 'confirm_group') : ?>
<p class="us-success"><?php esc_html_e('Your account has been created. Check your email for a link to confirm your address — once you do, your account is ready to use.', 'unsupervised-schedular'); ?></p>
<?php elseif ($confirmResult === 'ready') : ?>
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed and your account is ready to use.', 'unsupervised-schedular'); ?></p>
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
<?php elseif ($confirmResult === '1') : ?>
<p class="us-success"><?php esc_html_e('Thanks — your email is confirmed. Your account is now awaiting studio approval; we will email you when it is ready.', 'unsupervised-schedular'); ?></p>
<p><a href="<?php echo esc_url($loginUrl); ?>"><?php esc_html_e('Sign in to your account', 'unsupervised-schedular'); ?></a></p>
@@ -44,7 +49,7 @@ if (! defined('ABSPATH')) {
<p>
<label for="us-reg-email"><?php esc_html_e('Email', 'unsupervised-schedular'); ?></label>
<?php if ($inviteValid && $invite !== null) : ?>
<?php if ($inviteValid && $invite !== null && ! $invite->isGroup()) : ?>
<input type="email" id="us-reg-email" value="<?php echo esc_attr($invite->email); ?>" readonly>
<?php else : ?>
<input type="email" name="email" id="us-reg-email" autocomplete="email" required>
@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\Auth\EmailConfirmationHandler;
use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RegistrationStatus;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Tests\Unit\TestCase;
@@ -22,9 +24,84 @@ class EmailConfirmationHandlerTest extends TestCase
protected function tearDown(): void
{
unset($_REQUEST['action']);
$_GET = [];
parent::tearDown();
}
/**
* Stub everything maybeConfirm() needs for a valid token belonging to user
* 9, with wp_safe_redirect throwing so the redirect URL can be asserted.
*
* @param bool $autoApprove Whether user 9 carries the auto-approve marker.
*/
private function stubConfirmContext(bool $autoApprove): void
{
$this->stubMode(StudioSettings::MODE_INVITE);
Functions\when('is_admin')->justReturn(false);
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_permalink')->justReturn('http://wp/register/');
Functions\when('add_query_arg')->alias(static fn (string $k, string $v, string $u): string => $u . '?' . $k . '=' . $v);
Functions\when('get_users')->justReturn([9]);
Functions\when('get_user_meta')->alias(static function (int $id, string $key) use ($autoApprove) {
if ($key === RegistrationStatus::META_CONFIRM_EXPIRES) {
return '2030-01-01 00:00:00';
}
if ($key === RegistrationStatus::META_AUTO_APPROVE) {
return $autoApprove ? '1' : '';
}
return '';
});
Functions\when('update_user_meta')->justReturn(true);
Functions\when('delete_user_meta')->justReturn(true);
Functions\when('wp_safe_redirect')->alias(static function (string $url): void {
throw new \RuntimeException('redirect:' . $url);
});
$_GET['us_confirm'] = 'rawtoken';
}
public function testConfirmAutoApprovesGroupLinkSignupWithoutAdminReview(): void
{
$this->stubConfirmContext(true);
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$mailer = Mockery::mock(RegistrationMailer::class);
$mailer->shouldReceive('sendApproved')->once()->with($user)->andReturn(true);
$mailer->shouldNotReceive('notifyAdminsPending');
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
try {
$handler->maybeConfirm();
self::fail('Expected a redirect');
} catch (\RuntimeException $e) {
self::assertStringContainsString('us_confirmed=ready', $e->getMessage());
}
}
public function testConfirmWithoutAutoApproveNotifiesAdminsAndStaysPending(): void
{
$this->stubConfirmContext(false);
$user = Mockery::mock(\WP_User::class);
Functions\when('get_user_by')->justReturn($user);
$mailer = Mockery::mock(RegistrationMailer::class);
$mailer->shouldReceive('notifyAdminsPending')->once()->with($user)->andReturn(true);
$mailer->shouldNotReceive('sendApproved');
$handler = new EmailConfirmationHandler(new StudioSettings(), $mailer);
try {
$handler->maybeConfirm();
self::fail('Expected a redirect');
} catch (\RuntimeException $e) {
self::assertStringContainsString('us_confirmed=1', $e->getMessage());
}
}
private function handler(): EmailConfirmationHandler
{
return new EmailConfirmationHandler(new StudioSettings(), new RegistrationMailer());
+26 -2
View File
@@ -34,16 +34,40 @@ class InviteRepositoryTest extends TestCase
Mockery::on(static function (array $d): bool {
return $d['email'] === '[email protected]'
&& $d['token'] === 'tok123'
&& $d['kind'] === Invite::KIND_PERSONAL
&& $d['status'] === Invite::STATUS_PENDING
&& $d['invited_by'] === 2;
&& $d['invited_by'] === 2
&& $d['expires_at'] === null;
}),
['%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s']
['%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s']
);
$this->db->insert_id = 5;
self::assertSame(5, $this->repo->insert(new Invite('[email protected]', 'tok123', invitedBy: 2)));
}
public function testInsertPersistsGroupKindAndExpiry(): void
{
Functions\expect('current_time')->with('mysql')->andReturn('2026-06-02 09:00:00');
$this->db->shouldReceive('insert')
->once()
->with(
'wp_us_invites',
Mockery::on(static function (array $d): bool {
return $d['email'] === ''
&& $d['kind'] === Invite::KIND_GROUP
&& $d['expires_at'] === '2026-08-31 23:59:59';
}),
Mockery::type('array')
);
$this->db->insert_id = 6;
$invite = new Invite('', 'tok456', invitedBy: 2, kind: Invite::KIND_GROUP, expiresAt: '2026-08-31 23:59:59');
self::assertSame(6, $this->repo->insert($invite));
}
public function testFindByTokenReturnsInvite(): void
{
$this->db->shouldReceive('prepare')
+47 -1
View File
@@ -95,6 +95,52 @@ class InviteTest extends TestCase
self::assertFalse($invite->isAcceptable('2026-06-02 09:00:00'));
}
public function testGroupInviteHonoursExplicitExpiry(): void
{
$invite = new Invite(
'',
'tok',
createdAt: '2026-06-01 09:00:00',
kind: Invite::KIND_GROUP,
expiresAt: '2026-08-31 23:59:59'
);
self::assertTrue($invite->isGroup());
// 19 days after creation — past the personal 14-day window, but the
// explicit expiry governs.
self::assertFalse($invite->isExpired('2026-06-20 09:00:00'));
self::assertTrue($invite->isAcceptable('2026-06-20 09:00:00'));
self::assertTrue($invite->isExpired('2026-09-01 00:00:00'));
self::assertFalse($invite->isAcceptable('2026-09-01 00:00:00'));
}
public function testExplicitExpiryWinsOverCreationWindowForPersonalInvites(): void
{
$invite = new Invite('[email protected]', 'tok', createdAt: '2026-06-01 09:00:00', expiresAt: '2026-06-02 23:59:59');
// One day old (inside the 14-day window) but past its explicit expiry.
self::assertTrue($invite->isExpired('2026-06-03 09:00:00'));
}
public function testFromRowDefaultsKindWhenColumnMissing(): void
{
$invite = Invite::fromRow((object) [
'id' => '5',
'email' => '[email protected]',
'token' => 'tok123',
'role' => RoleManager::STUDENT,
'status' => Invite::STATUS_PENDING,
'invited_by' => null,
'accepted_user_id' => null,
'accepted_at' => null,
]);
self::assertSame(Invite::KIND_PERSONAL, $invite->kind);
self::assertFalse($invite->isGroup());
self::assertNull($invite->expiresAt);
}
public function testHashTokenIsDeterministicSha256(): void
{
$hash = Invite::hashToken('raw-token');
@@ -109,7 +155,7 @@ class InviteTest extends TestCase
{
$arr = (new Invite('[email protected]', 'tok', id: 1))->toArray();
foreach (['id', 'email', 'token', 'role', 'status', 'invited_by', 'accepted_user_id', 'accepted_at'] as $key) {
foreach (['id', 'email', 'token', 'role', 'kind', 'status', 'invited_by', 'accepted_user_id', 'accepted_at', 'expires_at'] as $key) {
self::assertArrayHasKey($key, $arr);
}
}
+73
View File
@@ -123,6 +123,79 @@ class RegistrationPageTest extends TestCase
self::assertSame('confirm', $this->submit(null, true));
}
public function testGroupInviteCreatesPendingAutoApproveAccountEvenWhenClosed(): void
{
$_POST = [ 'password' => 'password123', '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);
@@ -42,10 +42,41 @@ class RegistrationStatusTest extends TestCase
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AWAITING_APPROVAL);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_TOKEN);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_CONFIRM_EXPIRES);
Functions\expect('delete_user_meta')->once()->with(7, RegistrationStatus::META_AUTO_APPROVE);
RegistrationStatus::approve(7);
}
public function testMarkPendingWithAutoApproveSetsMarkerMeta(): void
{
Functions\when('wp_generate_password')->justReturn('rawtoken');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AWAITING_APPROVAL, '1');
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_TOKEN, hash('sha256', 'rawtoken'));
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_CONFIRM_EXPIRES, \Mockery::type('string'));
Functions\expect('update_user_meta')
->once()
->with(7, RegistrationStatus::META_AUTO_APPROVE, '1');
self::assertSame('rawtoken', RegistrationStatus::markPending(7, true));
}
public function testIsAutoApproveReadsMeta(): void
{
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {
return $key === RegistrationStatus::META_AUTO_APPROVE ? '1' : '';
});
self::assertTrue(RegistrationStatus::isAutoApprove(7));
self::assertFalse(RegistrationStatus::isAwaitingApproval(7));
}
public function testAwaitingApprovalAndEmailConfirmedReadMeta(): void
{
Functions\when('get_user_meta')->alias(static function (int $id, string $key) {