Release v2.11.1
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const registerUsersRoutes = require('../src/web/routes/admin/users');
|
||||
|
||||
test('pending invitations route loads active invitations with display fields', async () => {
|
||||
const handlers = {};
|
||||
let invitationQuery = '';
|
||||
const rendered = {};
|
||||
const createdAt = new Date('2026-09-03T10:00:00Z');
|
||||
const expiresAt = new Date('2026-09-04T10:00:00Z');
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
},
|
||||
post() {}
|
||||
};
|
||||
const requiredPermissions = [];
|
||||
|
||||
const deps = {
|
||||
pool: { async query() { return [[]]; } },
|
||||
common: {
|
||||
getSearchQuery: () => '',
|
||||
getSortQuery: () => '',
|
||||
getSortDirectionQuery: () => 'asc'
|
||||
},
|
||||
pages: {
|
||||
renderInvitationsPage(data, currentUser) {
|
||||
rendered.invitations = data.invitations;
|
||||
rendered.pagination = data.pagination;
|
||||
rendered.currentUser = currentUser;
|
||||
return '';
|
||||
}
|
||||
},
|
||||
formatDashboardDate(value) {
|
||||
return value === createdAt ? 'sent label' : 'expiry label';
|
||||
},
|
||||
rbacData: {
|
||||
fetchRoles: async () => [{ id: 2, name: 'Viewer' }],
|
||||
fetchInvitationsPage: async (_pool, page, pageSize, search, sort, direction) => {
|
||||
invitationQuery = { page, pageSize, search, sort, direction };
|
||||
return {
|
||||
invitations: [{
|
||||
id: 7,
|
||||
email: 'person@example.com',
|
||||
name: 'Person',
|
||||
role_ids_json: '[2]',
|
||||
created_at: createdAt,
|
||||
expires_at: expiresAt,
|
||||
created_by_username: 'admin'
|
||||
}],
|
||||
totalItems: 26,
|
||||
currentPage: 2
|
||||
};
|
||||
}
|
||||
},
|
||||
requirePermission(permission) {
|
||||
requiredPermissions.push(permission);
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
|
||||
const routeHandlers = handlers['/settings/invitations'];
|
||||
assert.ok(requiredPermissions.includes('invitations.read'));
|
||||
const currentUser = { id: 1 };
|
||||
const res = {
|
||||
send(value) {
|
||||
this.body = value;
|
||||
}
|
||||
};
|
||||
|
||||
await routeHandlers[1]({ currentUser, query: {} }, res, function (error) {
|
||||
throw error;
|
||||
});
|
||||
|
||||
assert.deepEqual(invitationQuery, { page: 1, pageSize: 25, search: '', sort: '', direction: 'asc' });
|
||||
assert.equal(rendered.currentUser, currentUser);
|
||||
assert.equal(rendered.pagination.totalItems, 26);
|
||||
assert.deepEqual(rendered.invitations, [{
|
||||
id: 7,
|
||||
email: 'person@example.com',
|
||||
name: 'Person',
|
||||
role_ids_json: '[2]',
|
||||
created_at: createdAt,
|
||||
expires_at: expiresAt,
|
||||
created_by_username: 'admin',
|
||||
roleNames: 'Viewer',
|
||||
createdAtLabel: 'sent label',
|
||||
expiresAtLabel: 'expiry label',
|
||||
createdByLabel: 'admin'
|
||||
}]);
|
||||
});
|
||||
|
||||
test('user invite route stores the invitation and sends an acceptance link', async () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
let sentEmail = null;
|
||||
const app = {
|
||||
get() {},
|
||||
post(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
const deps = {
|
||||
pool: {
|
||||
async query(sql, args) {
|
||||
queries.push({ sql, args });
|
||||
if (sql.includes('o_app_settings')) return [[{ setting_key: 'email.smtp_enabled', setting_value: 'true' }]];
|
||||
if (sql.includes('SELECT id FROM a_users WHERE email')) return [[]];
|
||||
if (sql.includes('INSERT INTO a_user_invitations')) return [{ insertId: 4 }];
|
||||
if (sql.includes('COUNT(*) AS invite_count')) return [[{ invite_count: 0 }]];
|
||||
return [[]];
|
||||
}
|
||||
},
|
||||
common: {
|
||||
validateMaxLength(value) { return String(value || '').trim(); }
|
||||
},
|
||||
pages: {
|
||||
renderUsersInvitePage() { return ''; }
|
||||
},
|
||||
formatDashboardDate: () => '',
|
||||
getAuditUserId: () => 9,
|
||||
sendAccountEmail: async (_settings, message) => { sentEmail = message; },
|
||||
createOneTimeToken: () => 'invite-token',
|
||||
hashSessionToken: (value) => 'hash:' + value,
|
||||
getRequestOrigin: () => 'https://signage.example.com',
|
||||
readArrayField: () => ['2'],
|
||||
rbacData: {
|
||||
fetchRoles: async () => [{ id: 2, name: 'Viewer' }]
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) { next(); };
|
||||
}
|
||||
};
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
|
||||
const res = {
|
||||
redirect(url) { this.redirectedTo = url; },
|
||||
status() { return this; },
|
||||
send() { return this; }
|
||||
};
|
||||
let routeError = null;
|
||||
await handlers['/settings/users/invite'][1]({
|
||||
body: { email: ' Person@Example.COM ', name: 'Person' },
|
||||
currentUser: { id: 9 }
|
||||
}, res, (error) => { routeError = error; });
|
||||
|
||||
assert.equal(routeError, null);
|
||||
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_user_invitations'));
|
||||
assert.deepEqual(insert.args, ['person@example.com', 'Person', '[2]', 'hash:invite-token', 9]);
|
||||
assert.equal(sentEmail.to, 'person@example.com');
|
||||
assert.match(sentEmail.html, /accept-invite\?token=invite-token/);
|
||||
assert.equal(res.redirectedTo, '/settings/users?message=Invitation%20sent.');
|
||||
});
|
||||
|
||||
test('user invite route rejects invalid email before sending', async () => {
|
||||
const handlers = {};
|
||||
const app = {
|
||||
get() {},
|
||||
post(path, ...routeHandlers) { handlers[path] = routeHandlers; }
|
||||
};
|
||||
let renderedMessage = '';
|
||||
const deps = {
|
||||
pool: { async query() { return [[]]; } },
|
||||
common: {
|
||||
validateMaxLength(value) { return String(value || '').trim(); }
|
||||
},
|
||||
pages: {
|
||||
renderUsersInvitePage(message) { renderedMessage = message; return ''; }
|
||||
},
|
||||
formatDashboardDate: () => '',
|
||||
getAuditUserId: () => 1,
|
||||
readArrayField: () => [],
|
||||
rbacData: { fetchRoles: async () => [] },
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) { next(); };
|
||||
}
|
||||
};
|
||||
|
||||
registerUsersRoutes(app, deps);
|
||||
const res = { status() { return this; }, send() {} };
|
||||
await handlers['/settings/users/invite'][1]({ body: { email: 'invalid', name: 'Person' }, currentUser: { id: 1 } }, res, () => {});
|
||||
|
||||
assert.equal(renderedMessage, 'Email address is invalid.');
|
||||
});
|
||||
@@ -23,6 +23,9 @@ test('API source payload accepts POST request and token-login settings', () => {
|
||||
token_url: 'https://example.com/login',
|
||||
token_request_body_json: '{"username":"demo","password":"secret"}',
|
||||
token_response_path: 'data.accessToken',
|
||||
token_refresh_url: 'https://example.com/refresh',
|
||||
token_refresh_request_body_json: '{"grant_type":"refresh_token","refresh_token":"{{refresh_token}}"}',
|
||||
token_refresh_response_path: 'data.refreshToken',
|
||||
token_header_name: 'Authorization',
|
||||
token_header_prefix: 'Bearer',
|
||||
update_interval_value: '5',
|
||||
@@ -35,6 +38,8 @@ test('API source payload accepts POST request and token-login settings', () => {
|
||||
assert.equal(payload.authMethod, 'token_login');
|
||||
assert.equal(payload.tokenUrl, 'https://example.com/login');
|
||||
assert.equal(payload.tokenResponsePath, 'data.accessToken');
|
||||
assert.equal(payload.tokenRefreshUrl, 'https://example.com/refresh');
|
||||
assert.equal(payload.tokenRefreshResponsePath, 'data.refreshToken');
|
||||
});
|
||||
|
||||
test('API source sends login POST, uses token, and refreshes once after 401', async () => {
|
||||
@@ -81,3 +86,52 @@ test('API source sends login POST, uses token, and refreshes once after 401', as
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('API source reuses an unexpired token and refreshes it after expiry', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalDateNow = Date.now;
|
||||
const calls = [];
|
||||
let currentTime = 1000000;
|
||||
Date.now = () => currentTime;
|
||||
global.fetch = async function (url, options) {
|
||||
calls.push({ url, options });
|
||||
if (url === 'https://example.com/login') {
|
||||
return jsonResponse(200, { access_token: 'initial-token', refresh_token: 'initial-refresh', expires_in: 120 });
|
||||
}
|
||||
if (url === 'https://example.com/refresh') {
|
||||
return jsonResponse(200, { access_token: 'refreshed-token', refresh_token: 'rotated-refresh', expires_in: 300 });
|
||||
}
|
||||
return jsonResponse(200, { items: [] });
|
||||
};
|
||||
|
||||
const source = {
|
||||
id: 987655,
|
||||
api_url: 'https://example.com/report',
|
||||
auth_method: 'token_login',
|
||||
token_url: 'https://example.com/login',
|
||||
token_request_body_json: JSON.stringify({ username: 'demo', password: 'secret' }),
|
||||
token_refresh_url: 'https://example.com/refresh',
|
||||
token_refresh_request_body_json: JSON.stringify({ grant_type: 'refresh_token', refresh_token: '{{refresh_token}}' })
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchApiSourceResponse(source);
|
||||
await fetchApiSourceResponse(source);
|
||||
assert.equal(calls.filter(call => call.url === 'https://example.com/login').length, 1);
|
||||
assert.equal(calls.filter(call => call.url === 'https://example.com/report').length, 2);
|
||||
|
||||
currentTime += 120000;
|
||||
await fetchApiSourceResponse(source);
|
||||
assert.equal(calls.filter(call => call.url === 'https://example.com/refresh').length, 1);
|
||||
assert.deepEqual(JSON.parse(calls[3].options.body), { grant_type: 'refresh_token', refresh_token: 'initial-refresh' });
|
||||
assert.equal(calls[4].options.headers.Authorization, 'Bearer refreshed-token');
|
||||
|
||||
currentTime += 300000;
|
||||
await fetchApiSourceResponse(source);
|
||||
assert.equal(calls.filter(call => call.url === 'https://example.com/refresh').length, 2);
|
||||
assert.deepEqual(JSON.parse(calls[5].options.body), { grant_type: 'refresh_token', refresh_token: 'rotated-refresh' });
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,8 @@ const assert = require('node:assert/strict');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
const { buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
|
||||
const { AUDIT_CATEGORY_KEYS, AUDIT_CATEGORY_LABELS, buildAuditChanges, recordRequestAuditEvent } = require('../src/data/audit-log');
|
||||
const { getDefaultAppSettings } = require('../src/data/app-settings');
|
||||
|
||||
function createPool(settings) {
|
||||
const inserts = [];
|
||||
@@ -14,6 +15,7 @@ function createPool(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.screen_control_commands', setting_value: JSON.stringify(settings.screenControlCommands || []) },
|
||||
{ setting_key: 'audit.include_request_metadata', setting_value: JSON.stringify(settings.includeMetadata) }
|
||||
]];
|
||||
}
|
||||
@@ -32,6 +34,24 @@ const request = {
|
||||
}
|
||||
};
|
||||
|
||||
test('audit categories include weather data sources', () => {
|
||||
assert.ok(AUDIT_CATEGORY_KEYS.includes('weather'));
|
||||
assert.equal(AUDIT_CATEGORY_LABELS.weather, 'Weather');
|
||||
});
|
||||
|
||||
test('screen control auditing is disabled by default', () => {
|
||||
assert.equal(getDefaultAppSettings()['audit.categories'].includes('screen-controls'), false);
|
||||
assert.deepEqual(getDefaultAppSettings()['audit.screen_control_commands'], []);
|
||||
});
|
||||
|
||||
test('screen control auditing filters commands and groups navigation', async () => {
|
||||
const pool = createPool({ enabled: true, categories: ['screen-controls'], screenControlCommands: ['navigation'], includeMetadata: false });
|
||||
await recordRequestAuditEvent(pool, request, { category: 'screen-controls', eventType: 'screen-control.previous' });
|
||||
await recordRequestAuditEvent(pool, request, { category: 'screen-controls', eventType: 'screen-control.pause' });
|
||||
assert.equal(pool.inserts.length, 1);
|
||||
assert.equal(pool.inserts[0][1], 'screen-control.previous');
|
||||
});
|
||||
|
||||
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' });
|
||||
|
||||
@@ -157,4 +157,128 @@ test('successful login returns to the requested link instead of the dashboard',
|
||||
await handlers['POST /login'][0](rememberedRequest, rememberedResponse, () => {});
|
||||
|
||||
assert.equal(rememberedResponse.sessionMaxAgeMs, 7 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('accept invite page renders a valid invitation with password requirements', async () => {
|
||||
const handlers = {};
|
||||
let rendered = null;
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
};
|
||||
registerAuthRoutes(app, {
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (sql.includes('a_user_invitations')) return [[{ email: 'person@example.com', name: 'Person' }]];
|
||||
return [[{ setting_key: 'security.password_min_length', setting_value: '12' }]];
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
renderAcceptInvitePage(...args) { rendered = args; return 'invite form'; }
|
||||
},
|
||||
hashSessionToken: (value) => 'hash:' + value
|
||||
});
|
||||
|
||||
const res = { send(value) { this.body = value; }, status() { return this; } };
|
||||
await handlers['GET /accept-invite'][0]({ query: { token: 'invite-token' } }, res, (error) => { throw error; });
|
||||
|
||||
assert.equal(res.body, 'invite form');
|
||||
assert.equal(rendered[0], '');
|
||||
assert.equal(rendered[1], 'invite-token');
|
||||
assert.equal(rendered[2], 'person@example.com');
|
||||
assert.equal(rendered[3], 'Person');
|
||||
assert.equal(rendered[4].minimumLength, 12);
|
||||
assert.equal(rendered[5], true);
|
||||
});
|
||||
|
||||
test('accept invite creates the account, assigns roles, and consumes the invitation', async () => {
|
||||
const handlers = {};
|
||||
const queries = [];
|
||||
let committed = false;
|
||||
let syncedRoles = null;
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
};
|
||||
const connection = {
|
||||
async query(sql, args) {
|
||||
queries.push({ sql, args });
|
||||
if (sql.includes('a_user_invitations')) return [[{ id: 8, email: 'person@example.com', name: 'Invited name', role_ids_json: '[2, 3]' }]];
|
||||
if (sql.includes('SELECT id FROM a_users')) return [[]];
|
||||
if (sql.includes('INSERT INTO a_users')) return [{ insertId: 21 }];
|
||||
return [[]];
|
||||
},
|
||||
async beginTransaction() {},
|
||||
async commit() { committed = true; },
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
registerAuthRoutes(app, {
|
||||
pool: {
|
||||
async query() { return [[]]; },
|
||||
async getConnection() { return connection; }
|
||||
},
|
||||
pages: {
|
||||
renderAcceptInvitePage() { return ''; }
|
||||
},
|
||||
getConnection: async () => connection,
|
||||
hashSessionToken: (value) => 'hash:' + value,
|
||||
hashPassword: () => ({ hash: 'hash', salt: 'salt', iterations: 1000 }),
|
||||
validatePasswordStrength: () => '',
|
||||
rbacData: {
|
||||
async syncUserRoles(_connection, userId, roleIds) { syncedRoles = { userId, roleIds }; }
|
||||
}
|
||||
});
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
token: 'invite-token',
|
||||
username: 'person',
|
||||
name: 'Person',
|
||||
password: 'ValidPass!1',
|
||||
confirm_password: 'ValidPass!1'
|
||||
}
|
||||
};
|
||||
const res = {
|
||||
redirect(url) { this.redirectedTo = url; },
|
||||
status() { return this; },
|
||||
send() { return this; }
|
||||
};
|
||||
await handlers['POST /accept-invite'][0](req, res, (error) => { throw error; });
|
||||
|
||||
const insert = queries.find((query) => query.sql.includes('INSERT INTO a_users'));
|
||||
const consumed = queries.find((query) => query.sql.includes('UPDATE a_user_invitations'));
|
||||
assert.deepEqual(insert.args, ['Person', 'person', 'person@example.com', 'hash', 'salt', 1000]);
|
||||
assert.deepEqual(syncedRoles, { userId: 21, roleIds: [2, 3] });
|
||||
assert.deepEqual(consumed.args, [8]);
|
||||
assert.equal(committed, true);
|
||||
assert.equal(res.redirectedTo, '/login?message=Account%20created.%20You%20can%20now%20sign%20in.');
|
||||
});
|
||||
|
||||
test('accept invite rejects an invalid token without creating an account', async () => {
|
||||
const handlers = {};
|
||||
let renderedMessage = '';
|
||||
const app = {
|
||||
get(path, ...routeHandlers) { handlers['GET ' + path] = routeHandlers; },
|
||||
post(path, ...routeHandlers) { handlers['POST ' + path] = routeHandlers; }
|
||||
};
|
||||
const connection = {
|
||||
async query(sql) {
|
||||
if (sql.includes('a_user_invitations')) return [[]];
|
||||
return [[]];
|
||||
},
|
||||
async rollback() {},
|
||||
release() {}
|
||||
};
|
||||
registerAuthRoutes(app, {
|
||||
pool: { async query() { return [[]]; } },
|
||||
pages: { renderAcceptInvitePage(message) { renderedMessage = message; return ''; } },
|
||||
getConnection: async () => connection,
|
||||
hashSessionToken: (value) => 'hash:' + value
|
||||
});
|
||||
|
||||
const res = { status() { return this; }, send() {} };
|
||||
await handlers['POST /accept-invite'][0]({ body: { token: 'expired-token' } }, res, (error) => { throw error; });
|
||||
|
||||
assert.equal(renderedMessage, 'This invitation is invalid or has expired.');
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
require('../src/common');
|
||||
|
||||
@@ -85,6 +86,81 @@ test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () =
|
||||
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
|
||||
});
|
||||
|
||||
test('shared API progress placeholder renders a clamped bar from numeric fields', () => {
|
||||
const sandbox = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
|
||||
const placeholderUtils = sandbox.window.placeholderUtils;
|
||||
const markup = placeholderUtils.renderProgressPlaceholder(
|
||||
{ fundraising: { current: '$1,250.00', total: '$1,000.00' } },
|
||||
'progress(fundraising.current,fundraising.total,success,light,striped,animated)'
|
||||
);
|
||||
|
||||
assert.match(markup, /<span class="progress api-progress" style="background-color:#f8f9fa;font-size:inherit;--bs-progress-font-size:inherit;--bs-progress-height:1em;height:1em;border-radius:var\(--bs-border-radius\);"/);
|
||||
assert.match(markup, /class="progress-bar bg-success progress-bar-striped progress-bar-animated"/);
|
||||
assert.match(markup, /style="width:100%;color:inherit;background-color:#198754;"/);
|
||||
assert.match(markup, /aria-valuenow="100"/);
|
||||
assert.match(markup, /width:100%/);
|
||||
|
||||
const objectValueMarkup = placeholderUtils.renderProgressPlaceholder(
|
||||
{ fundraising: { current: { value: 30, currency: 'USD' }, total: { amount: 100, currency: 'USD' } } },
|
||||
'progress(fundraising.current,fundraising.total)'
|
||||
);
|
||||
assert.match(objectValueMarkup, /aria-valuenow="30"/);
|
||||
assert.match(objectValueMarkup, /width:30%/);
|
||||
|
||||
const announcementColorMarkup = placeholderUtils.renderProgressPlaceholder(
|
||||
{ current: 25, total: 100 },
|
||||
'progress(current,total,midnight)'
|
||||
);
|
||||
assert.match(announcementColorMarkup, /background-color:#1e1d2d/);
|
||||
assert.doesNotMatch(announcementColorMarkup, /bg-primary/);
|
||||
|
||||
const omittedOptionsMarkup = placeholderUtils.renderProgressPlaceholder(
|
||||
{ current: 25, total: 100 },
|
||||
'progress(current,total)'
|
||||
);
|
||||
assert.match(omittedOptionsMarkup, /aria-valuenow="25"/);
|
||||
|
||||
const literalMarkup = placeholderUtils.renderProgressPlaceholder(
|
||||
{},
|
||||
'progress("30","100")'
|
||||
);
|
||||
assert.match(literalMarkup, /aria-valuenow="30"/);
|
||||
});
|
||||
|
||||
test('shared placeholder transforms support arithmetic', () => {
|
||||
const sandbox = { window: {} };
|
||||
vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), sandbox);
|
||||
const placeholderUtils = sandbox.window.placeholderUtils;
|
||||
|
||||
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.multiply(10)')), '120');
|
||||
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.add(3)')), '15');
|
||||
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.subtract(3)')), '9');
|
||||
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.divide(3)')), '4');
|
||||
assert.equal(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression({ amount: 12 }, 'amount.add(3).multiply(10)')), '150');
|
||||
|
||||
const textlessMarkup = placeholderUtils.renderProgressPlaceholder(
|
||||
{ current: 30, total: 100 },
|
||||
'progress(current,total,textless)'
|
||||
);
|
||||
assert.match(textlessMarkup, /aria-label="30%"/);
|
||||
assert.match(textlessMarkup, /aria-valuenow="30"/);
|
||||
assert.match(textlessMarkup, /style="width:30%;color:inherit;"><\/span>/);
|
||||
|
||||
const radiusMarkup = placeholderUtils.renderProgressPlaceholder(
|
||||
{ current: 30, total: 100 },
|
||||
'progress(current,total,radius(12px))'
|
||||
);
|
||||
assert.match(radiusMarkup, /border-radius:12px;/);
|
||||
|
||||
const now = Date.now();
|
||||
const timedMarkup = placeholderUtils.renderProgressPlaceholder(
|
||||
{ start: new Date(now - 30 * 60 * 1000).toISOString(), end: new Date(now + 30 * 60 * 1000).toISOString() },
|
||||
'progress(start,end)'
|
||||
);
|
||||
assert.match(timedMarkup, /aria-valuenow="50"/);
|
||||
});
|
||||
|
||||
test('timetable region registers the timetable type', () => {
|
||||
assert.ok(timetableRegionSource.includes("registry.register('timetable'"));
|
||||
assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value"));
|
||||
|
||||
@@ -44,7 +44,7 @@ test('pending migrations are empty when the schema already matches the app versi
|
||||
}
|
||||
]);
|
||||
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.10.2' });
|
||||
const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '2.11.1' });
|
||||
|
||||
assert.equal(pendingMigrations.length, 0);
|
||||
});
|
||||
|
||||
@@ -118,3 +118,57 @@ test('settings route renders the overview', async () => {
|
||||
});
|
||||
assert.equal(Number(videoInsert.params[1]), 1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('email settings can send a test email to the logged-in user', async () => {
|
||||
const handlers = {};
|
||||
const sentMessages = [];
|
||||
const app = {
|
||||
get(path, ...routeHandlers) {
|
||||
handlers[path] = routeHandlers;
|
||||
},
|
||||
post(path, ...routeHandlers) {
|
||||
handlers['POST ' + path] = routeHandlers;
|
||||
}
|
||||
};
|
||||
|
||||
registerSettingsRoutes(app, {
|
||||
pages: {},
|
||||
pool: {
|
||||
async query(sql) {
|
||||
if (/SELECT id, setting_key, setting_value FROM o_app_settings/.test(sql)) {
|
||||
return [[
|
||||
{ setting_key: 'email.smtp_enabled', setting_value: 'true' },
|
||||
{ setting_key: 'email.smtp_host', setting_value: 'smtp.example.test' },
|
||||
{ setting_key: 'email.from_address', setting_value: 'pulse@example.test' }
|
||||
]];
|
||||
}
|
||||
throw new Error('Unexpected settings write during test email action.');
|
||||
}
|
||||
},
|
||||
requirePermission() {
|
||||
return function (_req, _res, next) {
|
||||
next();
|
||||
};
|
||||
},
|
||||
sendAccountEmail(settings, message) {
|
||||
sentMessages.push({ settings, message });
|
||||
}
|
||||
});
|
||||
|
||||
const response = {
|
||||
redirect(url) {
|
||||
this.redirectedTo = url;
|
||||
}
|
||||
};
|
||||
await handlers['POST /settings/system'][1]({
|
||||
body: { settings_section: 'email', settings_action: 'test_email' },
|
||||
currentUser: { id: 7, email: 'admin@example.test' }
|
||||
}, response, function (error) {
|
||||
throw error;
|
||||
});
|
||||
|
||||
assert.equal(sentMessages.length, 1);
|
||||
assert.equal(sentMessages[0].message.to, 'admin@example.test');
|
||||
assert.equal(sentMessages[0].message.subject, 'Pulse Signage SMTP test email');
|
||||
assert.match(response.redirectedTo, /Test%20email%20sent%20to%20admin%40example\.test/);
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ test('dashboard move client button opens the move modal for the selected row', (
|
||||
return '';
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === 'td[data-label="Client"] > div') {
|
||||
if (selector === 'td[data-label="Client"] > div, [data-mobile-client-name]' || selector === 'td[data-label="Client"] > div') {
|
||||
return { textContent: 'Lobby Client' };
|
||||
}
|
||||
return null;
|
||||
@@ -235,7 +235,7 @@ test('dashboard move client button opens the move modal for the selected row', (
|
||||
if (selector === 'button[data-action="move-screen"]') {
|
||||
return this;
|
||||
}
|
||||
if (selector === 'tr[data-client-key]') {
|
||||
if (selector === 'tr[data-client-key], article[data-mobile-client-key]' || selector === 'tr[data-client-key]') {
|
||||
return row;
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user