CI / No Debug Code (pull_request) Successful in 4s
CI / Tests (PHP 8.2) (pull_request) Successful in 50s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m3s
CI / Tests (PHP 8.5) (pull_request) Successful in 2m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 3m24s
CI / Coding Standards & Static Analysis (pull_request) Successful in 8m21s
CI / Build Plugin Zip (pull_request) Skipped
The Book a lesson for a student panel built its picker from the us_student role but vetted the submission with the book_lesson capability. ChildLoginGate and RegistrationLoginGate withhold that capability from accounts that keep the role, so the panel offered every guardian-managed child and every unapproved signup and then refused them — with a message claiming no student had been chosen, and a form cleared of all five fields. Withholding book_lesson stops those accounts registering in their own name. It was never meant to stop the studio acting for them, which is what the panel is for, and for a child is the only route to a lesson besides their guardian. Guard the student role instead, via a new RoleManager::isStudent() shared with every picker and guard on the staff side so the two cannot drift apart again. Group enrolment gets the same predicate: addDirect() and grantAccess() vetted their posted ids not at all, and would enrol an instructor, an administrator, or an account deleted since the page was drawn — raising a real payment against them for a priced class. Keep a refused booking's fields as submitted, reading the form through one LessonController::submittedBooking() so what gets booked and what is shown again cannot disagree about a field name. A booking that succeeds still leaves an empty form, so the next one does not inherit it. Closes #185 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01XunBYk2sFEc1oL14sUiuBU
203 lines
7.5 KiB
PHP
203 lines
7.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Unsupervised\Schedular\Tests\Unit\Auth;
|
|
|
|
use Brain\Monkey\Functions;
|
|
use Unsupervised\Schedular\Auth\AccessSettings;
|
|
use Unsupervised\Schedular\Auth\RoleManager;
|
|
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
|
|
|
class RoleManagerTest extends TestCase
|
|
{
|
|
private function roleManager(bool $studio = true, bool $instructor = true): RoleManager
|
|
{
|
|
$access = \Mockery::mock(AccessSettings::class);
|
|
$access->allows('adminsAreStudioAdmins')->andReturn($studio);
|
|
$access->allows('adminsAreInstructors')->andReturn($instructor);
|
|
|
|
return new RoleManager($access);
|
|
}
|
|
|
|
public function testRegisterAddsInitHookAndCapFilter(): void
|
|
{
|
|
Functions\expect('add_action')
|
|
->once()
|
|
->with('init', \Mockery::any());
|
|
|
|
Functions\expect('add_filter')
|
|
->once()
|
|
->with('user_has_cap', \Mockery::any(), 10, 1);
|
|
|
|
(new RoleManager())->register();
|
|
}
|
|
|
|
public function testGrantsStudioCapsToAdministrators(): void
|
|
{
|
|
$result = $this->roleManager()->grantStudioCapsToAdministrators(['manage_options' => true]);
|
|
|
|
foreach (RoleManager::STUDIO_ADMIN_CAPS as $cap) {
|
|
self::assertTrue($result[$cap], "administrator should be granted {$cap}");
|
|
}
|
|
}
|
|
|
|
public function testGrantsInstructorCapsToAdministrators(): void
|
|
{
|
|
$result = $this->roleManager()->grantStudioCapsToAdministrators(['manage_options' => true]);
|
|
|
|
// A single-instructor studio owner runs as an administrator and also
|
|
// teaches, so they get the instructor caps — notably manage_availability,
|
|
// which is not part of the studio-admin set.
|
|
self::assertTrue($result[RoleManager::CAP_MANAGE_AVAILABILITY], 'administrator should be able to manage availability');
|
|
foreach (RoleManager::INSTRUCTOR_CAPS as $cap) {
|
|
self::assertTrue($result[$cap], "administrator should be granted {$cap}");
|
|
}
|
|
}
|
|
|
|
public function testStudioGrantDisabledWithholdsStudioCapsFromAdministrators(): void
|
|
{
|
|
$result = $this->roleManager(studio: false)->grantStudioCapsToAdministrators(['manage_options' => true]);
|
|
|
|
// manage_instructors is studio-only, so it disappears when the grant is off.
|
|
self::assertArrayNotHasKey(RoleManager::CAP_MANAGE_INSTRUCTORS, $result);
|
|
// Instructor caps remain because that grant is still on.
|
|
self::assertTrue($result[RoleManager::CAP_MANAGE_AVAILABILITY]);
|
|
}
|
|
|
|
public function testInstructorGrantDisabledWithholdsInstructorCapsFromAdministrators(): void
|
|
{
|
|
$result = $this->roleManager(instructor: false)->grantStudioCapsToAdministrators(['manage_options' => true]);
|
|
|
|
// manage_availability is instructor-only, so it disappears when the grant is off.
|
|
self::assertArrayNotHasKey(RoleManager::CAP_MANAGE_AVAILABILITY, $result);
|
|
// Studio caps remain because that grant is still on.
|
|
self::assertTrue($result[RoleManager::CAP_MANAGE_INSTRUCTORS]);
|
|
}
|
|
|
|
public function testDoesNotGrantCapsToNonAdministrators(): void
|
|
{
|
|
$result = $this->roleManager()->grantStudioCapsToAdministrators(['read' => true]);
|
|
|
|
self::assertArrayNotHasKey(RoleManager::CAP_MANAGE_OFFERINGS, $result);
|
|
self::assertSame(['read' => true], $result);
|
|
}
|
|
|
|
public function testCreateRolesSkipsExistingRoles(): void
|
|
{
|
|
Functions\when('get_role')->alias(static fn() => new \stdClass());
|
|
Functions\expect('add_role')->never();
|
|
|
|
(new RoleManager())->createRoles();
|
|
}
|
|
|
|
public function testCreateRolesAddsInstructorRoleWithCorrectCaps(): void
|
|
{
|
|
Functions\when('get_role')->alias(static function (string $role): ?object {
|
|
return $role === RoleManager::INSTRUCTOR ? null : new \stdClass();
|
|
});
|
|
|
|
Functions\expect('add_role')
|
|
->once()
|
|
->with(
|
|
RoleManager::INSTRUCTOR,
|
|
\Mockery::any(),
|
|
\Mockery::on(static function (array $caps): bool {
|
|
return ($caps['read'] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_MANAGE_AVAILABILITY] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_VIEW_LESSONS] ?? false) === true;
|
|
})
|
|
);
|
|
|
|
(new RoleManager())->createRoles();
|
|
}
|
|
|
|
public function testCreateRolesAddsStudioAdminRoleWithCorrectCaps(): void
|
|
{
|
|
Functions\when('get_role')->alias(static function (string $role): ?object {
|
|
return $role === RoleManager::STUDIO_ADMIN ? null : new \stdClass();
|
|
});
|
|
|
|
Functions\expect('add_role')
|
|
->once()
|
|
->with(
|
|
RoleManager::STUDIO_ADMIN,
|
|
\Mockery::any(),
|
|
\Mockery::on(static function (array $caps): bool {
|
|
return ($caps['read'] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_MANAGE_INSTRUCTORS] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_MANAGE_OFFERINGS] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_MANAGE_POLICIES] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_MANAGE_BILLING] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_VIEW_ALL_PAYMENTS] ?? false) === true;
|
|
})
|
|
);
|
|
|
|
(new RoleManager())->createRoles();
|
|
}
|
|
|
|
public function testCreateRolesAddsStudentRoleWithCorrectCaps(): void
|
|
{
|
|
Functions\when('get_role')->alias(static function (string $role): ?object {
|
|
return $role === RoleManager::STUDENT ? null : new \stdClass();
|
|
});
|
|
|
|
Functions\expect('add_role')
|
|
->once()
|
|
->with(
|
|
RoleManager::STUDENT,
|
|
\Mockery::any(),
|
|
\Mockery::on(static function (array $caps): bool {
|
|
return ($caps['read'] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_BOOK_LESSON] ?? false) === true
|
|
&& ($caps[RoleManager::CAP_VIEW_LESSONS] ?? false) === true;
|
|
})
|
|
);
|
|
|
|
(new RoleManager())->createRoles();
|
|
}
|
|
|
|
/**
|
|
* The predicate every staff-side "register this person" path shares. It is
|
|
* deliberately the role and not `book_lesson`, so the two accounts that have
|
|
* that capability withheld — a guardian's child and an unapproved signup — are
|
|
* still people the studio can act for.
|
|
*/
|
|
public function testIsStudentAcceptsAnyHolderOfTheStudentRole(): void
|
|
{
|
|
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::STUDENT]));
|
|
|
|
self::assertTrue(RoleManager::isStudent(5));
|
|
}
|
|
|
|
public function testIsStudentRejectsSomeoneWhoIsNotAStudent(): void
|
|
{
|
|
Functions\when('get_userdata')->justReturn($this->userWithRoles([RoleManager::INSTRUCTOR]));
|
|
|
|
self::assertFalse(RoleManager::isStudent(5));
|
|
}
|
|
|
|
public function testIsStudentRejectsAnAccountThatNoLongerExists(): void
|
|
{
|
|
Functions\when('get_userdata')->justReturn(false);
|
|
|
|
self::assertFalse(RoleManager::isStudent(5));
|
|
}
|
|
|
|
public function testIsStudentRejectsNoOneChosenWithoutLookingAnyoneUp(): void
|
|
{
|
|
Functions\expect('get_userdata')->never();
|
|
|
|
self::assertFalse(RoleManager::isStudent(0));
|
|
}
|
|
|
|
/** @param list<string> $roles */
|
|
private function userWithRoles(array $roles): \WP_User
|
|
{
|
|
$user = \Mockery::mock(\WP_User::class);
|
|
$user->ID = 5;
|
|
$user->roles = $roles;
|
|
|
|
return $user;
|
|
}
|
|
}
|