Add limited backoffice employee QR login links

This commit is contained in:
Jeppe Bundgaard
2026-07-06 17:32:58 +02:00
parent 8544ce0a18
commit 7ac5c5585b
4 changed files with 265 additions and 0 deletions
@@ -800,6 +800,178 @@ it('caps limited employee permissions to the manager permissions and selected de
->not->toContain(limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES);
});
it('generates reusable QR login links for active scoped employees', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'happy');
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Department']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited QR Employee',
'email' => 'limited-qr@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$created
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$response = api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-link',
[],
$session['headers']
);
$response
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$loginPath = (string)($response->data()['login_path'] ?? '');
expect($response->data()['employee_id'] ?? null)->toBe($employeeId);
expect($loginPath)->toMatch('/^\/login\/qr\?token=[a-f0-9]{64}$/');
parse_str((string)parse_url($loginPath, PHP_URL_QUERY), $query);
$token = (string)($query['token'] ?? '');
expect($token)->toMatch('/^[a-f0-9]{64}$/');
$tokenRow = api_test_runtime()->queryOne(
"SELECT `user_id`, `type` FROM `tokens` WHERE `token` = '" .
api_test_runtime()->db()->real_escape_string($token) .
"' LIMIT 1"
);
expect($tokenRow)->not->toBeNull();
expect((int)($tokenRow['user_id'] ?? 0))->toBe($employeeId);
expect($tokenRow['type'] ?? null)->toBe('AUTH_TOKEN');
$list = api_client()->get('/limited-backoffice/employees', $session['headers']);
$list
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
expect($list->body)->not->toContain($token);
expect($list->body)->not->toContain('login_path');
});
it('rejects invalid limited backoffice employee QR login link generation', function (): void {
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'auth');
api_test_covers('POST /limited-backoffice/employees/{employeeId}/login-link', 'validation');
$department = api_fixtures()->createDepartment(['name' => 'Limited Login Link Own']);
$otherDepartment = api_fixtures()->createDepartment(['name' => 'Limited Login Link Other']);
$session = limited_backoffice_manager_session([(int)$department['id']]);
$otherSession = limited_backoffice_manager_session([(int)$otherDepartment['id']]);
$created = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Link Target',
'email' => 'limited-link-target@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$department['id']],
], $session['headers']);
$employeeId = (int)($created->data()['id'] ?? 0);
expect($employeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($employeeId);
$withoutManageEmployees = api_fixtures()->createUserSession([
limited_backoffice_service::PERMISSION_ACCESS,
'department_access_' . (int)$department['id'],
]);
api_client()->post(
'/limited-backoffice/employees/' . $employeeId . '/login-link',
[],
$withoutManageEmployees['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions([limited_backoffice_service::PERMISSION_MANAGE_EMPLOYEES]);
api_client()->post(
'/limited-backoffice/employees/' . (int)$session['user']['id'] . '/login-link',
[],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managers cannot edit themselves.');
api_client()->post('/limited-backoffice/employees/999999999/login-link', [], $session['headers'])
->assertStatus(404)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Managed employee not found.');
$otherCreated = api_client()->post('/limited-backoffice/employees', [
'display_name' => 'Limited Other Department Target',
'email' => 'limited-other-target@example.test',
'password' => 'Secret123!',
'role_key' => 'viewer',
'department_ids' => [(int)$otherDepartment['id']],
], $otherSession['headers']);
$otherEmployeeId = (int)($otherCreated->data()['id'] ?? 0);
expect($otherEmployeeId)->toBeGreaterThan(0);
limited_backoffice_cleanup_created_employee($otherEmployeeId);
api_client()->post(
'/limited-backoffice/employees/' . $otherEmployeeId . '/login-link',
[],
$session['headers']
)
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['department_access_' . (int)$otherDepartment['id']]);
api_client()->delete('/limited-backoffice/employees/' . $employeeId, null, $session['headers'])
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
api_client()->post('/limited-backoffice/employees/' . $employeeId . '/login-link', [], $session['headers'])
->assertStatus(409)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot create a login link for an inactive employee.');
$superuser = api_fixtures()->createUser(['group_id' => 1]);
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$superuser['id'] . ", 1, 'department_admin', '[" . (int)$department['id'] . "]', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$superuser['id']]);
api_client()->post('/limited-backoffice/employees/' . (int)$superuser['id'] . '/login-link', [], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage superuser accounts.');
$sharedGroup = api_fixtures()->createGroup();
$firstSharedUser = api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
api_fixtures()->createUser(['group_id' => $sharedGroup['id']]);
$departmentJson = '[' . (int)$department['id'] . ']';
api_test_runtime()->db()->query(
'INSERT INTO `limited_backoffice_employees`
(`user_id`, `managed_group_id`, `role_key`, `department_ids`, `created_by_user_id`)
VALUES (' . (int)$firstSharedUser['id'] . ', ' . (int)$sharedGroup['id'] . ", 'viewer', '" . $departmentJson . "', " . (int)$session['user']['id'] . ')'
);
api_fixtures()->cleanupDeleteWhere('limited_backoffice_employees', ['user_id' => (int)$firstSharedUser['id']]);
api_client()->post('/limited-backoffice/employees/' . (int)$firstSharedUser['id'] . '/login-link', [], $session['headers'])
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMessage('Cannot manage shared groups.');
});
it('includes limited employees in the regular employee list and protects raw user edits', function (): void {
api_test_covers('GET /users', 'limited backoffice employee list');
api_test_covers('PUT /users', 'limited backoffice guard');