diff --git a/CHANGELOG.md b/CHANGELOG.md index 922db86..9e48e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## 2.8.1 - 2026-08-16 + +### Fixed + +- Prevented startup and runtime duplicate-key writes from consuming auto-increment values in permission, player, onboarding, settings, and relationship tables. +- Prevented partial timetable schemas from being incorrectly treated as fully migrated during schema version detection. + +### Changed + +- Renamed the built-in administrator role key to `super-admin`, while allowing its display name and description to be edited without being overwritten on restart. + ## 2.8.0 - 2026-08-16 ### Added diff --git a/build/package.player.json b/build/package.player.json index 8f2dc82..dc4fc2e 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.8.0", + "version": "2.8.1", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index a8064be..7177fc3 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.8.0", + "version": "2.8.1", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/docker-compose/.env.example b/docker-compose/.env.example index b1fb10a..715cc93 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -19,7 +19,7 @@ PLAYER_INTERNAL_URL="http://player:8081" # Web app bootstrap settings DEFAULT_ADMIN_USERNAME="admin" DEFAULT_ADMIN_NAME="Admin" -DEFAULT_ADMIN_PASSWORD="password123" +DEFAULT_ADMIN_PASSWORD="password123!" # Bridge settings for the player-bridge service WEB_INTERNAL_URL="http://web:8080" diff --git a/package-lock.json b/package-lock.json index 72a020e..3e5563e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.8.0", + "version": "2.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.8.0", + "version": "2.8.1", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index b2b9c31..d510ef0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.8.0", + "version": "2.8.1", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/app-settings.js b/src/data/app-settings.js index 8c1d6e9..3fdd95a 100644 --- a/src/data/app-settings.js +++ b/src/data/app-settings.js @@ -165,11 +165,19 @@ async function saveAppSettings(pool, settings, modifiedBy) { if (!Object.prototype.hasOwnProperty.call(inputSettings, definition.key)) { continue; } + await connection.query( + `UPDATE o_app_settings + SET setting_value = ?, modified_by = ? + WHERE setting_key = ?`, + [JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, definition.key] + ); await connection.query( `INSERT INTO o_app_settings (setting_key, setting_value, created_by, modified_by) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), modified_by = VALUES(modified_by)`, - [definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null] + SELECT ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM o_app_settings WHERE setting_key = ? + )`, + [definition.key, JSON.stringify(normalizedSettings[definition.key]), modifiedBy || null, modifiedBy || null, definition.key] ); } if (typeof connection.commit === 'function') { diff --git a/src/data/player-registry.js b/src/data/player-registry.js index 3a33d87..94477de 100644 --- a/src/data/player-registry.js +++ b/src/data/player-registry.js @@ -92,15 +92,19 @@ async function upsertPlayerRegistration(pool, options) { return null; } + await pool.query( + `UPDATE d_players + SET public_base_url = ?, internal_base_url = ?, last_seen_at = CURRENT_TIMESTAMP, modified_at = CURRENT_TIMESTAMP + WHERE identifier = ?`, + [publicBaseUrl || null, internalBaseUrl || null, identifier] + ); await pool.query( `INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ON DUPLICATE KEY UPDATE - public_base_url = VALUES(public_base_url), - internal_base_url = VALUES(internal_base_url), - last_seen_at = CURRENT_TIMESTAMP, - modified_at = CURRENT_TIMESTAMP`, - [identifier, publicBaseUrl || null, internalBaseUrl || null] + SELECT ?, ?, ?, CURRENT_TIMESTAMP + WHERE NOT EXISTS ( + SELECT 1 FROM d_players WHERE identifier = ? + )`, + [identifier, publicBaseUrl || null, internalBaseUrl || null, identifier] ); return resolvePlayerRegistration(pool, identifier); @@ -115,15 +119,22 @@ async function recordPlayerHeartbeat(pool, options) { return null; } + await pool.query( + `UPDATE d_players + SET public_base_url = COALESCE(?, public_base_url), + internal_base_url = COALESCE(?, internal_base_url), + last_seen_at = CURRENT_TIMESTAMP, + modified_at = CURRENT_TIMESTAMP + WHERE identifier = ?`, + [publicBaseUrl || null, internalBaseUrl || null, identifier] + ); await pool.query( `INSERT INTO d_players (identifier, public_base_url, internal_base_url, last_seen_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ON DUPLICATE KEY UPDATE - public_base_url = COALESCE(VALUES(public_base_url), public_base_url), - internal_base_url = COALESCE(VALUES(internal_base_url), internal_base_url), - last_seen_at = CURRENT_TIMESTAMP, - modified_at = CURRENT_TIMESTAMP`, - [identifier, publicBaseUrl || null, internalBaseUrl || null] + SELECT ?, ?, ?, CURRENT_TIMESTAMP + WHERE NOT EXISTS ( + SELECT 1 FROM d_players WHERE identifier = ? + )`, + [identifier, publicBaseUrl || null, internalBaseUrl || null, identifier] ); return resolvePlayerRegistration(pool, identifier); diff --git a/src/db/bootstrap.js b/src/db/bootstrap.js index 1f3aaef..c5c752a 100644 --- a/src/db/bootstrap.js +++ b/src/db/bootstrap.js @@ -15,11 +15,19 @@ async function bootstrapDatabase(pool) { } for (const permission of PERMISSIONS) { + await pool.query( + `UPDATE a_permissions + SET name = ?, section_name = ?, description = ?, modified_by = ? + WHERE permission_key = ?`, + [permission.name, permission.sectionName, permission.description || null, null, permission.key] + ); await pool.query( `INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by) - VALUES (?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE name = VALUES(name), section_name = VALUES(section_name), description = VALUES(description), modified_by = VALUES(modified_by)` , - [permission.key, permission.name, permission.sectionName, permission.description || null, null, null] + SELECT ?, ?, ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM a_permissions WHERE permission_key = ? + )`, + [permission.key, permission.name, permission.sectionName, permission.description || null, null, null, permission.key] ); } @@ -35,20 +43,37 @@ async function bootstrapDatabase(pool) { ); } + const [legacyRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', ['administrators']); + const [currentRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]); + if (legacyRoleRows.length && !currentRoleRows.length) { + await pool.query( + `UPDATE a_roles + SET role_key = ?, name = ?, description = ?, modified_by = ? + WHERE role_key = ?`, + [DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, 'administrators'] + ); + } + await pool.query( `INSERT INTO a_roles (role_key, name, description, created_by, modified_by) - VALUES (?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`, - [DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null] + SELECT ?, ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM a_roles WHERE role_key = ? + )`, + [DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null, DEFAULT_ROLE.key] ); const [defaultRoleRows] = await pool.query('SELECT id FROM a_roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]); const defaultRoleId = defaultRoleRows.length ? Number(defaultRoleRows[0].id) : null; if (defaultRoleId) { await pool.query( - `INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) - SELECT ?, id, NULL, NULL FROM a_permissions`, - [defaultRoleId] + `INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by) + SELECT ?, permissions.id, NULL, NULL + FROM a_permissions permissions + LEFT JOIN a_role_permissions existing + ON existing.role_id = ? AND existing.permission_id = permissions.id + WHERE existing.id IS NULL`, + [defaultRoleId, defaultRoleId] ); } diff --git a/src/db/migrations.js b/src/db/migrations.js index c438d8f..297df75 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -538,7 +538,7 @@ async function detectSchemaVersion(pool) { const scheduleGroupsExists = await tableExists(pool, 'i_schedule_groups'); const scheduleEntriesExists = await tableExists(pool, 'i_schedule_entries'); - if ((timetableGroupsExists || timetableEntriesExists) && !scheduleGroupsExists && !scheduleEntriesExists) { + if (timetableGroupsExists && timetableEntriesExists && !scheduleGroupsExists && !scheduleEntriesExists) { return '2.6.18'; } @@ -556,9 +556,14 @@ async function recordSchemaVersion(pool, version) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` ); + const stateValue = String(version || appVersion || '0.0.0').trim(); await pool.query( - 'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE state_value = VALUES(state_value)', - [APP_STATE_SCHEMA_VERSION_KEY, String(version || appVersion || '0.0.0').trim()] + 'UPDATE ' + APP_STATE_TABLE + ' SET state_value = ? WHERE state_key = ?', + [stateValue, APP_STATE_SCHEMA_VERSION_KEY] + ); + await pool.query( + 'INSERT INTO ' + APP_STATE_TABLE + ' (state_key, state_value) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM ' + APP_STATE_TABLE + ' WHERE state_key = ?)', + [APP_STATE_SCHEMA_VERSION_KEY, stateValue, APP_STATE_SCHEMA_VERSION_KEY] ); } diff --git a/src/player/onboarding/index.js b/src/player/onboarding/index.js index 8465235..48363b0 100644 --- a/src/player/onboarding/index.js +++ b/src/player/onboarding/index.js @@ -128,8 +128,18 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam } await pool.query( - 'INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', - [normalizedDeviceId, normalizedClientName, screen.id] + `UPDATE d_onboarding_devices + SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP + WHERE device_id = ?`, + [normalizedClientName, screen.id, normalizedDeviceId] + ); + await pool.query( + `INSERT INTO d_onboarding_devices (device_id, client_name, screen_id) + SELECT ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM d_onboarding_devices WHERE device_id = ? + )`, + [normalizedDeviceId, normalizedClientName, screen.id, normalizedDeviceId] ); return getOnboardingStatus(pool, normalizedDeviceId); diff --git a/src/rbac.js b/src/rbac.js index 9f9066b..5e69773 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -236,8 +236,8 @@ const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section, sectionIndex) }); const DEFAULT_ROLE = { - key: 'administrators', - name: 'Administrators', + key: 'super-admin', + name: 'Super Admin', description: 'Full access to the admin interface.' }; diff --git a/src/web/lib/auth/rbac-data.js b/src/web/lib/auth/rbac-data.js index f0e3354..6ece63e 100644 --- a/src/web/lib/auth/rbac-data.js +++ b/src/web/lib/auth/rbac-data.js @@ -236,7 +236,14 @@ async function syncUserRoles(pool, userId, roleIds) { await pool.query('DELETE FROM a_user_roles WHERE user_id = ?', [userId]); for (const roleId of uniqueRoleIds) { - await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); + await pool.query( + `INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by) + SELECT ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ? + )`, + [userId, roleId, null, null, userId, roleId] + ); } } @@ -249,7 +256,14 @@ async function syncRoleUsers(pool, roleId, userIds) { await pool.query('DELETE FROM a_user_roles WHERE role_id = ?', [roleId]); for (const userId of uniqueUserIds) { - await pool.query('INSERT IGNORE INTO a_user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); + await pool.query( + `INSERT INTO a_user_roles (user_id, role_id, created_by, modified_by) + SELECT ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM a_user_roles WHERE user_id = ? AND role_id = ? + )`, + [userId, roleId, null, null, userId, roleId] + ); } } @@ -268,7 +282,15 @@ async function syncRolePermissions(pool, roleId, permissionKeys) { await pool.query('DELETE FROM a_role_permissions WHERE role_id = ?', [roleId]); for (const permissionRow of permissionRows) { - await pool.query('INSERT IGNORE INTO a_role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]); + const permissionId = Number(permissionRow.id); + await pool.query( + `INSERT INTO a_role_permissions (role_id, permission_id, created_by, modified_by) + SELECT ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM a_role_permissions WHERE role_id = ? AND permission_id = ? + )`, + [roleId, permissionId, null, null, roleId, permissionId] + ); } } diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index eae8d20..bd17e70 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -1,4 +1,5 @@ // Admin RBAC route registration and permission management. + const { DEFAULT_ROLE } = require('#src/rbac'); module.exports = function registerRbacRoutes(app, deps) { const pool = deps.pool; @@ -359,7 +360,7 @@ excludeUserId: currentUserId }); const users = mapUsersForView(data.users, viewModel.selectedUserIds); - viewModel.role.inUse = String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0; + viewModel.role.inUse = String(viewModel.role.role_key || '') === DEFAULT_ROLE.key || String(viewModel.role.role_key || '') === 'administrators' || Number(viewModel.role.user_count) > 0; res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages'))); } catch (error) { @@ -580,8 +581,8 @@ if (!role) { return res.status(404).send('Role not found.'); } - if (String(role.role_key || '') === 'administrators') { - return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.')); + if (String(role.role_key || '') === DEFAULT_ROLE.key || String(role.role_key || '') === 'administrators') { + return res.redirect('/settings/roles?message=' + encodeURIComponent('The built-in Super Admin role cannot be deleted.')); } if (Number(role.user_count) > 0) { return res.redirect('/settings/roles?message=' + encodeURIComponent('Remove all users from this role before deleting it.')); diff --git a/src/web/routes/settings/rbac/form-view-model.js b/src/web/routes/settings/rbac/form-view-model.js index 0234a5c..f6e2ccf 100644 --- a/src/web/routes/settings/rbac/form-view-model.js +++ b/src/web/routes/settings/rbac/form-view-model.js @@ -124,6 +124,9 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup } function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) { + const roleKey = String(role && role.role_key || '').trim(); + const isProtectedRole = roleKey === 'super-admin' || roleKey === 'administrators'; + const deleteDisabled = isProtectedRole || Boolean(role && role.inUse); return { title: 'Edit role', active: 'rbac', @@ -135,13 +138,16 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us footerCancelUrl: '/settings/roles', cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?", footerDeleteUrl: '/settings/roles/' + Number(role && role.id) + '/delete', - footerDeleteDisabled: Boolean(role && role.inUse), - footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '', + footerDeleteDisabled: deleteDisabled, + footerDeleteConfirmMessage: 'Delete ' + String(role && role.name || 'this role') + '?', + footerDeleteTitle: isProtectedRole + ? 'The built-in Super Admin role cannot be deleted.' + : (role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : ''), footerShowDelete: true, message: message, currentUser: currentUser || null, - role: role, - inUse: Boolean(role && role.inUse), + role: Object.assign({}, role, { isProtected: isProtectedRole }), + inUse: deleteDisabled, permissionGroups: permissionGroups || [], permissionSections: buildPermissionSections(permissionGroups), users: users || [], diff --git a/src/web/routes/signage/announcements/routes.js b/src/web/routes/signage/announcements/routes.js index ed24c9f..5729a44 100644 --- a/src/web/routes/signage/announcements/routes.js +++ b/src/web/routes/signage/announcements/routes.js @@ -159,12 +159,23 @@ module.exports = function registerAnnouncementRoutes(app, deps) { } if (selectedIds.length) { - await pool.query( - 'INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by) VALUES ? ON DUPLICATE KEY UPDATE modified_at = CURRENT_TIMESTAMP, modified_by = VALUES(modified_by)', - [selectedIds.map(function (screenId) { - return [announcementId, screenId, actorId || null, actorId || null]; - })] - ); + for (const screenId of selectedIds) { + await pool.query( + `UPDATE d_announcement_screens + SET modified_at = CURRENT_TIMESTAMP, modified_by = ? + WHERE announcement_id = ? AND screen_id = ?`, + [actorId || null, announcementId, screenId] + ); + await pool.query( + `INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by) + SELECT ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM d_announcement_screens + WHERE announcement_id = ? AND screen_id = ? + )`, + [announcementId, screenId, actorId || null, actorId || null, announcementId, screenId] + ); + } } } diff --git a/src/web/views/settings/rbac/form.hbs b/src/web/views/settings/rbac/form.hbs index c1e2deb..d35a888 100644 --- a/src/web/views/settings/rbac/form.hbs +++ b/src/web/views/settings/rbac/form.hbs @@ -27,7 +27,7 @@
diff --git a/test/app-settings.test.js b/test/app-settings.test.js index db31767..cfea5af 100644 --- a/test/app-settings.test.js +++ b/test/app-settings.test.js @@ -76,5 +76,5 @@ test('save app settings writes only supplied keys in one transaction', async () 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); + assert.equal(queries.filter(function (entry) { return entry && entry.sql; }).length, 2); }); diff --git a/test/player-registry.test.js b/test/player-registry.test.js index 16c9e3a..fca6440 100644 --- a/test/player-registry.test.js +++ b/test/player-registry.test.js @@ -17,7 +17,7 @@ test('player registry upserts include the identifier field', async () => { if (String(sql || '').includes('information_schema.COLUMNS')) { return [[{ column_count: 1 }]]; } - if (String(sql || '').includes('INSERT INTO d_players')) { + if (String(sql || '').includes('UPDATE d_players')) { return [{ affectedRows: 1 }]; } if (String(sql || '').includes('SELECT id, identifier, public_base_url, internal_base_url, last_seen_at')) { @@ -63,5 +63,5 @@ test('player registry upserts include the identifier field', async () => { })); assert.deepEqual((calls.find(function (call) { return /INSERT INTO d_players/.test(String(call.sql || '')); - }) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081']); + }) || {}).params, ['Shop2', 'http://player.local', 'http://player:8081', 'Shop2']); }); \ No newline at end of file diff --git a/test/rbac-permissions.test.js b/test/rbac-permissions.test.js index 6e80752..8a28577 100644 --- a/test/rbac-permissions.test.js +++ b/test/rbac-permissions.test.js @@ -5,7 +5,7 @@ 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 { 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'); @@ -111,4 +111,20 @@ test('rbac duplicate route exists', () => { 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.')); }); \ No newline at end of file diff --git a/test/schema-update-log.test.js b/test/schema-update-log.test.js index 62310ff..c172c2f 100644 --- a/test/schema-update-log.test.js +++ b/test/schema-update-log.test.js @@ -76,4 +76,45 @@ test('pending migrations are reported when an older schema still needs scripts', 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'); +}); + +test('a partial timetable schema does not fast-forward migration detection', async () => { + const pool = createPool([ + { + match(sql) { + return sql.includes('FROM information_schema.COLUMNS') && sql.includes('TABLE_NAME = ?') && sql.includes('COLUMN_NAME = ?'); + }, + result: [[{ column_count: 0 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_timetable_groups'; + }, + result: [[{ table_count: 1 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_timetable_entries'; + }, + result: [[{ table_count: 0 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_schedule_groups'; + }, + result: [[{ table_count: 0 }]] + }, + { + match(_sql, params) { + return Array.isArray(params) && params[0] === 'i_schedule_entries'; + }, + result: [[{ table_count: 0 }]] + } + ]); + + const pendingMigrations = await getPendingMigrations(pool, { currentVersion: '0.0.0' }); + + assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.16'; })); + assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.17'; })); + assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.18'; })); }); \ No newline at end of file