diff --git a/InfoLogger/README.md b/InfoLogger/README.md index 9fdf37be3..eab877439 100644 --- a/InfoLogger/README.md +++ b/InfoLogger/README.md @@ -5,8 +5,10 @@ - [InfoLogger GUI (ILG)](#infologger-gui-ilg) - [Interface User Guide](#interface-user-guide) + - [Shifter based role](#shifter-based-role) - [Requirements](#requirements) - [Installation](#installation) + - [Development database installation](#development-database-installation) - [Dummy InfoLogger test server](#dummy-infologger-test-server) - [InfoLogger insights](#infologger-insights) - [Continuous Integration Workflows](#continuous-integration-workflows) @@ -34,6 +36,10 @@ It interfaces with the system using two modes: - Use arrows keys to navigate quickly between logs - Download the logs in a file via the top left download icon +### Shifter based role + +If the authenticated user is defined as having one of the access roles 'shifter' but does **not** have 'admin', then the UI should restrict the levels by which the user can filter messages. More specifically, the shifters are only allowed to filter messages by `Ops` (Operations) + ## Requirements - `nodejs` >= `16.x` - InfoLogger MariaDB database for Query mode diff --git a/InfoLogger/public/Model.js b/InfoLogger/public/Model.js index 7dcd8d802..22c6d8f0c 100644 --- a/InfoLogger/public/Model.js +++ b/InfoLogger/public/Model.js @@ -17,7 +17,7 @@ import { Observable, WebSocketClient, QueryRouter, Loader, RemoteData, sessionService, Notification, } from '/js/src/index.js'; -import { callRateLimiter, setBrowserTabTitle } from './common/utils.js'; +import { callRateLimiter, setBrowserTabTitle, hasShifterButNoAdminRole } from './common/utils.js'; import { ConfigurationService } from './services/ConfigurationService.js'; import { MODE } from './constants/mode.const.js'; import Log from './log/Log.js'; @@ -311,12 +311,16 @@ export default class Model extends Observable { /** * Delegates sub-model actions depending new location of the page + * If user is shifter but not admin, set Ops as maximum level for filtering */ handleLocationChange() { const { params } = this.router; if (params) { this.parseLocation(params); } + if (hasShifterButNoAdminRole(this.session.access)) { + this.log.filter.setCriteria('level', 'max', 1); + } } /** diff --git a/InfoLogger/public/common/utils.js b/InfoLogger/public/common/utils.js index 9c5d16168..4466c0e19 100644 --- a/InfoLogger/public/common/utils.js +++ b/InfoLogger/public/common/utils.js @@ -12,13 +12,16 @@ * or submit itself to any jurisdiction. */ +import { Role } from './../constants/role.const.js'; +import { INFOLOGGER_LEVEL_LIST } from './../constants/infologger-level.const.js'; + /** * Limit the number of calls to `fn` to 1 per `time` maximum. * First call is immediate if `time` have been waited already. * All other calls before end of `time` window will lead to 1 exececution at the end of window. * @param {string} fn - function to be called * @param {string} time - ms - * @returns {Function} lambda function to be called to call `fn` + * @returns {void} lambda function to be called to call `fn` * @example * let f = callRateLimiter((arg) => console.log('called', arg), 1000); * 00:00:00 f(1);f(2);f(3);f(4); @@ -60,3 +63,30 @@ export function setBrowserTabTitle(title = undefined) { document.title = title; } } + +/** + * Method to check if the user has only shifter role and not admin role + * @param {string[]} access - array of user roles email groups affiliation + * @returns {boolean} true if the user has only shifter role and not admin role, false otherwise + */ +export function hasShifterButNoAdminRole(access = []) { + return access.includes(Role.SHIFTER) && !access.includes(Role.ADMIN); +} + +/** + * Method to return filter levels allowed for filtering based on current user role email groups affiliation + * * Shifters are only allowed to filter by Ops level + * @param {string[]} access - array of user roles email groups affiliation + * @returns {{label: string, index:number}[]} - filter levels allowed for filtering + */ +export function getFilterLevelsAllowed(access = []) { + return hasShifterButNoAdminRole(access) + ? INFOLOGGER_LEVEL_LIST.map((level) => ({ + ...level, + available: level.label === 'Ops', + })) + : INFOLOGGER_LEVEL_LIST.map((level) => ({ + ...level, + available: true, + })); +} diff --git a/InfoLogger/public/constants/infologger-level.const.js b/InfoLogger/public/constants/infologger-level.const.js new file mode 100644 index 000000000..b6fac2957 --- /dev/null +++ b/InfoLogger/public/constants/infologger-level.const.js @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Object containing the different levels of logs that can be displayed in the application, + * with their label and index as in the database + * These values are as per InfoLogger defined levels: + * {@link https://github.com/AliceO2Group/InfoLogger/blob/master/doc/README.md} + */ +export const InfoLoggerLevel = Object.freeze({ + OPS: { + label: 'Ops', + index: 1, + }, + SUPPORT: { + label: 'Support', + index: 6, + }, + DEVEL: { + label: 'Devel', + index: 11, + }, + TRACE: { + label: 'Trace', + index: null, + }, +}); + +/** + * Array containing the different levels of logs that can be displayed in the application, + * with their label and index as in the database, used for iterating over the levels in the UI + * These values are as per InfoLogger defined levels: + */ +export const INFOLOGGER_LEVEL_LIST = Object.values(InfoLoggerLevel); diff --git a/InfoLogger/public/constants/role.const.js b/InfoLogger/public/constants/role.const.js new file mode 100644 index 000000000..95907e126 --- /dev/null +++ b/InfoLogger/public/constants/role.const.js @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * Object containing the different roles that a user can have in the application, used for checking + * permissions and access levels. These roles are defined in CERN Application Service + */ +export const Role = Object.freeze({ + SHIFTER: 'shifter', + ADMIN: 'admin', +}); diff --git a/InfoLogger/public/logFilter/LogFilter.js b/InfoLogger/public/logFilter/LogFilter.js index 502b10def..f9de7ef47 100644 --- a/InfoLogger/public/logFilter/LogFilter.js +++ b/InfoLogger/public/logFilter/LogFilter.js @@ -22,7 +22,8 @@ import { Observable } from '/js/src/index.js'; */ /** - * @typedef Criteria * @type {Array.} + * @typedef Criteria + * @type {Array.} */ /** @@ -145,7 +146,7 @@ export default class LogFilter extends Observable { /** * Generates a function to filter a log passed as argument to it * Output of function is boolean. - * @returns {Function.} - function to filter logs + * @returns {void.} - function to filter logs */ toStringifyFunction() { /** diff --git a/InfoLogger/public/logFilter/commandFilters.js b/InfoLogger/public/logFilter/commandFilters.js index 6e3066819..2f410bda6 100644 --- a/InfoLogger/public/logFilter/commandFilters.js +++ b/InfoLogger/public/logFilter/commandFilters.js @@ -14,92 +14,97 @@ import { h } from '/js/src/index.js'; +const LIMIT_LEVELS = [ + { label: '100k', value: 100000 }, + { label: '500k', value: 500000 }, + { label: '1M', value: 1000000 }, +]; +const SEVERITIES_ALLOWED = [ + { label: 'Debug', value: 'D' }, + { label: 'Info', value: 'I' }, + { label: 'Warn', value: 'W' }, + { label: 'Error', value: 'E' }, + { label: 'Fatal', value: 'F' }, +]; + /** * Filtering main options, in toolbar, top-right. * - severity * - level * - limit * - reset - * @param {Model} model - root model of the application + * @param {Log} logModel - log model of the application + * @param {{label: string, index:number}[]} filterLevelsAllowed - levels allowed for filtering * @returns {vnode} - the view of filters panel */ -export default (model) => [ +export default (logModel, filterLevelsAllowed) => [ h( - '', - h('.btn-group', [ - buttonSeverity(model, 'Debug', 'Match severity debug', 'D'), - buttonSeverity(model, 'Info', 'Match severity info', 'I'), - buttonSeverity(model, 'Warn', 'Match severity warnings', 'W'), - buttonSeverity(model, 'Error', 'Match severity errors', 'E'), - buttonSeverity(model, 'Fatal', 'Match severity fatal', 'F'), - ]), - h('span.mh3'), - h('.btn-group', [ - buttonFilterLevel(model, 'Ops', 1), - buttonFilterLevel(model, 'Support', 6), - buttonFilterLevel(model, 'Devel', 11), - buttonFilterLevel(model, 'Trace', null), // 21 - ]), - h('span.mh3'), - h('.btn-group', [ - buttonLogLimit(model, '100k', 100000), - buttonLogLimit(model, '500k', 500000), - buttonLogLimit(model, '1M', 1000000), - ]), - h('span.mh3'), - buttonReset(model), + '.btn-group', + SEVERITIES_ALLOWED.map(({ label, value }) => _selectableButtonComponent( + label, + { + id: `severity-${value}`, + title: `Match severity ${label.toLowerCase()}`, + isActive: logModel.filter.criterias.severity.in.includes(value), + onclick: () => logModel.setCriteria('severity', 'in', value), + }, + )), ), -]; -/** - * Makes a button to toggle severity - * @param {Model} model - root model of the application - * @param {string} label - button's label - * @param {string} title - button's title on mouse over - * @param {string} value - a char to represent severity: W E F or I, can be many with spaces like 'W E' - * @returns {vnode} - the button to toggle severity - */ -const buttonSeverity = (model, label, title, value) => h('button.btn', { - className: model.log.filter.criterias.severity.in.includes(value) ? 'active' : '', - onclick: (e) => { - model.log.setCriteria('severity', 'in', value); - e.target.blur(); // remove focus so user can 'enter' without actually toggle again the button - }, - title: title, -}, label); + h( + '.btn-group', + filterLevelsAllowed.map(({ label, index, available }) => _selectableButtonComponent( + label, + { + id: `level-${index}`, + title: available ? `Filter level ≤ ${index}` : `You don't have access to level ${label}`, + isActive: logModel.filter.criterias.level.max === index, + onclick: () => logModel.setCriteria('level', 'max', index), + disabled: !available, + }, + )), + ), + + h( + '.btn-group', + LIMIT_LEVELS.map(({ label, value }) => + _selectableButtonComponent( + label, + { + id: `limit-${value}`, + title: `Keep only ${value / 1000}k logs in the view`, + isActive: logModel.limit === value, + onclick: () => logModel.setLimit(value), + }, + )), + ), + + _selectableButtonComponent( + 'Reset filters', + { + title: 'Reset date, time, matches, excludes, log levels', + isActive: false, + onclick: () => logModel.filter.resetCriteria(), + }, + ), +]; /** - * Makes a button to set filtering level (shifter, debug, etc) with number - * @param {Model} model - root model of the application + * Component representing the creation of a button for filtering header * @param {string} label - button's label - * @param {number} value - maximum level of filtering, from 1 to 21 + * @param {object} options - options for the button + * @param {string} options.id - button's id + * @param {string} options.title - button's title on mouse over + * @param {boolean} options.isActive - whether the button is active + * @param {void} options.onclick - function to call when button is clicked + * @param {boolean} options.disabled - whether the button is disabled * @returns {vnode} - component representing the creation of a button for filtering */ -const buttonFilterLevel = (model, label, value) => h('button.btn', { - className: model.log.filter.criterias.level.max === value ? 'active' : '', - onclick: () => model.log.setCriteria('level', 'max', value), - title: `Filter level ≤ ${value}`, -}, label); +const _selectableButtonComponent = (label, { id, title, isActive, onclick, disabled }) => h('button.btn', { + id, + className: [isActive ? 'active' : '', disabled ? 'disabled' : ''].join(' '), + onclick, + title, + disabled, -/** - * Makes a button to set log limit, maximum logs in memory - * @param {Model} model - root model of the application - * @param {string} label - button's label - * @param {number} limit - how much logs to keep in memory - * @returns {vnode} - component representing the creation of a button for log limit - */ -const buttonLogLimit = (model, label, limit) => h('button.btn', { - className: model.log.limit === limit ? 'active' : '', - onclick: () => model.log.setLimit(limit), - title: `Keep only ${label} logs in the view`, }, label); - -/** - * Makes a button to reset filters - * @param {Model} model - root model of the application - * @returns {vnode} - component representing the creation of a button to reset filters - */ -const buttonReset = (model) => h('button.btn', { - onclick: () => model.log.filter.resetCriteria(), - title: 'Reset date, time, matches, excludes, log levels', -}, 'Reset filters'); diff --git a/InfoLogger/public/view.js b/InfoLogger/public/view.js index 8f744afce..e7a00f844 100644 --- a/InfoLogger/public/view.js +++ b/InfoLogger/public/view.js @@ -24,6 +24,7 @@ import tableLogsContent from './log/tableLogsContent.js'; import tableLogsScrollMap from './log/tableLogsScrollMap.js'; import aboutComponent from './about/about.component.js'; import errorComponent from './common/errorComponent.js'; +import { getFilterLevelsAllowed } from './common/utils.js'; /** * Main view of the application @@ -34,14 +35,12 @@ export default (model) => [ notification(model.notification), h('.flex-column absolute-fill', [ h('.shadow-level2', [ - h('header.p1.flex-row.f7', [ - h('', commandLogs(model)), + h('header.p1.flex-wrap.flex-row.f7.g2', [ + h('.flex-row', commandLogs(model)), h( - '.flex-grow', - { - style: 'display: flex; flex-direction:row-reverse;', - }, - commandFilters(model), + '.flex-row.g3', + { style: 'margin-left: auto;' }, + commandFilters(model.log, getFilterLevelsAllowed(model.session.access)), ), ]), h('header.f7', tableFilters(model)), diff --git a/InfoLogger/test/mocha-index.js b/InfoLogger/test/mocha-index.js index 051a18aa1..40666763b 100644 --- a/InfoLogger/test/mocha-index.js +++ b/InfoLogger/test/mocha-index.js @@ -10,15 +10,17 @@ * In applying this license CERN does not waive the privileges and immunities * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. -*/ + */ /* eslint-disable no-console */ +/* eslint-disable init-declarations */ + const puppeteer = require('puppeteer'); const assert = require('assert'); -const {spawn} = require('child_process'); +const { spawn } = require('child_process'); const config = require('./test-config.js'); -const {createServer, closeServer} = require('./live-simulator/infoLoggerServer.js'); +const { createServer, closeServer } = require('./live-simulator/infoLoggerServer.js'); // APIs: // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md @@ -28,16 +30,16 @@ const {createServer, closeServer} = require('./live-simulator/infoLoggerServer.j // Network and rendering can have delays this can leads to random failures // if they are tested just after their initialization. -describe('InfoLogger', function() { +describe('InfoLogger', function () { let browser; let page; let subprocess; // web-server runs into a subprocess let subprocessOutput = ''; let ilgServer; - + this.timeout(30000); this.slow(1000); - + const baseUrl = `http://${config.http.hostname}:${config.http.port}/`; before(async () => { @@ -62,25 +64,30 @@ describe('InfoLogger', function() { ilgServer = createServer(); // Start web-server in background - subprocess = spawn('node', ['index.js', 'test/test-config.js'], {stdio: 'pipe'}); - subprocess.stdout.on('data', (chunk) => subprocessOutput += chunk.toString()); - subprocess.stderr.on('data', (chunk) => subprocessOutput += chunk.toString()); - subprocess.on('error', (error) => console.error(`Server failed due to: ${error}`)) + subprocess = spawn('node', ['index.js', 'test/test-config.js'], { stdio: 'pipe' }); + subprocess.stdout.on('data', (chunk) => { + subprocessOutput += chunk.toString(); + }); + subprocess.stderr.on('data', (chunk) => { + subprocessOutput += chunk.toString(); + }); + subprocess.on('error', (error) => console.error(`Server failed due to: ${error}`)); // Start browser to test UI - browser = await puppeteer.launch({headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox']}); + browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); page = await browser.newPage(); // Export page and configurations for the other mocha files exports.page = page; - exports.helpers = {baseUrl, jwt: config.jwt}; + + exports.helpers = { baseUrl, jwt: config.jwt }; }); it('should load first page "/"', async () => { // try many times until backend server is ready for (let i = 0; i < 10; i++) { try { - await page.goto(baseUrl, {waitUntil: 'networkidle0'}); + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); break; // connection ok, this test passed } catch (e) { if (e.message.includes('net::ERR_CONNECTION_REFUSED')) { @@ -92,8 +99,8 @@ describe('InfoLogger', function() { } }); - it('should have redirected to default page "/?q={"severity":{"in":"I W E F"}}"', async function() { - await page.goto(baseUrl, {waitUntil: 'networkidle0'}); + it('should have redirected to default page "/?q={"severity":{"in":"I W E F"}}"', async () => { + await page.goto(baseUrl, { waitUntil: 'networkidle0' }); const location = await page.evaluate(() => window.location); const search = decodeURIComponent(location.search); @@ -101,6 +108,7 @@ describe('InfoLogger', function() { }); require('./public/user-actions-mocha'); + require('./public/shifter-based-actions-mocha'); require('./public/log-filter-actions-mocha'); require('./public/live-mode-mocha'); require('./public/query-mode-mocha'); @@ -114,7 +122,5 @@ describe('InfoLogger', function() { console.log('---------------------------------------------'); subprocess.kill(); closeServer(ilgServer); - }); }); - diff --git a/InfoLogger/test/public/shifter-based-actions-mocha.js b/InfoLogger/test/public/shifter-based-actions-mocha.js new file mode 100644 index 000000000..847e349ce --- /dev/null +++ b/InfoLogger/test/public/shifter-based-actions-mocha.js @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const assert = require('assert'); +const test = require('../mocha-index'); +const { INFOLOGGER_LEVEL_LIST } = require('../../public/constants/infologger-level.const'); +const { baseUrl, getShifterAuthQueryParams } = require('../test-utils.js'); + +describe('Shifter Based Actions Test Suite', async () => { + // eslint-disable-next-line init-declarations + let page; + const shifterQueryParams = getShifterAuthQueryParams(); + + before(() => { + ({ page } = test); + }); + + it('should successfully load a page with shifter access token and level set to 1', async () => { + await page.goto(`${baseUrl}?${shifterQueryParams}`, { waitUntil: 'networkidle0' }); + + const location = await page.evaluate(() => window.location); + const search = decodeURIComponent(location.search); + assert.strictEqual(search, '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'); + + const criterias = await page.evaluate(() => window.model.log.filter.criterias); + assert.strictEqual(criterias.level.max, 1); + }); + + it( + 'should successfully load page with shifter role and level 1 even if query parameter contains a higher level', + async () => { + await page.goto( + `${baseUrl}?${shifterQueryParams}&q={"level":{"max":6}}`, + { waitUntil: 'networkidle0' }, + ); + + const location = await page.evaluate(() => window.location); + const search = decodeURIComponent(location.search); + assert.strictEqual(search, '?q={"severity":{"in":"I W E F"},"level":{"max":1}}'); + + const criterias = await page.evaluate(() => window.model.log.filter.criterias); + assert.strictEqual(criterias.level.max, 1); + }, + ); + + it('should disable buttons for level filter if user is shifter but not admin', async () => { + const currentAccess = await page.evaluate(() => window.model.session.access); + + await page.evaluate(() => { + window.model.session.access = ['shifter']; + window.model.notify(); + }); + + for (const { label, index } of INFOLOGGER_LEVEL_LIST) { + const isLevelDisabled = await page.$eval(`#level-${index}`, (button) => button.classList.contains('disabled')); + assert.strictEqual( + isLevelDisabled, + label === 'Ops' ? false : true, + `Level ${label} should be ${label === 'Ops' ? 'enabled' : 'disabled'} for shifter access`, + ); + } + + // Restore access rights for the following tests + await page.evaluate((access) => { + window.model.session.access = access; + window.model.notify(); + }, currentAccess); + }); +}); diff --git a/InfoLogger/test/test-utils.js b/InfoLogger/test/test-utils.js new file mode 100644 index 000000000..dbfb0ec84 --- /dev/null +++ b/InfoLogger/test/test-utils.js @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +const testConfig = require('./test-config.js'); +const { O2TokenService } = require('@aliceo2/web-ui'); + +/** + * Generates a JWT token for testing purposes with the given payload + * @param {object} payload - payload to include in the token + * @param {number} payload.personid - user's person id + * @param {string} payload.username - user's username + * @param {string} payload.name - user's name + * @param {string} payload.access - user's access rights + * @returns {string} - generated JWT token + */ +const generateToken = ({ personid, username, name, access }) => { + const tokenService = new O2TokenService(testConfig.jwt); + const token = tokenService.generateToken(personid, username, name, access); + return token; +}; + +/** + * Generates a JWT token for testing purposes with shifter access rights + * @returns {string} - generated JWT token with shifter access rights + */ +const generateShifterAccessToken = () => { + const payload = { + personid: 2, + username: 'testshifter', + name: 'Test Shifter', + access: 'shifter,guest', + }; + return generateToken(payload); +}; + +/** + * Generates query parameters for authentication with shifter access rights + * @returns {string} - query parameters for authentication with shifter access rights + */ +const getShifterAuthQueryParams = () => { + const shiftToken = generateShifterAccessToken(); + return `personid=2&name=Test Shifter&username=testshifter&access=shifter&token=${shiftToken}`; +}; + +/** + * Base URL for the application used in tests, constructed from the test configuration + * and can be used to navigate to the application in tests + * @returns {string} - base URL for the application + */ +const baseUrl = `http://${testConfig.http.hostname}:${testConfig.http.port}/`; + +module.exports = { + getShifterAuthQueryParams, + baseUrl, +};