[MM-18152] Desktop notifications (#1040)

* temp

* add in html5 notification tests

* strip out custom permissions handling

* disable middle click

* validate as URI instead of URL

allow’s custom protocol’s to pass through

* add context isolation to new window requests

* add new permissions handling

* prevent setting user to away from quit/shutdown

* dispatch desktop notifications from renderer

* remove test code

* log desktop notification errors

* should deny as a last resort

* only trigger callback once
This commit is contained in:
Dean Whillier 2019-09-23 14:59:12 -04:00 committed by GitHub
parent 77d823a076
commit 761ef8d0e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 116 additions and 468 deletions

View file

@ -3,7 +3,7 @@
"provider": "generic",
"url": "https://releases.mattermost.com/desktop/"
}],
"appId": "com.mattermost.desktop",
"appId": "Mattermost.Desktop",
"artifactName": "${name}-${version}-${os}-${arch}.${ext}",
"directories": {
"buildResources": "resources",

View file

@ -18,7 +18,6 @@ import LoginModal from './LoginModal.jsx';
import MattermostView from './MattermostView.jsx';
import TabBar from './TabBar.jsx';
import HoveringURL from './HoveringURL.jsx';
import PermissionRequestDialog from './PermissionRequestDialog.jsx';
import Finder from './Finder.jsx';
import NewTeamModal from './NewTeamModal.jsx';
@ -333,8 +332,6 @@ export default class MainPage extends React.Component {
onSelect={this.handleSelect}
onAddServer={this.addServer}
showAddServerButton={this.props.showAddServerButton}
requestingPermission={this.props.requestingPermission}
onClickPermissionDialog={this.props.onClickPermissionDialog}
/>
</Row>
);
@ -421,16 +418,6 @@ export default class MainPage extends React.Component {
onLogin={this.handleLogin}
onCancel={this.handleLoginCancel}
/>
{this.props.teams.length === 1 && this.props.requestingPermission[0] ? // eslint-disable-line multiline-ternary
<PermissionRequestDialog
id='MainPage-permissionDialog'
placement='bottom'
{...this.props.requestingPermission[0]}
onClickAllow={this.props.onClickPermissionDialog.bind(null, 0, 'allow')}
onClickBlock={this.props.onClickPermissionDialog.bind(null, 0, 'block')}
onClickClose={this.props.onClickPermissionDialog.bind(null, 0, 'close')}
/> : null
}
<Grid fluid={true}>
{ tabsRow }
{ viewsRow }
@ -474,8 +461,6 @@ MainPage.propTypes = {
onSelectSpellCheckerLocale: PropTypes.func.isRequired,
deeplinkingUrl: PropTypes.string,
showAddServerButton: PropTypes.bool.isRequired,
requestingPermission: TabBar.propTypes.requestingPermission,
onClickPermissionDialog: PropTypes.func,
};
/* eslint-enable react/no-set-state */

View file

@ -10,6 +10,7 @@ import url from 'url';
import React from 'react';
import PropTypes from 'prop-types';
import {ipcRenderer, remote, shell} from 'electron';
import log from 'electron-log';
import contextMenu from '../js/contextMenu';
import Utils from '../../utils/util';
@ -23,6 +24,8 @@ const preloadJS = `file://${remote.app.getAppPath()}/browser/webview/mattermost_
const ERR_NOT_IMPLEMENTED = -11;
const U2F_EXTENSION_URL = 'chrome-extension://kmendfapggjehodndflmmgagdbamhnfd/u2f-comms.html';
const appIconURL = `file:///${remote.app.getAppPath()}/assets/appicon.png`;
export default class MattermostView extends React.Component {
constructor(props) {
super(props);
@ -36,6 +39,7 @@ export default class MattermostView extends React.Component {
};
this.handleUnreadCountChange = this.handleUnreadCountChange.bind(this);
this.dispatchNotification = this.dispatchNotification.bind(this);
this.reload = this.reload.bind(this);
this.clearCacheAndReload = this.clearCacheAndReload.bind(this);
this.focusOnWebView = this.focusOnWebView.bind(this);
@ -56,6 +60,27 @@ export default class MattermostView extends React.Component {
}
}
async dispatchNotification(title, body, channel, teamId, silent) {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
log.error('Notifications not granted');
return;
}
const notification = new Notification(title, {
body,
tag: body,
icon: appIconURL,
requireInteraction: false,
silent,
});
notification.onclick = () => {
this.webviewRef.current.send('notification-clicked', {channel, teamId});
};
notification.onerror = () => {
log.error('Notification failed to show');
};
}
componentDidMount() {
const self = this;
const webview = this.webviewRef.current;
@ -90,7 +115,7 @@ export default class MattermostView extends React.Component {
// Open link in browserWindow. for example, attached files.
webview.addEventListener('new-window', (e) => {
if (!Utils.isValidURL(e.url)) {
if (!Utils.isValidURI(e.url)) {
return;
}
const currentURL = url.parse(webview.getURL());
@ -108,7 +133,7 @@ export default class MattermostView extends React.Component {
shell.openExternal(e.url);
} else {
// New window should disable nodeIntegration.
window.open(e.url, remote.app.getName(), 'nodeIntegration=no, show=yes');
window.open(e.url, remote.app.getName(), 'nodeIntegration=no, contextIsolation=yes, show=yes');
}
} else {
// if the link is external, use default browser.
@ -153,13 +178,11 @@ export default class MattermostView extends React.Component {
});
break;
case 'onBadgeChange': {
const sessionExpired = event.args[0];
const unreadCount = event.args[1];
const mentionCount = event.args[2];
const isUnread = event.args[3];
const isMentioned = event.args[4];
self.handleUnreadCountChange(sessionExpired, unreadCount, mentionCount, isUnread, isMentioned);
self.handleUnreadCountChange(...event.args);
break;
}
case 'dispatchNotification': {
self.dispatchNotification(...event.args);
break;
}
case 'onNotificationClick':

View file

@ -1,90 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {Button, Glyphicon, Popover} from 'react-bootstrap';
const PERMISSIONS = {
media: {
description: 'Use your camera and microphone',
glyph: 'facetime-video',
},
geolocation: {
description: 'Know your location',
glyph: 'map-marker',
},
notifications: {
description: 'Show notifications',
glyph: 'bell',
},
midiSysex: {
description: 'Use your MIDI devices',
glyph: 'music',
},
pointerLock: {
description: 'Lock your mouse cursor',
glyph: 'hand-up',
},
fullscreen: {
description: 'Enter full screen',
glyph: 'resize-full',
},
openExternal: {
description: 'Open external',
glyph: 'new-window',
},
};
function glyph(permission) {
const data = PERMISSIONS[permission];
if (data) {
return data.glyph;
}
return 'alert';
}
function description(permission) {
const data = PERMISSIONS[permission];
if (data) {
return data.description;
}
return `Be granted "${permission}" permission`;
}
export default function PermissionRequestDialog(props) {
const {origin, permission, onClickAllow, onClickBlock, onClickClose, ...reft} = props;
return (
<Popover
className='PermissionRequestDialog'
{...reft}
>
<div
className='PermissionRequestDialog-content'
>
<p>{`${origin} wants to:`}</p>
<p className='PermissionRequestDialog-content-description'>
<Glyphicon glyph={glyph(permission)}/>
{description(permission)}
</p>
<p className='PermissionRequestDialog-content-buttons'>
<Button onClick={onClickAllow}>{'Allow'}</Button>
<Button onClick={onClickBlock}>{'Block'}</Button>
</p>
<Button
className='PermissionRequestDialog-content-close'
bsStyle='link'
onClick={onClickClose}
>{'×'}</Button>
</div>
</Popover>
);
}
PermissionRequestDialog.propTypes = {
origin: PropTypes.string,
permission: PropTypes.oneOf(['media', 'geolocation', 'notifications', 'midiSysex', 'pointerLock', 'fullscreen', 'openExternal']),
onClickAllow: PropTypes.func,
onClickBlock: PropTypes.func,
onClickClose: PropTypes.func,
};

View file

@ -3,9 +3,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {Glyphicon, Nav, NavItem, Overlay} from 'react-bootstrap';
import PermissionRequestDialog from './PermissionRequestDialog.jsx';
import {Glyphicon, Nav, NavItem} from 'react-bootstrap';
export default class TabBar extends React.Component { // need "this"
render() {
@ -41,24 +39,6 @@ export default class TabBar extends React.Component { // need "this"
);
}
const id = 'teamTabItem' + index;
const requestingPermission = this.props.requestingPermission[index];
const permissionOverlay = (
<Overlay
className='TabBar-permissionOverlay'
placement='bottom'
show={requestingPermission && this.props.activeKey === index}
target={() => this.refs[id]}
>
<PermissionRequestDialog
id={`${id}-permissionDialog`}
origin={requestingPermission ? requestingPermission.origin : null}
permission={requestingPermission ? requestingPermission.permission : null}
onClickAllow={this.props.onClickPermissionDialog.bind(null, index, 'allow')}
onClickBlock={this.props.onClickPermissionDialog.bind(null, index, 'block')}
onClickClose={this.props.onClickPermissionDialog.bind(null, index, 'close')}
/>
</Overlay>
);
// draggable=false is a workaround for https://github.com/mattermost/desktop/issues/667
// It would obstruct https://github.com/mattermost/desktop/issues/478
@ -79,7 +59,6 @@ export default class TabBar extends React.Component { // need "this"
</span>
{ ' ' }
{ badgeDiv }
{permissionOverlay}
</NavItem>);
});
if (this.props.showAddServerButton === true) {
@ -127,10 +106,5 @@ TabBar.propTypes = {
mentionCounts: PropTypes.array,
mentionAtActiveCounts: PropTypes.array,
showAddServerButton: PropTypes.bool,
requestingPermission: PropTypes.arrayOf(PropTypes.shape({
origin: PropTypes.string,
permission: PropTypes.string,
})),
onAddServer: PropTypes.func,
onClickPermissionDialog: PropTypes.func,
};

View file

@ -14,13 +14,14 @@ import React from 'react';
import ReactDOM from 'react-dom';
import {remote, ipcRenderer} from 'electron';
import utils from '../utils/util';
import Config from '../common/config';
import EnhancedNotification from './js/notification';
import MainPage from './components/MainPage.jsx';
import {createDataURL as createBadgeDataURL} from './js/badge';
Notification = EnhancedNotification; // eslint-disable-line no-global-assign, no-native-reassign
const config = new Config(remote.app.getPath('userData') + '/config.json', remote.getCurrentWindow().registryConfigData);
const teams = config.teams;
@ -31,9 +32,6 @@ if (teams.length === 0) {
remote.getCurrentWindow().loadFile('browser/settings.html');
}
const permissionRequestQueue = [];
const requestingPermission = new Array(teams.length);
const parsedURL = url.parse(window.location.href, true);
const initialIndex = parsedURL.query.index ? parseInt(parsedURL.query.index, 10) : 0;
@ -44,7 +42,6 @@ if (!parsedURL.query.index || parsedURL.query.index === null) {
config.on('update', (configData) => {
teams.splice(0, teams.length, ...configData.teams);
requestingPermission.length = teams.length;
});
config.on('synchronize', () => {
@ -134,53 +131,10 @@ function teamConfigChange(updatedTeams) {
config.set('teams', updatedTeams);
}
function feedPermissionRequest() {
const webviews = document.getElementsByTagName('webview');
const webviewOrigins = Array.from(webviews).map((w) => utils.getDomain(w.getAttribute('src')));
for (let index = 0; index < requestingPermission.length; index++) {
if (requestingPermission[index]) {
break;
}
for (const request of permissionRequestQueue) {
if (request.origin === webviewOrigins[index]) {
requestingPermission[index] = request;
break;
}
}
}
}
function handleClickPermissionDialog(index, status) {
const requesting = requestingPermission[index];
ipcRenderer.send('update-permission', requesting.origin, requesting.permission, status);
if (status === 'allow' || status === 'block') {
const newRequests = permissionRequestQueue.filter((request) => {
if (request.permission === requesting.permission && request.origin === requesting.origin) {
return false;
}
return true;
});
permissionRequestQueue.splice(0, permissionRequestQueue.length, ...newRequests);
} else if (status === 'close') {
const i = permissionRequestQueue.findIndex((e) => e.permission === requesting.permission && e.origin === requesting.origin);
permissionRequestQueue.splice(i, 1);
}
requestingPermission[index] = null;
feedPermissionRequest();
}
function handleSelectSpellCheckerLocale(locale) {
config.set('spellCheckerLocale', locale);
}
ipcRenderer.on('request-permission', (event, origin, permission) => {
if (permissionRequestQueue.length >= 100) {
return;
}
permissionRequestQueue.push({origin, permission});
feedPermissionRequest();
});
ReactDOM.render(
<MainPage
teams={teams}
@ -191,8 +145,6 @@ ReactDOM.render(
onSelectSpellCheckerLocale={handleSelectSpellCheckerLocale}
deeplinkingUrl={deeplinkingUrl}
showAddServerButton={config.enableServerManagement}
requestingPermission={requestingPermission}
onClickPermissionDialog={handleClickPermissionDialog}
/>,
document.getElementById('content')
);

View file

@ -3,16 +3,13 @@
// See LICENSE.txt for license information.
'use strict';
import {ipcRenderer, webFrame} from 'electron';
/* eslint-disable no-magic-numbers */
import EnhancedNotification from '../js/notification';
import {ipcRenderer, webFrame, remote} from 'electron';
const UNREAD_COUNT_INTERVAL = 1000;
//eslint-disable-next-line no-magic-numbers
const CLEAR_CACHE_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
Notification = EnhancedNotification; // eslint-disable-line no-global-assign, no-native-reassign
Reflect.deleteProperty(global.Buffer); // http://electron.atom.io/docs/tutorial/security/#buffer-global
function isReactAppInitialized() {
@ -50,6 +47,46 @@ window.addEventListener('load', () => {
});
});
// listen for messages from the webapp
window.addEventListener('message', ({origin, data: {type, message = {}} = {}} = {}) => {
if (origin !== window.location.origin) {
return;
}
switch (type) {
case 'webapp-ready': {
// register with the webapp to enable custom integration functionality
window.postMessage(
{
type: 'register-desktop',
message: {
version: remote.app.getVersion(),
},
},
window.location.origin
);
break;
}
case 'dispatch-notification': {
const {title, body, channel, teamId, silent} = message;
ipcRenderer.sendToHost('dispatchNotification', title, body, channel, teamId, silent);
break;
}
}
});
ipcRenderer.on('notification-clicked', (event, {channel, teamId}) => {
window.postMessage(
{
type: 'notification-clicked',
message: {
channel,
teamId,
},
},
window.location.origin
);
});
function hasClass(element, className) {
const rclass = /[\t\r\n\f]/g;
if ((' ' + element.className + ' ').replace(rclass, ' ').indexOf(className) > -1) {
@ -209,3 +246,5 @@ ipcRenderer.on('user-activity-update', (event, {userIsActive, isSystemEvent}) =>
setInterval(() => {
webFrame.clearCache();
}, CLEAR_CACHE_INTERVAL);
/* eslint-enable no-magic-numbers */

View file

@ -25,8 +25,6 @@ import appMenu from './main/menus/app';
import trayMenu from './main/menus/tray';
import downloadURL from './main/downloadURL';
import allowProtocolDialog from './main/allowProtocolDialog';
import PermissionManager from './main/PermissionManager';
import permissionRequestHandler from './main/permissionRequestHandler';
import AppStateManager from './main/AppStateManager';
import initCookieManager from './main/cookieManager';
import {shouldBeHiddenOnStartup} from './main/utils';
@ -61,7 +59,6 @@ let spellChecker = null;
let deeplinkingUrl = null;
let scheme = null;
let appState = null;
let permissionManager = null;
let registryConfig = null;
let config = null;
let trayIcon = null;
@ -233,12 +230,6 @@ function handleConfigUpdate(configData) {
});
}
if (permissionManager) {
const trustedURLs = config.teams.map((team) => team.url);
permissionManager.setTrustedURLs(trustedURLs);
ipcMain.emit('update-dict', true, config.spellCheckerLocale);
}
ipcMain.emit('update-menu', true, configData);
}
@ -571,10 +562,40 @@ function initializeAfterAppReady() {
ipcMain.emit('update-dict');
const permissionFile = path.join(app.getPath('userData'), 'permission.json');
const trustedURLs = config.teams.map((team) => team.url);
permissionManager = new PermissionManager(permissionFile, trustedURLs);
session.defaultSession.setPermissionRequestHandler(permissionRequestHandler(mainWindow, permissionManager));
// supported permission types
const supportedPermissionTypes = [
'media',
'geolocation',
'notifications',
'fullscreen',
'openExternal',
];
// handle permission requests
// - approve if a supported permission type and the request comes from the renderer or one of the defined servers
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
// is the requested permission type supported?
if (!supportedPermissionTypes.includes(permission)) {
callback(false);
return;
}
// is the request coming from the renderer?
if (webContents.id === mainWindow.webContents.id) {
callback(true);
return;
}
// get the requesting webContents url
const requestingURL = webContents.getURL();
// is the target url trusted?
const matchingTeamIndex = config.teams.findIndex((team) => {
return requestingURL.startsWith(team.url);
});
callback(matchingTeamIndex >= 0);
});
}
//

View file

@ -1,81 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import utils from '../utils/util';
import * as Validator from './Validator';
const PERMISSION_GRANTED = 'granted';
const PERMISSION_DENIED = 'denied';
export default class PermissionManager {
constructor(file, trustedURLs = []) {
this.file = file;
this.setTrustedURLs(trustedURLs);
if (fs.existsSync(file)) {
try {
this.permissions = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
this.permissions = Validator.validatePermissionsList(this.permissions);
if (!this.permissions) {
throw new Error('Provided permissions file does not validate, using defaults instead.');
}
} catch (err) {
console.error(err);
this.permissions = {};
}
} else {
this.permissions = {};
}
}
writeFileSync() {
fs.writeFileSync(this.file, JSON.stringify(this.permissions, null, ' '));
}
grant(origin, permission) {
if (!this.permissions[origin]) {
this.permissions[origin] = {};
}
this.permissions[origin][permission] = PERMISSION_GRANTED;
this.writeFileSync();
}
deny(origin, permission) {
if (!this.permissions[origin]) {
this.permissions[origin] = {};
}
this.permissions[origin][permission] = PERMISSION_DENIED;
this.writeFileSync();
}
clear(origin, permission) {
delete this.permissions[origin][permission];
}
isGranted(origin, permission) {
if (this.trustedOrigins[origin] === true) {
return true;
}
if (this.permissions[origin]) {
return this.permissions[origin][permission] === PERMISSION_GRANTED;
}
return false;
}
isDenied(origin, permission) {
if (this.permissions[origin]) {
return this.permissions[origin][permission] === PERMISSION_DENIED;
}
return false;
}
setTrustedURLs(trustedURLs) {
this.trustedOrigins = {};
for (const url of trustedURLs) {
const origin = utils.getDomain(url);
this.trustedOrigins[origin] = true;
}
}
}

View file

@ -66,7 +66,6 @@ export default class UserActivityMonitor extends EventEmitter {
// NOTE: electron.powerMonitor cannot be referenced until the app is ready
electron.powerMonitor.on('suspend', this.handleSystemGoingAway);
electron.powerMonitor.on('resume', this.handleSystemComingBack);
electron.powerMonitor.on('shutdown', this.handleSystemGoingAway);
electron.powerMonitor.on('lock-screen', this.handleSystemGoingAway);
electron.powerMonitor.on('unlock-screen', this.handleSystemComingBack);
@ -87,7 +86,6 @@ export default class UserActivityMonitor extends EventEmitter {
stopMonitoring() {
electron.powerMonitor.off('suspend', this.handleSystemGoingAway);
electron.powerMonitor.off('resume', this.handleSystemComingBack);
electron.powerMonitor.off('shutdown', this.handleSystemGoingAway);
electron.powerMonitor.off('lock-screen', this.handleSystemGoingAway);
electron.powerMonitor.off('unlock-screen', this.handleSystemComingBack);

View file

@ -62,16 +62,6 @@ const configDataSchemaV1 = Joi.object({
spellCheckerLocale: Joi.string().regex(/^[a-z]{2}-[A-Z]{2}$/).default('en-US'),
});
// eg. data['https://community.mattermost.com']['notifications'] = 'granted';
// eg. data['http://localhost:8065']['notifications'] = 'denied';
const permissionsSchema = Joi.object().pattern(
Joi.string().uri(),
Joi.object().pattern(
Joi.string(),
Joi.any().valid('granted', 'denied'),
),
);
// eg. data['community.mattermost.com'] = { data: 'certificate data', issuerName: 'COMODO RSA Domain Validation Secure Server CA'};
const certificateStoreSchema = Joi.object().pattern(
Joi.string().uri(),
@ -124,11 +114,6 @@ export function validateV1ConfigData(data) {
return validateAgainstSchema(data, configDataSchemaV1);
}
// validate permission.json
export function validatePermissionsList(data) {
return validateAgainstSchema(data, permissionsSchema);
}
// validate certificate.json
export function validateCertificateStore(data) {
return validateAgainstSchema(data, certificateStoreSchema);

View file

@ -53,6 +53,7 @@ function createMainWindow(config, options) {
nodeIntegration: true,
contextIsolation: false,
webviewTag: true,
disableBlinkFeatures: 'Auxclick',
},
});

View file

@ -1,67 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {URL} from 'url';
import {ipcMain} from 'electron';
function dequeueRequests(requestQueue, permissionManager, origin, permission, status) {
switch (status) {
case 'allow':
permissionManager.grant(origin, permission);
break;
case 'block':
permissionManager.deny(origin, permission);
break;
default:
break;
}
if (status === 'allow' || status === 'block') {
const newQueue = requestQueue.filter((request) => {
if (request.origin === origin && request.permission === permission) {
request.callback(status === 'allow');
return false;
}
return true;
});
requestQueue.splice(0, requestQueue.length, ...newQueue);
} else {
const index = requestQueue.findIndex((request) => {
return request.origin === origin && request.permission === permission;
});
requestQueue[index].callback(false);
requestQueue.splice(index, 1);
}
}
export default function permissionRequestHandler(mainWindow, permissionManager) {
const requestQueue = [];
ipcMain.on('update-permission', (event, origin, permission, status) => {
dequeueRequests(requestQueue, permissionManager, origin, permission, status);
});
return (webContents, permission, callback) => {
let targetURL;
try {
targetURL = new URL(webContents.getURL());
} catch (err) {
console.log(err);
callback(false);
return;
}
if (permissionManager.isDenied(targetURL.origin, permission)) {
callback(false);
return;
}
if (permissionManager.isGranted(targetURL.origin, permission)) {
callback(true);
return;
}
requestQueue.push({
origin: targetURL.origin,
permission,
callback,
});
mainWindow.webContents.send('request-permission', targetURL.origin, permission);
};
}

View file

@ -1,92 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import fs from 'fs';
import path from 'path';
import env from '../modules/environment';
import PermissionManager from '../../src/main/PermissionManager';
const permissionFile = path.join(env.userDataDir, 'permission.json');
const ORIGIN1 = 'https://example.com';
const PERMISSION1 = 'notifications';
const ORIGIN2 = 'https://example2.com';
const PERMISSION2 = 'test';
const DENIED = 'denied';
const GRANTED = 'granted';
describe('PermissionManager', function() {
beforeEach(function(done) {
fs.unlink(permissionFile, () => {
done();
});
});
it('should grant a permission for an origin', function() {
const manager = new PermissionManager(permissionFile);
manager.isGranted(ORIGIN1, PERMISSION1).should.be.false;
manager.isDenied(ORIGIN1, PERMISSION1).should.be.false;
manager.grant(ORIGIN1, PERMISSION1);
manager.isGranted(ORIGIN1, PERMISSION1).should.be.true;
manager.isDenied(ORIGIN1, PERMISSION1).should.be.false;
manager.isGranted(ORIGIN2, PERMISSION1).should.be.false;
manager.isGranted(ORIGIN1, PERMISSION2).should.be.false;
});
it('should deny a permission for an origin', function() {
const manager = new PermissionManager(permissionFile);
manager.isGranted(ORIGIN1, PERMISSION1).should.be.false;
manager.isDenied(ORIGIN1, PERMISSION1).should.be.false;
manager.deny(ORIGIN1, PERMISSION1);
manager.isGranted(ORIGIN1, PERMISSION1).should.be.false;
manager.isDenied(ORIGIN1, PERMISSION1).should.be.true;
manager.isDenied(ORIGIN2, PERMISSION1).should.be.false;
manager.isDenied(ORIGIN1, PERMISSION2).should.be.false;
});
it('should save permissions to the file', function() {
const manager = new PermissionManager(permissionFile);
manager.deny(ORIGIN1, PERMISSION1);
manager.grant(ORIGIN2, PERMISSION2);
JSON.parse(fs.readFileSync(permissionFile)).should.deep.equal({
[ORIGIN1]: {
[PERMISSION1]: DENIED,
},
[ORIGIN2]: {
[PERMISSION2]: GRANTED,
},
});
});
it('should restore permissions from the file', function() {
fs.writeFileSync(permissionFile, JSON.stringify({
[ORIGIN1]: {
[PERMISSION1]: DENIED,
},
[ORIGIN2]: {
[PERMISSION2]: GRANTED,
},
}));
const manager = new PermissionManager(permissionFile);
manager.isDenied(ORIGIN1, PERMISSION1).should.be.true;
manager.isGranted(ORIGIN2, PERMISSION2).should.be.true;
});
it('should allow permissions for trusted URLs', function() {
fs.writeFileSync(permissionFile, JSON.stringify({}));
const manager = new PermissionManager(permissionFile, [ORIGIN1, ORIGIN2]);
manager.isGranted(ORIGIN1, PERMISSION1).should.be.true;
manager.isGranted(ORIGIN2, PERMISSION2).should.be.true;
});
});