const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const rbacPermissionsScript = fs.readFileSync(require.resolve('../src/web/public/js/rbac/permissions.js'), 'utf8'); const rbacFormTemplate = fs.readFileSync(require.resolve('../src/web/views/settings/rbac/form.hbs'), 'utf8'); const rbacRoutesSource = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8'); const { buildPermissionSections, buildRbacEditViewModel } = require('../src/web/routes/settings/rbac/form-view-model'); const rbacFormViewModel = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/form-view-model.js'), 'utf8'); const rbacSource = fs.readFileSync(require.resolve('../src/rbac.js'), 'utf8'); for (const create of [true, false]) { for (const enabled of [true, false]) { test((create ? 'create' : 'update') + ' role persists two-factor requirement ' + enabled, async () => { require('../src/common'); const registerRbacRoutes = require('../src/web/routes/admin/rbac'); const handlers = {}; const writes = []; let auditEvent; const connection = { async beginTransaction() {}, async commit() {}, async rollback() {}, release() {}, async query(sql, params) { writes.push({ sql, params }); return [{ insertId: 9 }]; } }; registerRbacRoutes({ get() {}, post(path, ...routeHandlers) { handlers[path] = routeHandlers; } }, { pool: { async query() { return [[]]; }, async getConnection() { return connection; } }, common: { validateMaxLength: value => value, getSearchQuery: () => '', getSortQuery: () => '', getSortDirectionQuery: () => '', fetchDuplicateName: async () => null }, pages: {}, requirePermission: () => (_req, _res, next) => next(), getAuditUserId: () => 3, recordRequestAuditEvent: async (_pool, _req, event) => { auditEvent = event; }, readArrayField: () => [], normalizePermissionKeys: value => value, permissions: [], rbacData: { fetchRoleById: async () => ({ id: 9, name: 'Secure', description: '', require_two_factor: enabled ? 0 : 1 }), fetchPermissions: async () => [], fetchUsersWithRolesPage: async () => ({ users: [], currentPage: 1, totalItems: 0 }), fetchUsersWithRoles: async () => [], syncRoleUsers: async () => {} } }); const response = { redirect(url) { this.redirectedTo = url; } }; await handlers[create ? '/settings/roles' : '/settings/roles/:id'][1]({ params: { id: '9' }, query: {}, currentUser: { id: 3 }, body: { name: 'Secure', description: '', ...(enabled ? { require_two_factor: '1' } : {}) } }, response, error => { throw error; }); assert.equal(writes.length, 1); assert.match(writes[0].sql, /require_two_factor/); assert.equal(writes[0].params[create ? 3 : 2], enabled ? 1 : 0); assert.match(response.redirectedTo, /^\/settings\/roles\/9\/edit/); if (create) assert.equal(auditEvent.details.requireTwoFactor, enabled); else assert.deepEqual(auditEvent.details.changes.requireTwoFactor, { from: !enabled, to: enabled }); }); } } test('duplicating a role retains its two-factor requirement and the editor renders the toggle', () => { require('../src/common'); const { buildDuplicateRole } = require('../src/web/routes/settings/rbac/duplicate'); const pages = require('../src/web/pages'); for (const enabled of [true, false]) { const role = { id: 9, name: 'Secure', require_two_factor: enabled }; assert.equal(buildDuplicateRole(role).require_two_factor, enabled); const html = pages.renderRbacEditPage(role, '', null, [], [], null); const toggle = html.match(/]+>/)[0]; assert.equal(toggle.includes('checked'), enabled); } }); test('rbac form exposes bulk permission controls', () => { assert.match(rbacFormTemplate, /name="permissions_present" value="1"/); assert.match(rbacFormTemplate, /name="user_ids_present\[\]" value="\{\{id\}\}"/); assert.match(rbacFormTemplate, /rbac-permissions-table-head/); assert.ok(!rbacFormTemplate.includes('table-light')); assert.match(rbacFormTemplate, /

Permissions<\/h3>[\s\S]*
[\s\S]*accordion accordion-flush/); assert.match(rbacFormTemplate, /card card-outline card-secondary admin-form-card overflow-hidden[\s\S]*

Permissions<\/h3>/); assert.ok(!rbacFormTemplate.includes('card card-outline card-secondary overflow-hidden')); assert.match(rbacFormTemplate, /accordion-item overflow-hidden" data-permission-section/); assert.match(rbacFormTemplate, /accordion-body p-0/); assert.match(rbacFormTemplate, /data-bs-parent="#role-permissions-accordion"/); assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/); assert.match(rbacFormTemplate, //); assert.match(rbacFormTemplate, /class="text-center"/); assert.match(rbacFormTemplate, /data-permission-row-toggle/); assert.ok(rbacFormTemplate.includes('text-reset')); assert.ok(rbacFormTemplate.includes('color: inherit;')); assert.ok(rbacFormTemplate.includes('text-decoration-style: dotted')); assert.ok(rbacFormTemplate.includes('aria-label="Role actions"')); assert.ok(!rbacFormTemplate.includes('aria-label="Permission actions"')); assert.ok(!rbacFormTemplate.includes('showSaveAndClose=false')); assert.ok(!rbacFormTemplate.includes('showSaveAndNew=false')); assert.ok(!rbacFormTemplate.includes('Select none')); assert.ok(!rbacFormTemplate.includes('permission categories')); assert.ok(!rbacFormTemplate.includes('btn-link')); assert.ok(!rbacFormTemplate.includes('rounded-bottom-3 overflow-hidden')); assert.ok(rbacFormViewModel.includes("label: 'Read'")); assert.ok(rbacFormViewModel.includes("label: 'Create'")); assert.ok(rbacFormViewModel.includes("label: 'Update'")); assert.ok(rbacFormViewModel.includes("label: 'Delete'")); assert.ok(rbacFormViewModel.includes("label: 'Allow'")); assert.ok(rbacRoutesSource.includes('resourceOrder: Number(permission.resourceOrder) || 999')); assert.ok(rbacFormViewModel.includes('section.actions = actionDefinitions.map')); assert.ok(rbacFormViewModel.includes('data-async-save-close-url="/settings/roles" data-async-save-new-url="/settings/roles/new"')); assert.ok(rbacFormViewModel.includes('cancelConfirmMessage: "You\'ve made changes. Are you sure you want to leave this page?"')); assert.match(rbacFormTemplate, /cancelConfirmMessage=cancelConfirmMessage/); assert.ok(rbacSource.includes("sectionName: 'Main Navigation'")); assert.ok(rbacSource.includes("permissions: [")); assert.ok(rbacSource.includes("resourceOrder: resource.order")); }); test('rbac permissions script supports bulk permission selection', () => { assert.ok(rbacPermissionsScript.includes('function setRowCheckboxes(row, checked)')); assert.ok(rbacPermissionsScript.includes('function toggleRowCheckboxes(row)')); assert.ok(rbacPermissionsScript.includes('data-permission-row-toggle')); assert.match(rbacPermissionsScript, /toggleRowCheckboxes\(findPermissionRow\(targetId\)\);\s*markPermissionFormDirty\(control\);/); assert.ok(!rbacPermissionsScript.includes('data-permission-select-none')); assert.ok(!rbacPermissionsScript.includes('data-permission-section-select-none')); }); test('rbac accordion sections follow configured order', () => { const sections = buildPermissionSections([ { categoryName: 'Settings', sectionIndex: 0, sectionOrder: 40, resourceIndex: 1, resourceOrder: 20, title: 'Later resource', permissions: [] }, { categoryName: 'Main navigation', sectionIndex: 3, sectionOrder: 10, resourceIndex: 0, resourceOrder: 20, title: 'Clients', permissions: [] }, { categoryName: 'Main navigation', sectionIndex: 0, sectionOrder: 10, resourceIndex: 1, resourceOrder: 10, title: 'Dashboard', permissions: [] } ]); assert.deepEqual(sections.map(function (section) { return section.title; }), ['Main navigation', 'Settings']); assert.deepEqual(sections[0].groups.map(function (group) { return group.title; }), ['Dashboard', 'Clients']); }); test('rbac duplicate route exists', () => { const rbacRoutes = fs.readFileSync(require.resolve('../src/web/routes/admin/rbac.js'), 'utf8'); const duplicateHelpers = fs.readFileSync(require.resolve('../src/web/routes/settings/rbac/duplicate.js'), 'utf8'); assert.match(rbacRoutes, /\/settings\/roles\/:id\/duplicate/); assert.ok(rbacRoutes.includes("requirePermission('rbac.create')")); assert.ok(duplicateHelpers.includes('buildDuplicateRoleName')); assert.ok(duplicateHelpers.includes('buildDuplicateRole')); }); test('built-in Super Admin role cannot be deleted but can be renamed', () => { const model = buildRbacEditViewModel({ id: 1, role_key: 'super-admin', name: 'Super Admin', inUse: false }, '', null, [], [], null); assert.equal(model.footerDeleteDisabled, true); assert.equal(model.role.isProtected, true); assert.equal(model.footerDeleteTitle, 'The built-in Super Admin role cannot be deleted.'); assert.equal(model.footerDeleteConfirmMessage, 'Delete Super Admin?'); assert.ok(!rbacFormTemplate.includes('The built-in role name cannot be changed.')); assert.ok(!rbacFormTemplate.includes('The built-in role description cannot be changed.')); });