Release 2.8.0
This commit is contained in:
@@ -60,8 +60,8 @@ test('save action buttons can hide the secondary dropdown and delete control', (
|
||||
test('save action buttons keep the new action wired for create pages', () => {
|
||||
const html = compileActionButtons({
|
||||
formId: 'example-form',
|
||||
saveUrl: '/users',
|
||||
cancelUrl: '/users',
|
||||
saveUrl: '/settings/users',
|
||||
cancelUrl: '/settings/users',
|
||||
showSaveAndClose: true,
|
||||
showSaveAndNew: true,
|
||||
showDelete: true
|
||||
|
||||
@@ -71,7 +71,7 @@ test('user create route allows users without roles', async () => {
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/users'];
|
||||
const routeHandlers = handlers['/settings/users'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const req = {
|
||||
@@ -99,7 +99,7 @@ test('user create route allows users without roles', async () => {
|
||||
|
||||
assert.ok(insertedArgs);
|
||||
assert.deepEqual(syncedRoles, { userId: 11, roleIds: [] });
|
||||
assert.equal(res.redirectedTo, '/users/11/edit?message=User%20created.');
|
||||
assert.equal(res.redirectedTo, '/settings/users/11/edit?message=User%20created.');
|
||||
});
|
||||
|
||||
test('user create save and new goes to the blank create page', async () => {
|
||||
@@ -164,7 +164,7 @@ test('user create save and new goes to the blank create page', async () => {
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/users'];
|
||||
const routeHandlers = handlers['/settings/users'];
|
||||
const req = {
|
||||
body: {
|
||||
name: 'No Roles User',
|
||||
@@ -189,5 +189,5 @@ test('user create save and new goes to the blank create page', async () => {
|
||||
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
|
||||
assert.equal(res.redirectedTo, '/users/new?message=User%20created.');
|
||||
assert.equal(res.redirectedTo, '/settings/users/new?message=User%20created.');
|
||||
});
|
||||
@@ -27,7 +27,7 @@ test('user edit save and new goes to the blank create page', async () => {
|
||||
return {
|
||||
async beginTransaction() {},
|
||||
async query(sql) {
|
||||
if (sql.includes('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?')) {
|
||||
if (sql.includes('UPDATE a_users SET name = ?, username = ?, account_locked = ?, modified_by = ? WHERE id = ?')) {
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
@@ -65,7 +65,7 @@ test('user edit save and new goes to the blank create page', async () => {
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/users/:id/username'];
|
||||
const routeHandlers = handlers['/settings/users/:id/username'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const req = {
|
||||
@@ -91,5 +91,5 @@ test('user edit save and new goes to the blank create page', async () => {
|
||||
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
|
||||
assert.equal(res.redirectedTo, '/users/new?message=User%20updated.');
|
||||
assert.equal(res.redirectedTo, '/settings/users/new?message=User%20updated.');
|
||||
});
|
||||
@@ -52,7 +52,7 @@ test('user password reset redirects back to the selected user edit page', async
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/users/:id/password'];
|
||||
const routeHandlers = handlers['/settings/users/:id/password'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const req = {
|
||||
@@ -74,5 +74,5 @@ test('user password reset redirects back to the selected user edit page', async
|
||||
|
||||
await routeHandlers[1](req, res, () => {});
|
||||
|
||||
assert.equal(res.redirectedTo, '/users/7/edit?message=Password%20updated.');
|
||||
assert.equal(res.redirectedTo, '/settings/users/7/edit?message=Password%20updated.');
|
||||
});
|
||||
@@ -18,6 +18,7 @@ Module._resolveFilename = function (request, parent, isMain, options) {
|
||||
const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('../src/web/routes/signage/announcements/duplicate');
|
||||
const { buildAnnouncementFormViewModel } = require('../src/web/routes/signage/announcements/form-view-model');
|
||||
const renderAnnouncementsPage = require('../src/web/routes/signage/announcements/list');
|
||||
const announcementIcons = require('../src/data/announcement-icons');
|
||||
|
||||
test('announcement duplicate helper copies editable fields and resets runtime state', () => {
|
||||
const source = {
|
||||
@@ -98,6 +99,23 @@ test('announcement form template uses an icon-backed type picker', () => {
|
||||
assert.match(template, /data-type-icon="\{\{icon\}\}"/);
|
||||
});
|
||||
|
||||
test('announcement form template includes a searchable bootstrap icon catalog', () => {
|
||||
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'announcements', 'form.hbs'), 'utf8');
|
||||
|
||||
assert.match(template, /data-announcement-icon-search/);
|
||||
assert.match(template, /announcementIconCatalog/);
|
||||
assert.match(template, /data-announcement-icon-grid/);
|
||||
assert.ok(!/data-announcement-icon-search-section/.test(template));
|
||||
});
|
||||
|
||||
test('announcement icons accept catalog-only bootstrap icon keys', () => {
|
||||
assert.equal(announcementIcons.normalizeAnnouncementIcon('bank'), 'bank');
|
||||
assert.ok(announcementIcons.ANNOUNCEMENT_ICON_CATALOG.some(function (icon) {
|
||||
return icon.value === 'bank';
|
||||
}));
|
||||
assert.equal(announcementIcons.ANNOUNCEMENT_ICON_LABELS.bank, 'Bank');
|
||||
});
|
||||
|
||||
test('announcement form template uses a checkbox card grid for screen groups', () => {
|
||||
const template = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'announcements', 'form.hbs'), 'utf8');
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
getDefaultAppSettings,
|
||||
normalizeSettingValue,
|
||||
normalizeAppSettings,
|
||||
fetchAppSettings,
|
||||
saveAppSettings
|
||||
} = require('../src/data/app-settings');
|
||||
|
||||
test('application settings expose typed defaults', () => {
|
||||
const settings = getDefaultAppSettings();
|
||||
|
||||
assert.equal(settings['app.name'], 'Pulse Signage');
|
||||
assert.equal(settings['uploads.image_max_bytes'], 100 * 1024 * 1024);
|
||||
assert.deepEqual(settings['uploads.allowed_mime_types'], ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'video/mp4', 'video/webm', 'video/ogg']);
|
||||
assert.equal(settings['uploads.optimize_images'], true);
|
||||
assert.equal(settings['security.session_lifetime_days'], 14);
|
||||
assert.equal(settings['security.allow_user_session_revocation'], true);
|
||||
assert.equal(settings['security.max_active_sessions'], 0);
|
||||
assert.equal(settings['security.password_min_length'], 10);
|
||||
assert.equal(settings['security.password_min_categories'], 3);
|
||||
assert.equal(settings['security.require_password_change_for_new_users'], true);
|
||||
assert.equal(settings['security.require_password_change_after_admin_reset'], true);
|
||||
assert.equal(settings['security.login_max_attempts'], 5);
|
||||
assert.equal(settings['security.login_lockout_minutes'], 15);
|
||||
assert.equal(settings['security.login_rate_limit_scope'], 'both');
|
||||
assert.equal(settings['data-sources.rss_default_interval_value'], 60);
|
||||
assert.equal(settings['data-sources.rss_default_interval_unit'], 'minutes');
|
||||
assert.equal(settings['data-sources.api_default_interval_value'], 60);
|
||||
assert.equal(settings['data-sources.api_default_interval_unit'], 'minutes');
|
||||
});
|
||||
|
||||
test('application settings normalize supported values and reject invalid values', () => {
|
||||
assert.equal(normalizeSettingValue('player.default_fade_between_slides', 'false'), false);
|
||||
assert.equal(normalizeSettingValue('uploads.optimize_images', 'false'), false);
|
||||
assert.deepEqual(normalizeSettingValue('uploads.allowed_mime_types', ['image/png', ' image/png', 'video/mp4']), ['image/png', 'video/mp4']);
|
||||
assert.throws(() => normalizeSettingValue('player.default_fade_between_slides', 'bounce'), /Invalid value/);
|
||||
assert.throws(() => normalizeSettingValue('uploads.image_max_bytes', 0), /Invalid value/);
|
||||
assert.throws(() => normalizeSettingValue('unknown.setting', 'value'), /Unknown application setting/);
|
||||
});
|
||||
|
||||
test('fetch app settings merges stored values with defaults', async () => {
|
||||
const pool = {
|
||||
async query() {
|
||||
return [[
|
||||
{ setting_key: 'uploads.image_max_bytes', setting_value: '2097152' },
|
||||
{ setting_key: 'uploads.optimize_images', setting_value: 'false' },
|
||||
{ setting_key: 'unknown.setting', setting_value: 'ignored' }
|
||||
]];
|
||||
}
|
||||
};
|
||||
|
||||
const settings = await fetchAppSettings(pool);
|
||||
assert.equal(settings['uploads.image_max_bytes'], 2097152);
|
||||
assert.equal(settings['uploads.optimize_images'], false);
|
||||
assert.equal(settings['player.default_fade_between_slides'], true);
|
||||
});
|
||||
|
||||
test('save app settings writes only supplied keys in one transaction', async () => {
|
||||
const queries = [];
|
||||
const connection = {
|
||||
async beginTransaction() { queries.push('begin'); },
|
||||
async query(sql, params) { queries.push({ sql, params }); },
|
||||
async commit() { queries.push('commit'); },
|
||||
async rollback() { queries.push('rollback'); },
|
||||
release() { queries.push('release'); }
|
||||
};
|
||||
const pool = {
|
||||
async getConnection() { return connection; }
|
||||
};
|
||||
|
||||
const saved = await saveAppSettings(pool, { 'player.default_fade_between_slides': false }, 7);
|
||||
assert.equal(saved['player.default_fade_between_slides'], false);
|
||||
assert.equal(queries[0], 'begin');
|
||||
assert.equal(queries[queries.length - 2], 'commit');
|
||||
assert.equal(queries[queries.length - 1], 'release');
|
||||
assert.equal(queries.filter(function (entry) { return entry && entry.sql; }).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerAuditLogRoutes = require('../src/web/routes/settings/audit-log');
|
||||
|
||||
test('audit log uses its own read permission and shared pagination partial', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
registerAuditLogRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (String(sql).includes('FROM o_audit_events')) {
|
||||
return [[
|
||||
{
|
||||
id: 1,
|
||||
occurred_at: '2026-08-16T12:00:00.000Z',
|
||||
category: 'authentication',
|
||||
event_type: 'login.success',
|
||||
target_label: 'newer',
|
||||
details_json: null
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
occurred_at: '2026-08-15T12:00:00.000Z',
|
||||
category: 'authentication',
|
||||
event_type: 'login.failed',
|
||||
target_label: 'older',
|
||||
details_json: null
|
||||
}
|
||||
]];
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
formatDashboardDate() {
|
||||
return 'Aug 16, 2026';
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(handlers['/settings/audit-log']);
|
||||
const request = {
|
||||
currentUser: { id: 7, permissions: ['audit-log.read', 'audit-log.allow'] },
|
||||
query: {}
|
||||
};
|
||||
let continued = false;
|
||||
handlers['/settings/audit-log'][0](request, {}, function () {
|
||||
continued = true;
|
||||
});
|
||||
assert.equal(continued, true);
|
||||
|
||||
const response = { send(value) { this.body = value; } };
|
||||
await handlers['/settings/audit-log'][1](request, response, function (error) {
|
||||
throw error;
|
||||
});
|
||||
assert.match(response.body, /Audit log/);
|
||||
assert.match(response.body, /table-pagination/);
|
||||
assert.match(response.body, /data-local-datetime/);
|
||||
assert.match(response.body, /audit-event-type-options/);
|
||||
assert.match(response.body, /login\.success/);
|
||||
assert.ok(response.body.indexOf('login.success') < response.body.indexOf('login.failed'));
|
||||
assert.match(response.body, />Export</);
|
||||
assert.ok(handlers['/settings/audit-log/export']);
|
||||
|
||||
const exportResponse = {
|
||||
headers: {},
|
||||
setHeader(name, value) { this.headers[name] = value; },
|
||||
send(value) { this.body = value; }
|
||||
};
|
||||
await handlers['/settings/audit-log/export'][1](request, exportResponse, function (error) {
|
||||
throw error;
|
||||
});
|
||||
assert.match(exportResponse.headers['Content-Type'], /text\/csv/);
|
||||
assert.match(exportResponse.body, /Occurred At \(UTC\)/);
|
||||
assert.match(exportResponse.body, /login.success/);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { recordRequestAuditEvent } = require('../src/data/audit-log');
|
||||
|
||||
function createPool(settings) {
|
||||
const inserts = [];
|
||||
return {
|
||||
inserts,
|
||||
async query(sql, params) {
|
||||
if (String(sql).includes('SELECT id, setting_key, setting_value FROM o_app_settings')) {
|
||||
return [[
|
||||
{ setting_key: 'audit.enabled', setting_value: JSON.stringify(settings.enabled) },
|
||||
{ setting_key: 'audit.categories', setting_value: JSON.stringify(settings.categories) },
|
||||
{ setting_key: 'audit.include_request_metadata', setting_value: JSON.stringify(settings.includeMetadata) }
|
||||
]];
|
||||
}
|
||||
if (String(sql).includes('INSERT INTO o_audit_events')) {
|
||||
inserts.push(params);
|
||||
}
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const request = {
|
||||
ip: '192.0.2.10',
|
||||
headers: {
|
||||
'user-agent': 'audit-test'
|
||||
}
|
||||
};
|
||||
|
||||
test('audit writer skips disabled categories', async () => {
|
||||
const pool = createPool({ enabled: true, categories: ['security'], includeMetadata: true });
|
||||
await recordRequestAuditEvent(pool, request, { category: 'authentication', eventType: 'login.success' });
|
||||
assert.equal(pool.inserts.length, 0);
|
||||
});
|
||||
|
||||
test('audit writer omits request metadata when disabled', async () => {
|
||||
const pool = createPool({ enabled: true, categories: ['authentication'], includeMetadata: false });
|
||||
await recordRequestAuditEvent(pool, request, { category: 'authentication', eventType: 'login.success' });
|
||||
assert.equal(pool.inserts.length, 1);
|
||||
assert.equal(pool.inserts[0][6], null);
|
||||
assert.equal(pool.inserts[0][7], null);
|
||||
});
|
||||
@@ -8,10 +8,12 @@ const renderLoginPage = require('../src/web/routes/auth/login');
|
||||
const { createSessionService, normalizeReturnToPath } = require('../src/web/lib/auth/session');
|
||||
|
||||
test('login page preserves the return target in a hidden field', () => {
|
||||
const html = renderLoginPage('', '/slides/6/edit');
|
||||
const html = renderLoginPage('', '/slides/6/edit', 'demo');
|
||||
|
||||
assert.match(html, /name="returnTo"/);
|
||||
assert.match(html, /value="\/slides\/6\/edit"/);
|
||||
assert.match(html, /name="remember_me"/);
|
||||
assert.match(html, /name="username"[^>]*value="demo"/);
|
||||
});
|
||||
|
||||
test('requireAuth preserves the original request path when redirecting to login', () => {
|
||||
@@ -80,8 +82,10 @@ test('successful login returns to the requested link instead of the dashboard',
|
||||
}
|
||||
},
|
||||
createUserSession: async () => 'session-token',
|
||||
setSessionCookie(res, token) {
|
||||
getSessionMaxAgeMs: async () => 7 * 24 * 60 * 60 * 1000,
|
||||
setSessionCookie(res, token, maxAgeMs) {
|
||||
res.sessionToken = token;
|
||||
res.sessionMaxAgeMs = maxAgeMs;
|
||||
},
|
||||
clearSessionCookie() {},
|
||||
parseCookies() {
|
||||
@@ -126,5 +130,31 @@ test('successful login returns to the requested link instead of the dashboard',
|
||||
await handlers['POST /login'][0](req, res, () => {});
|
||||
|
||||
assert.equal(res.sessionToken, 'session-token');
|
||||
assert.equal(res.sessionMaxAgeMs, null);
|
||||
assert.equal(res.redirectedTo, '/slides/6/edit?tab=content');
|
||||
|
||||
const rememberedRequest = {
|
||||
body: {
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
remember_me: '1'
|
||||
},
|
||||
query: {}
|
||||
};
|
||||
const rememberedResponse = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
},
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
send(value) {
|
||||
this.sentValue = value;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
await handlers['POST /login'][0](rememberedRequest, rememberedResponse, () => {});
|
||||
|
||||
assert.equal(rememberedResponse.sessionMaxAgeMs, 7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { registerExpiredSessionSweepTask } = require('../src/web/lib/background-tasks/tasks-scheduled/expired-session-sweep');
|
||||
|
||||
test('expired session sweep registers and deletes expired sessions', async () => {
|
||||
let definition = null;
|
||||
const queries = [];
|
||||
registerExpiredSessionSweepTask({
|
||||
backgroundTaskQueue: {
|
||||
registerRecurringTask(value) {
|
||||
definition = value;
|
||||
}
|
||||
},
|
||||
pool: {
|
||||
async query(sql) {
|
||||
queries.push(sql);
|
||||
return [{ affectedRows: 3 }];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(definition.key, 'expired-session-sweep');
|
||||
assert.equal(definition.intervalMs, 60 * 60 * 1000);
|
||||
await definition.run();
|
||||
assert.deepEqual(queries, ['DELETE FROM a_sessions WHERE expires_at <= NOW()']);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const registerNotFoundHandler = require('../src/web/middleware/not-found');
|
||||
|
||||
test('unmatched requests are forwarded as 404 errors', () => {
|
||||
let handler = null;
|
||||
registerNotFoundHandler({
|
||||
use(value) {
|
||||
handler = value;
|
||||
}
|
||||
});
|
||||
|
||||
let receivedError = null;
|
||||
handler({}, {}, function (error) {
|
||||
receivedError = error;
|
||||
});
|
||||
|
||||
assert.equal(receivedError.statusCode, 404);
|
||||
assert.equal(receivedError.message, 'Page not found.');
|
||||
});
|
||||
@@ -8,4 +8,23 @@ test('password strength requires a longer mixed-character password', () => {
|
||||
assert.equal(validatePasswordStrength('1234567890'), 'Password must be at least 10 characters and include 3 of: uppercase, lowercase, number, and symbol.');
|
||||
assert.equal(validatePasswordStrength('Password12'), '');
|
||||
assert.equal(validatePasswordStrength('NewPassword!1'), '');
|
||||
});
|
||||
|
||||
test('password strength supports strong and strict policies', () => {
|
||||
assert.equal(validatePasswordStrength('Password12', { policy: 'strong' }), 'Password must be at least 12 characters and include 3 of: uppercase, lowercase, number, and symbol.');
|
||||
assert.equal(validatePasswordStrength('Password12!!', { policy: 'strong' }), '');
|
||||
assert.equal(validatePasswordStrength('Password12!Abc', { policy: 'strict' }), '');
|
||||
assert.equal(validatePasswordStrength('Password12!Ab', { policy: 'strict' }), 'Password must be at least 14 characters and include uppercase, lowercase, number, and symbol.');
|
||||
});
|
||||
|
||||
test('password strength supports custom category requirements', () => {
|
||||
const requirements = {
|
||||
minimumLength: 8,
|
||||
minimumCategories: 2,
|
||||
requireUppercase: true,
|
||||
requireSymbol: true
|
||||
};
|
||||
|
||||
assert.equal(validatePasswordStrength('password1!', requirements), 'Password must be at least 8 characters and include uppercase, symbol.');
|
||||
assert.equal(validatePasswordStrength('Password!', requirements), '');
|
||||
});
|
||||
@@ -4,17 +4,23 @@ 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 } = 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');
|
||||
|
||||
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, /accordion accordion-flush/);
|
||||
assert.match(rbacFormTemplate, /card card-outline card-secondary overflow-hidden/);
|
||||
assert.match(rbacFormTemplate, /rbac-permissions-table-head/);
|
||||
assert.ok(!rbacFormTemplate.includes('table-light'));
|
||||
assert.match(rbacFormTemplate, /<h3 class="card-title">Permissions<\/h3>[\s\S]*<div class="card-body p-0">[\s\S]*accordion accordion-flush/);
|
||||
assert.match(rbacFormTemplate, /card card-outline card-secondary admin-form-card overflow-hidden[\s\S]*<h3 class="card-title">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.ok(!rbacFormTemplate.includes('data-bs-parent="#role-permissions-accordion"'));
|
||||
assert.match(rbacFormTemplate, /data-bs-parent="#role-permissions-accordion"/);
|
||||
assert.match(rbacFormTemplate, /table-layout: fixed; width: 100%;/);
|
||||
assert.match(rbacFormTemplate, /<col style="width: 5\.5rem;" \/>/);
|
||||
assert.match(rbacFormTemplate, /class="text-center"/);
|
||||
@@ -37,9 +43,12 @@ test('rbac form exposes bulk permission controls', () => {
|
||||
assert.ok(rbacFormViewModel.includes("label: 'Delete'"));
|
||||
assert.ok(rbacFormViewModel.includes("label: 'Allow'"));
|
||||
assert.ok(rbacFormViewModel.includes('left.resourceOrder'));
|
||||
assert.ok(rbacRoutesSource.includes('resourceOrder: Number(permission.resourceOrder) || 999'));
|
||||
assert.ok(rbacFormViewModel.includes('section.actions = actionDefinitions.map'));
|
||||
assert.ok(rbacFormViewModel.includes('data-async-save-new-url="/rbac/new"'));
|
||||
assert.ok(rbacFormViewModel.includes('data-async-save-close-url="/rbac" data-async-save-new-url="/rbac/new"'));
|
||||
assert.ok(rbacFormViewModel.includes('data-async-save-new-url="/settings/roles/new"'));
|
||||
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"));
|
||||
@@ -50,15 +59,16 @@ test('rbac permissions script supports bulk permission selection', () => {
|
||||
assert.ok(rbacPermissionsScript.includes('function toggleRowCheckboxes(row)'));
|
||||
assert.ok(rbacPermissionsScript.includes('data-permission-row-toggle'));
|
||||
assert.ok(rbacPermissionsScript.includes("toggleRowCheckboxes(findPermissionRow(targetId));"));
|
||||
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 source order', () => {
|
||||
test('rbac accordion sections follow configured order', () => {
|
||||
const sections = buildPermissionSections([
|
||||
{
|
||||
categoryName: 'Settings',
|
||||
sectionIndex: 3,
|
||||
sectionIndex: 0,
|
||||
sectionOrder: 40,
|
||||
resourceIndex: 1,
|
||||
resourceOrder: 20,
|
||||
@@ -67,9 +77,9 @@ test('rbac accordion sections follow source order', () => {
|
||||
},
|
||||
{
|
||||
categoryName: 'Main navigation',
|
||||
sectionIndex: 0,
|
||||
sectionIndex: 3,
|
||||
sectionOrder: 10,
|
||||
resourceIndex: 1,
|
||||
resourceIndex: 0,
|
||||
resourceOrder: 20,
|
||||
title: 'Clients',
|
||||
permissions: []
|
||||
@@ -78,7 +88,7 @@ test('rbac accordion sections follow source order', () => {
|
||||
categoryName: 'Main navigation',
|
||||
sectionIndex: 0,
|
||||
sectionOrder: 10,
|
||||
resourceIndex: 0,
|
||||
resourceIndex: 1,
|
||||
resourceOrder: 10,
|
||||
title: 'Dashboard',
|
||||
permissions: []
|
||||
@@ -97,7 +107,7 @@ 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, /\/rbac\/:id\/duplicate/);
|
||||
assert.match(rbacRoutes, /\/settings\/roles\/:id\/duplicate/);
|
||||
assert.ok(rbacRoutes.includes("requirePermission('rbac.create')"));
|
||||
assert.ok(duplicateHelpers.includes('buildDuplicateRoleName'));
|
||||
assert.ok(duplicateHelpers.includes('buildDuplicateRole'));
|
||||
|
||||
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.21' });
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.8.0' });
|
||||
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
@@ -74,4 +74,6 @@ test('pending migrations are reported when an older schema still needs scripts',
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.6.17' });
|
||||
|
||||
assert.ok(pendingMigrations.length > 0);
|
||||
assert.equal(pendingMigrations.filter(function (migration) { return migration.version.indexOf('2.8.') === 0; }).length, 1);
|
||||
assert.equal(pendingMigrations.find(function (migration) { return migration.version.indexOf('2.8.') === 0; }).version, '2.8.0');
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerSettingsRoutes = require('../src/web/routes/settings/routes');
|
||||
const { ANNOUNCEMENT_ICON_CATALOG } = require('../src/data/announcement-icons');
|
||||
|
||||
test('settings route renders the overview', async () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
},
|
||||
post(path, ...routeHandlers) {
|
||||
handlers['POST ' + path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
registerSettingsRoutes(app, {
|
||||
pages: {
|
||||
renderSettingsPage(_settings, _icons, currentUser) {
|
||||
return currentUser && currentUser.id === 7 ? 'settings-page' : 'unexpected';
|
||||
}
|
||||
},
|
||||
pool: {
|
||||
async query(sql, params) {
|
||||
queries.push({ sql, params });
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
requirePermission(permissionKey) {
|
||||
assert.ok(['system-settings.read', 'system-settings.update'].includes(permissionKey));
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const pageResponse = {
|
||||
send(value) {
|
||||
this.body = value;
|
||||
}
|
||||
};
|
||||
await handlers['/settings/system'][1]({ currentUser: { id: 7 }, query: {} }, pageResponse, function (error) {
|
||||
throw error;
|
||||
});
|
||||
assert.equal(pageResponse.body, 'settings-page');
|
||||
|
||||
assert.equal(Object.keys(handlers).length, 2);
|
||||
assert.ok(handlers['/settings/system']);
|
||||
assert.ok(handlers['POST /settings/system']);
|
||||
|
||||
const saveResponse = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
}
|
||||
};
|
||||
await handlers['POST /settings/system'][1]({
|
||||
body: { 'suggested_icons[]': ['bell-fill'], image_max_mb: '100', video_max_mb: '1024' },
|
||||
body: { settings_section: 'icons', 'suggested_icons[]': ['bell-fill'] },
|
||||
currentUser: { id: 7 }
|
||||
}, saveResponse, function (error) {
|
||||
throw error;
|
||||
});
|
||||
assert.ok(queries.some(function (entry) { return entry.sql && /DELETE FROM o_app_settings/.test(entry.sql); }));
|
||||
assert.ok(queries.some(function (entry) { return entry.sql && /INSERT INTO o_app_settings/.test(entry.sql); }));
|
||||
assert.match(saveResponse.redirectedTo, /^\/settings\/system\?message=/);
|
||||
|
||||
let tooManyError = null;
|
||||
await handlers['POST /settings/system'][1]({
|
||||
body: { settings_section: 'icons', suggested_icons: ANNOUNCEMENT_ICON_CATALOG.slice(0, 49).map(function (option) { return option.value; }) },
|
||||
currentUser: { id: 7 }
|
||||
}, {}, function (error) {
|
||||
tooManyError = error;
|
||||
});
|
||||
assert.equal(tooManyError.statusCode, 400);
|
||||
assert.match(tooManyError.message, /48/);
|
||||
|
||||
const mediaResponse = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
}
|
||||
};
|
||||
await handlers['POST /settings/system'][1]({
|
||||
body: { settings_section: 'media', image_max_mb: '100', video_max_mb: '1024', wysiwyg_image_max_mb: '0.5', allowed_mime_types: ['image/png', 'video/mp4'] },
|
||||
currentUser: { id: 7 }
|
||||
}, mediaResponse, function (error) {
|
||||
throw error;
|
||||
});
|
||||
assert.match(mediaResponse.redirectedTo, /media-uploads/);
|
||||
|
||||
const securityResponse = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
}
|
||||
};
|
||||
await handlers['POST /settings/system'][1]({
|
||||
body: { settings_section: 'security', session_lifetime_days: '30', allow_user_session_revocation: '1', max_active_sessions: '5', login_max_attempts: '5', login_lockout_minutes: '15', login_rate_limit_scope: 'both', password_min_length: '12', password_min_categories: '3' },
|
||||
currentUser: { id: 7 }
|
||||
}, securityResponse, function (error) {
|
||||
throw error;
|
||||
});
|
||||
assert.match(securityResponse.redirectedTo, /security-sessions/);
|
||||
assert.ok(queries.some(function (entry) {
|
||||
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.session_lifetime_days';
|
||||
}));
|
||||
assert.ok(queries.some(function (entry) {
|
||||
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings') && entry.params && entry.params[0] === 'security.password_min_length';
|
||||
}));
|
||||
|
||||
const mediaInsertQueries = queries.filter(function (entry) {
|
||||
return entry.sql && entry.sql.includes('INSERT INTO o_app_settings');
|
||||
});
|
||||
const videoInsert = mediaInsertQueries.find(function (entry) {
|
||||
return entry.params && entry.params[0] === 'uploads.video_max_bytes';
|
||||
});
|
||||
assert.equal(Number(videoInsert.params[1]), 1024 * 1024 * 1024);
|
||||
});
|
||||
@@ -45,6 +45,12 @@ test('slide editor tracks uploaded image paths for cleanup', () => {
|
||||
assert.ok(slideFormEditorSource.includes('clearImageUploadPaths'));
|
||||
});
|
||||
|
||||
test('slide editor marks the owning form dirty after TinyMCE edits', () => {
|
||||
assert.ok(slideFormEditorSource.includes("var markFormDirty = typeof settings.markFormDirty === 'function' ? settings.markFormDirty : function () {};"));
|
||||
assert.match(slideFormEditorSource, /if \(hydrated\)\s*\{\s*markFormDirty\(\);/);
|
||||
assert.match(slideFormSource, /markFormDirty:\s*function \(\)\s*\{\s*slideForm\.dataset\.dirty = 'true';/);
|
||||
});
|
||||
|
||||
test('slide editor keeps image-only rich text from being treated as empty', () => {
|
||||
assert.ok(slideFormEditorSource.includes('/<img\\b/i.test(raw)'));
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ test('user duplicate route pre-fills the add form', async () => {
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/users/:id/duplicate'];
|
||||
const routeHandlers = handlers['/settings/users/:id/duplicate'];
|
||||
assert.equal(Array.isArray(routeHandlers), true);
|
||||
|
||||
const req = {
|
||||
|
||||
@@ -8,11 +8,11 @@ const usersFormTemplate = fs.readFileSync(require.resolve('../src/web/views/sett
|
||||
test('user edit view model includes delete confirmation text', () => {
|
||||
const model = buildUsersEditViewModel({ id: 12, inUse: false, roleIds: [] }, 'Saved.', { id: 1 }, []);
|
||||
|
||||
assert.equal(model.footerDeleteUrl, '/users/12/delete');
|
||||
assert.equal(model.footerDeleteUrl, '/settings/users/12/delete');
|
||||
assert.equal(model.footerDeleteConfirmMessage, 'Delete this user?');
|
||||
assert.equal(model.footerDeleteDisabled, false);
|
||||
assert.equal(model.showSaveSecondaryActions, true);
|
||||
assert.equal(model.formAttrs, 'data-async-save data-async-save-close-url="/users" data-async-save-new-url="/users/new"');
|
||||
assert.equal(model.formAttrs, 'data-async-save data-async-save-close-url="/settings/users" data-async-save-new-url="/settings/users/new"');
|
||||
});
|
||||
|
||||
test('user add view model keeps delete disabled without a confirm message', () => {
|
||||
@@ -21,7 +21,7 @@ test('user add view model keeps delete disabled without a confirm message', () =
|
||||
assert.equal(model.footerDeleteDisabled, true);
|
||||
assert.equal(model.footerDeleteUrl, '');
|
||||
assert.equal(model.footerDeleteConfirmMessage, undefined);
|
||||
assert.equal(model.formAttrs, 'data-async-save data-async-save-close-url="/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/users/new"');
|
||||
assert.equal(model.formAttrs, 'data-async-save data-async-save-close-url="/settings/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/settings/users/new"');
|
||||
});
|
||||
|
||||
test('user add view model uses success messaging for redirected saves', () => {
|
||||
|
||||
Reference in New Issue
Block a user