From 356d9f984d20ff81a305be16d284aae6c30a9dd7 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Wed, 22 Jul 2026 10:44:16 -0300 Subject: [PATCH] Add multi-use group invite links with expiry and auto-approval on email confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A studio admin can generate a shareable group invite link (e.g. for a newsletter) from the Invites page, choosing a required expiry date. Anyone with the link may register while it is valid, in any registration mode: the form collects their own email, they must confirm it via the usual hashed token, and confirming approves the account immediately — group signups never enter the Pending Students queue. - us_invites grows kind (personal/group) and expires_at; an explicit expiry wins over the personal 14-day window. Group links stay pending (multi-use) until revoked or expired. - RegistrationPage: group signups create the account pending with the us_auto_approve marker and send the confirmation email; no auto-login. - EmailConfirmationHandler: auto-approve accounts are approved on confirmation, emailed the approved notice, and redirected to a new us_confirmed=ready notice with a sign-in link. Closes #77 Co-Authored-By: Claude Fable 5 --- docs/features/account-registration.md | 26 +++++-- src/Auth/EmailConfirmationHandler.php | 15 ++++ src/Auth/Invite.php | 37 +++++++-- src/Auth/InviteRepository.php | 4 +- src/Auth/RegistrationController.php | 37 +++++++++ src/Auth/RegistrationPage.php | 27 +++++-- src/Auth/RegistrationStatus.php | 23 +++++- src/Schema.php | 2 + templates/admin/invites.php | 27 ++++++- templates/frontend/register-page.php | 11 ++- .../Auth/EmailConfirmationHandlerTest.php | 77 +++++++++++++++++++ tests/Unit/Auth/InviteRepositoryTest.php | 28 ++++++- tests/Unit/Auth/InviteTest.php | 48 +++++++++++- tests/Unit/Auth/RegistrationPageTest.php | 73 ++++++++++++++++++ tests/Unit/Auth/RegistrationStatusTest.php | 31 ++++++++ 15 files changed, 437 insertions(+), 29 deletions(-) diff --git a/docs/features/account-registration.md b/docs/features/account-registration.md index 8b4da2d..a97e741 100644 --- a/docs/features/account-registration.md +++ b/docs/features/account-registration.md @@ -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=` 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=` 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 diff --git a/src/Auth/EmailConfirmationHandler.php b/src/Auth/EmailConfirmationHandler.php index e4144d7..67e42be 100644 --- a/src/Auth/EmailConfirmationHandler.php +++ b/src/Auth/EmailConfirmationHandler.php @@ -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 ); } diff --git a/src/Auth/Invite.php b/src/Auth/Invite.php index 83ad082..d3bb125 100644 --- a/src/Auth/Invite.php +++ b/src/Auth/Invite.php @@ -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, ]; } } diff --git a/src/Auth/InviteRepository.php b/src/Auth/InviteRepository.php index 80d35ae..1ce6116 100644 --- a/src/Auth/InviteRepository.php +++ b/src/Auth/InviteRepository.php @@ -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; diff --git a/src/Auth/RegistrationController.php b/src/Auth/RegistrationController.php index 402d420..8d93ad2 100644 --- a/src/Auth/RegistrationController.php +++ b/src/Auth/RegistrationController.php @@ -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. */ diff --git a/src/Auth/RegistrationPage.php b/src/Auth/RegistrationPage.php index a9f8e5a..17e022a 100644 --- a/src/Auth/RegistrationPage.php +++ b/src/Auth/RegistrationPage.php @@ -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; } /** diff --git a/src/Auth/RegistrationStatus.php b/src/Auth/RegistrationStatus.php index a5bfa4b..8e998b4 100644 --- a/src/Auth/RegistrationStatus.php +++ b/src/Auth/RegistrationStatus.php @@ -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 ) ); } diff --git a/src/Schema.php b/src/Schema.php index af2deb6..ff09a19 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -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), diff --git a/templates/admin/invites.php b/templates/admin/invites.php index afb54a2..26bfb1b 100644 --- a/templates/admin/invites.php +++ b/templates/admin/invites.php @@ -66,6 +66,23 @@ if (! defined('ABSPATH')) { +

+

+
+ + + + + + + +
+ +

+
+ +
+

@@ -74,8 +91,9 @@ if (! defined('ABSPATH')) { - - + + + @@ -84,7 +102,7 @@ if (! defined('ABSPATH')) { +
- email); ?> + isGroup() ? esc_html__('Group link', 'unsupervised-schedular') : esc_html($invite->email); ?> isExpired($now)) : ?> @@ -92,6 +110,9 @@ if (! defined('ABSPATH')) { createdAt); ?> + expiresAt !== null ? (string) mysql2date('M j, Y', $invite->expiresAt) : '—'); ?> +
diff --git a/templates/frontend/register-page.php b/templates/frontend/register-page.php index 172fc17..fe0a891 100644 --- a/templates/frontend/register-page.php +++ b/templates/frontend/register-page.php @@ -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 $policyForms @@ -23,6 +23,11 @@ if (! defined('ABSPATH')) {

+ +

+ +

+

@@ -44,7 +49,7 @@ if (! defined('ABSPATH')) {

- + isGroup()) : ?> diff --git a/tests/Unit/Auth/EmailConfirmationHandlerTest.php b/tests/Unit/Auth/EmailConfirmationHandlerTest.php index 7bb1a71..ff01463 100644 --- a/tests/Unit/Auth/EmailConfirmationHandlerTest.php +++ b/tests/Unit/Auth/EmailConfirmationHandlerTest.php @@ -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()); diff --git a/tests/Unit/Auth/InviteRepositoryTest.php b/tests/Unit/Auth/InviteRepositoryTest.php index 5be7600..a232208 100644 --- a/tests/Unit/Auth/InviteRepositoryTest.php +++ b/tests/Unit/Auth/InviteRepositoryTest.php @@ -34,16 +34,40 @@ class InviteRepositoryTest extends TestCase Mockery::on(static function (array $d): bool { return $d['email'] === 'a@b.test' && $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('a@b.test', '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') diff --git a/tests/Unit/Auth/InviteTest.php b/tests/Unit/Auth/InviteTest.php index 42fa834..61eb830 100644 --- a/tests/Unit/Auth/InviteTest.php +++ b/tests/Unit/Auth/InviteTest.php @@ -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('a@b.test', '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' => 'a@b.test', + '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('a@b.test', '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); } } diff --git a/tests/Unit/Auth/RegistrationPageTest.php b/tests/Unit/Auth/RegistrationPageTest.php index 8ba0cb8..43ba2dd 100644 --- a/tests/Unit/Auth/RegistrationPageTest.php +++ b/tests/Unit/Auth/RegistrationPageTest.php @@ -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' => 'new@b.test' ]; + + 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('submit(null, false); diff --git a/tests/Unit/Auth/RegistrationStatusTest.php b/tests/Unit/Auth/RegistrationStatusTest.php index f2d05ec..1143495 100644 --- a/tests/Unit/Auth/RegistrationStatusTest.php +++ b/tests/Unit/Auth/RegistrationStatusTest.php @@ -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) {