restructure: move bin/ and tests/ to src/

Also add symlinks from the old `bin/` and `tests/` locations to avoid
breaking scripts and other tools.

Motivations:

  * Scripts and tests no longer have to do dubious things like:

        require('ep_etherpad-lite/node_modules/foo')

    to access packages installed as dependencies in
    `src/package.json`.

  * Plugins can access the backend test helper library in a non-hacky
    way:

        require('ep_etherpad-lite/tests/backend/common')

  * We can delete the top-level `package.json` without breaking our
    ability to lint the files in `bin/` and `tests/`.

    Deleting the top-level `package.json` has downsides: It will cause
    `npm` to print warnings whenever plugins are installed, npm will
    no longer be able to enforce a plugin's peer dependency on
    ep_etherpad-lite, and npm will keep deleting the
    `node_modules/ep_etherpad-lite` symlink that points to `../src`.

    But there are significant upsides to deleting the top-level
    `package.json`: It will drastically speed up plugin installation
    because `npm` doesn't have to recursively walk the dependencies in
    `src/package.json`. Also, deleting the top-level `package.json`
    avoids npm's horrible dependency hoisting behavior (where it moves
    stuff from `src/node_modules/` to the top-level `node_modules/`
    directory). Dependency hoisting causes numerous mysterious
    problems such as silent failures in `npm outdated` and `npm
    update`. Dependency hoisting also breaks plugins that do:

        require('ep_etherpad-lite/node_modules/foo')
This commit is contained in:
John McLear 2021-02-03 12:08:43 +00:00 committed by Richard Hansen
parent efde0b787a
commit 2ea8ea1275
146 changed files with 191 additions and 1161 deletions

View file

@ -0,0 +1,60 @@
'use strict';
const apiHandler = require('../../node/handler/APIHandler');
const log4js = require('log4js');
const process = require('process');
const server = require('../../node/server');
const settings = require('../../node/utils/Settings');
const supertest = require('supertest');
const webaccess = require('../../node/hooks/express/webaccess');
const backups = {};
let inited = false;
exports.apiKey = apiHandler.exportedForTestingOnly.apiKey;
exports.agent = null;
exports.baseUrl = null;
exports.httpServer = null;
exports.logger = log4js.getLogger('test');
const logLevel = exports.logger.level;
// Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions.
// https://github.com/mochajs/mocha/issues/2640
process.on('unhandledRejection', (reason, promise) => { throw reason; });
exports.init = async function () {
if (inited) return exports.agent;
inited = true;
if (!logLevel.isLessThanOrEqualTo(log4js.levels.DEBUG)) {
exports.logger.warn('Disabling non-test logging for the duration of the test. ' +
'To enable non-test logging, change the loglevel setting to DEBUG.');
log4js.setGlobalLogLevel(log4js.levels.OFF);
exports.logger.setLevel(logLevel);
}
// Note: This is only a shallow backup.
backups.settings = Object.assign({}, settings);
// Start the Etherpad server on a random unused port.
settings.port = 0;
settings.ip = 'localhost';
exports.httpServer = await server.start();
exports.baseUrl = `http://localhost:${exports.httpServer.address().port}`;
exports.logger.debug(`HTTP server at ${exports.baseUrl}`);
// Create a supertest user agent for the HTTP server.
exports.agent = supertest(exports.baseUrl);
// Speed up authn tests.
backups.authnFailureDelayMs = webaccess.authnFailureDelayMs;
webaccess.authnFailureDelayMs = 0;
after(async function () {
webaccess.authnFailureDelayMs = backups.authnFailureDelayMs;
// Note: This does not unset settings that were added.
Object.assign(settings, backups.settings);
log4js.setGlobalLogLevel(logLevel);
await server.exit();
});
return exports.agent;
};

View file

@ -0,0 +1,68 @@
/*
* Fuzz testing the import endpoint
* Usage: node fuzzImportTest.js
*/
const common = require('./common');
const host = `http://${settings.ip}:${settings.port}`;
const request = require('request');
const froth = require('mocha-froth');
const settings = require('../container/loadSettings').loadSettings();
const apiKey = common.apiKey;
const apiVersion = 1;
const testPadId = `TEST_fuzz${makeid()}`;
const endPoint = function (point, version) {
version = version || apiVersion;
return `/api/${version}/${point}?apikey=${apiKey}`;
};
console.log('Testing against padID', testPadId);
console.log(`To watch the test live visit ${host}/p/${testPadId}`);
console.log('Tests will start in 5 seconds, click the URL now!');
setTimeout(() => {
for (let i = 1; i < 1000000; i++) { // 1M runs
setTimeout(() => {
runTest(i);
}, i * 100); // 100 ms
}
}, 5000); // wait 5 seconds
function runTest(number) {
request(`${host + endPoint('createPad')}&padID=${testPadId}`, (err, res, body) => {
const req = request.post(`${host}/p/${testPadId}/import`, (err, res, body) => {
if (err) {
throw new Error('FAILURE', err);
} else {
console.log('Success');
}
});
let fN = '/test.txt';
let cT = 'text/plain';
// To be more aggressive every other test we mess with Etherpad
// We provide a weird file name and also set a weird contentType
if (number % 2 == 0) {
fN = froth().toString();
cT = froth().toString();
}
const form = req.form();
form.append('file', froth().toString(), {
filename: fN,
contentType: cT,
});
});
}
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

View file

@ -0,0 +1,75 @@
/**
* API specs
*
* Tests for generic overarching HTTP API related features not related to any
* specific part of the data model or domain. For example: tests for versioning
* and openapi definitions.
*/
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const supertest = require('supertest');
const validateOpenAPI = require('openapi-schema-validation').validate;
const api = supertest(`http://${settings.ip}:${settings.port}`);
const apiKey = common.apiKey;
let apiVersion = 1;
const testPadId = makeid();
describe(__filename, function () {
describe('API Versioning', function () {
it('errors if can not connect', function (done) {
api
.get('/api/')
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
});
});
describe('OpenAPI definition', function () {
it('generates valid openapi definition document', function (done) {
api
.get('/api/openapi.json')
.expect((res) => {
const {valid, errors} = validateOpenAPI(res.body, 3);
if (!valid) {
const prettyErrors = JSON.stringify(errors, null, 2);
throw new Error(`Document is not valid OpenAPI. ${errors.length} validation errors:\n${prettyErrors}`);
}
return;
})
.expect(200, done);
});
});
describe('jsonp support', function () {
it('supports jsonp calls', function (done) {
api
.get(`${endPoint('createPad')}&jsonp=jsonp_1&padID=${testPadId}`)
.expect((res) => {
if (!res.text.match('jsonp_1')) throw new Error('no jsonp call seen');
})
.expect('Content-Type', /javascript/)
.expect(200, done);
});
});
});
var endPoint = function (point) {
return `/api/${apiVersion}/${point}?apikey=${apiKey}`;
};
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

View file

@ -0,0 +1,111 @@
'use strict';
/*
* This file is copied & modified from <basedir>/tests/backend/specs/api/pad.js
*
* TODO: maybe unify those two files and merge in a single one.
*/
const common = require('../../common');
const fs = require('fs');
const settings = require('../../../../node/utils/Settings');
const supertest = require('supertest');
const api = supertest(`http://${settings.ip}:${settings.port}`);
const apiKey = common.apiKey;
let apiVersion = 1;
const testPadId = makeid();
describe(__filename, function () {
describe('Connectivity For Character Encoding', function () {
it('can connect', function (done) {
api.get('/api/')
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('API Versioning', function () {
it('finds the version tag', function (done) {
api.get('/api/')
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
});
});
describe('Permission', function () {
it('errors with invalid APIKey', function (done) {
// This is broken because Etherpad doesn't handle HTTP codes properly see #2343
// If your APIKey is password you deserve to fail all tests anyway
const permErrorURL = `/api/${apiVersion}/createPad?apikey=password&padID=test`;
api.get(permErrorURL)
.expect(401, done);
});
});
describe('createPad', function () {
it('creates a new Pad', function (done) {
api.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setHTML', function () {
it('Sets the HTML of a Pad attempting to weird utf8 encoded content', function (done) {
fs.readFile('../tests/backend/specs/api/emojis.html', 'utf8', (err, html) => {
api.post(endPoint('setHTML'))
.send({
padID: testPadId,
html,
})
.expect((res) => {
if (res.body.code !== 0) throw new Error("Can't set HTML properly");
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
describe('getHTML', function () {
it('get the HTML of Pad with emojis', function (done) {
api.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.html.indexOf('&#127484') === -1) {
throw new Error('Unable to get the HTML');
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
/*
End of test
*/
var endPoint = function (point, version) {
version = version || apiVersion;
return `/api/${version}/${point}?apikey=${apiKey}`;
};
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 10; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

View file

@ -0,0 +1,110 @@
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const supertest = require('supertest');
const api = supertest(`http://${settings.ip}:${settings.port}`);
const apiKey = common.apiKey;
let apiVersion = 1;
let authorID = '';
const padID = makeid();
const timestamp = Date.now();
describe(__filename, function () {
describe('API Versioning', function () {
it('errors if can not connect', function (done) {
api.get('/api/')
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
});
});
// BEGIN GROUP AND AUTHOR TESTS
// ///////////////////////////////////
// ///////////////////////////////////
/* Tests performed
-> createPad(padID)
-> createAuthor([name]) -- should return an authorID
-> appendChatMessage(padID, text, authorID, time)
-> getChatHead(padID)
-> getChatHistory(padID)
*/
describe('createPad', function () {
it('creates a new Pad', function (done) {
api.get(`${endPoint('createPad')}&padID=${padID}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('createAuthor', function () {
it('Creates an author with a name set', function (done) {
api.get(endPoint('createAuthor'))
.expect((res) => {
if (res.body.code !== 0 || !res.body.data.authorID) throw new Error('Unable to create author');
authorID = res.body.data.authorID; // we will be this author for the rest of the tests
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('appendChatMessage', function () {
it('Adds a chat message to the pad', function (done) {
api.get(`${endPoint('appendChatMessage')}&padID=${padID}&text=blalblalbha&authorID=${authorID}&time=${timestamp}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create chat message');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getChatHead', function () {
it('Gets the head of chat', function (done) {
api.get(`${endPoint('getChatHead')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.chatHead !== 0) throw new Error('Chat Head Length is wrong');
if (res.body.code !== 0) throw new Error('Unable to get chat head');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getChatHistory', function () {
it('Gets Chat History of a Pad', function (done) {
api.get(`${endPoint('getChatHistory')}&padID=${padID}`)
.expect((res) => {
if (res.body.data.messages.length !== 1) throw new Error('Chat History Length is wrong');
if (res.body.code !== 0) throw new Error('Unable to get chat history');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
var endPoint = function (point) {
return `/api/${apiVersion}/${point}?apikey=${apiKey}`;
};
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,71 @@
/*
* Fuzz testing the import endpoint
*/
/*
const common = require('../../common');
const froth = require('mocha-froth');
const request = require('request');
const settings = require('../../../container/loadSettings.js').loadSettings();
const host = "http://" + settings.ip + ":" + settings.port;
const apiKey = common.apiKey;
var apiVersion = 1;
var testPadId = "TEST_fuzz" + makeid();
var endPoint = function(point, version){
version = version || apiVersion;
return '/api/'+version+'/'+point+'?apikey='+apiKey;
}
//console.log("Testing against padID", testPadId);
//console.log("To watch the test live visit " + host + "/p/" + testPadId);
//console.log("Tests will start in 5 seconds, click the URL now!");
setTimeout(function(){
for (let i=1; i<5; i++) { // 5000 runs
setTimeout( function timer(){
runTest(i);
}, i*100 ); // 100 ms
}
process.exit(0);
},5000); // wait 5 seconds
function runTest(number){
request(host + endPoint('createPad') + '&padID=' + testPadId, function(err, res, body){
var req = request.post(host + '/p/'+testPadId+'/import', function (err, res, body) {
if (err) {
throw new Error("FAILURE", err);
}else{
console.log("Success");
}
});
var fN = '/tmp/fuzztest.txt';
var cT = 'text/plain';
if (number % 2 == 0) {
fN = froth().toString();
cT = froth().toString();
}
let form = req.form();
form.append('file', froth().toString(), {
filename: fN,
contentType: cT
});
console.log("here");
});
}
function makeid() {
var text = "";
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for( var i=0; i < 5; i++ ){
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
*/

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

View file

@ -0,0 +1,319 @@
'use strict';
/*
* ACHTUNG: there is a copied & modified version of this file in
* <basedir>/tests/container/spacs/api/pad.js
*
* TODO: unify those two files, and merge in a single one.
*/
const common = require('../../common');
const settings = require('../../../container/loadSettings.js').loadSettings();
const supertest = require('supertest');
const api = supertest(`http://${settings.ip}:${settings.port}`);
const apiKey = common.apiKey;
const apiVersion = 1;
const testImports = {
'malformed': {
input: '<html><body><li>wtf</ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>wtf<br><br></body></html>',
wantText: 'wtf\n\n',
disabled: true,
},
'nonelistiteminlist #3620': {
input: '<html><body><ul>test<li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet">test<li>FOO</ul><br></body></html>',
wantText: '\ttest\n\t* FOO\n\n',
disabled: true,
},
'whitespaceinlist #3620': {
input: '<html><body><ul> <li>FOO</li></ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="bullet"><li>FOO</ul><br></body></html>',
wantText: '\t* FOO\n\n',
},
'prefixcorrectlinenumber': {
input: '<html><body><ol><li>should be 1</li><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li><li>should be 2</ol><br></body></html>',
wantText: '\t1. should be 1\n\t2. should be 2\n\n',
},
'prefixcorrectlinenumbernested': {
input: '<html><body><ol><li>should be 1</li><ol><li>foo</li></ol><li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1<ol start="2" class="number"><li>foo</ol><li>should be 2</ol><br></body></html>',
wantText: '\t1. should be 1\n\t\t1.1. foo\n\t2. should be 2\n\n',
},
/*
"prefixcorrectlinenumber when introduced none list item - currently not supported see #3450": {
input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol start="1" class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
}
,
"newlinesshouldntresetlinenumber #2194": {
input: '<html><body><ol><li>should be 1</li>test<li>should be 2</li></ol></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ol class="number"><li>should be 1</li>test<li>should be 2</li></ol><br></body></html>',
wantText: '\t1. should be 1\n\ttest\n\t2. should be 2\n\n',
}
*/
'ignoreAnyTagsOutsideBody': {
description: 'Content outside body should be ignored',
input: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>empty<br><br></body></html>',
wantText: 'empty\n\n',
},
'indentedListsAreNotBullets': {
description: 'Indented lists are represented with tabs and without bullets',
input: '<html><body><ul class="indent"><li>indent</li><li>indent</ul></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><ul class="indent"><li>indent</li><li>indent</ul><br></body></html>',
wantText: '\tindent\n\tindent\n\n',
},
'lineWithMultipleSpaces': {
description: 'Multiple spaces should be collapsed',
input: '<html><body>Text with more than one space.<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with more than one space.<br><br></body></html>',
wantText: 'Text with more than one space.\n\n',
},
'lineWithMultipleNonBreakingAndNormalSpaces': {
// XXX the HTML between "than" and "one" looks strange
description: 'non-breaking space should be preserved, but can be replaced when it',
input: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Text with&nbsp; more&nbsp;&nbsp; than&nbsp; one space.<br><br></body></html>',
wantText: 'Text with more than one space.\n\n',
},
'multiplenbsp': {
description: 'Multiple non-breaking space should be preserved',
input: '<html><body>&nbsp;&nbsp;<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;&nbsp;<br><br></body></html>',
wantText: ' \n\n',
},
'multipleNonBreakingSpaceBetweenWords': {
description: 'A normal space is always inserted before a word',
input: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp;&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'nonBreakingSpacePreceededBySpaceBetweenWords': {
description: 'A non-breaking space preceded by a normal space',
input: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp;word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'nonBreakingSpaceFollowededBySpaceBetweenWords': {
description: 'A non-breaking space followed by a normal space',
input: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br><br></body></html>',
wantText: ' word1 word2 word3\n\n',
},
'spacesAfterNewline': {
description: 'Collapse spaces that follow a newline',
input: '<!doctype html><html><body>something<br> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantText: 'something\nsomething\n\n',
},
'spacesAfterNewlineP': {
description: 'Collapse spaces that follow a paragraph',
input: '<!doctype html><html><body>something<p></p> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantText: 'something\n\nsomething\n\n',
},
'spacesAtEndOfLine': {
description: 'Collapse spaces that preceed/follow a newline',
input: '<html><body>something <br> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>something<br><br></body></html>',
wantText: 'something\nsomething\n\n',
},
'spacesAtEndOfLineP': {
description: 'Collapse spaces that preceed/follow a paragraph',
input: '<html><body>something <p></p> something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>something<br><br></body></html>',
wantText: 'something\n\nsomething\n\n',
},
'nonBreakingSpacesAfterNewlines': {
description: 'Don\'t collapse non-breaking spaces that follow a newline',
input: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br>&nbsp;&nbsp; something<br><br></body></html>',
wantText: 'something\n something\n\n',
},
'nonBreakingSpacesAfterNewlinesP': {
description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
input: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>something<br><br>&nbsp;&nbsp; something<br><br></body></html>',
wantText: 'something\n\n something\n\n',
},
'collapseSpacesInsideElements': {
description: 'Preserve only one space when multiple are present',
input: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'collapseSpacesAcrossNewlines': {
description: 'Newlines and multiple spaces across newlines should be collapsed',
input: `
<html><body>Need
<span> more </span>
space
<i> s </i>
!<br></body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'multipleNewLinesAtBeginning': {
description: 'Multiple new lines and paragraphs at the beginning should be preserved',
input: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body><br><br><br><br>first line<br><br>second line<br><br></body></html>',
wantText: '\n\n\n\nfirst line\n\nsecond line\n\n',
},
'multiLineParagraph': {
description: 'A paragraph with multiple lines should not loose spaces when lines are combined',
input: `<html><body>
<p>
а б в г ґ д е є ж з и і ї й к л м н о
п р с т у ф х ц ч ш щ ю я ь
</p>
</body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086; &#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь\n\n',
},
'multiLineParagraphWithPre': {
// XXX why is there &nbsp; before "in"?
description: 'lines in preformatted text should be kept intact',
input: `<html><body>
<p>
а б в г ґ д е є ж з и і ї й к л м н о<pre>multiple
lines
in
pre
</pre></p><p>п р с т у ф х ц ч ш щ ю я
ь</p>
</body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>&#1072; &#1073; &#1074; &#1075; &#1169; &#1076; &#1077; &#1108; &#1078; &#1079; &#1080; &#1110; &#1111; &#1081; &#1082; &#1083; &#1084; &#1085; &#1086;<br>multiple<br>&nbsp;&nbsp; lines<br>&nbsp;in<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; pre<br><br>&#1087; &#1088; &#1089; &#1090; &#1091; &#1092; &#1093; &#1094; &#1095; &#1096; &#1097; &#1102; &#1103; &#1100;<br><br></body></html>',
wantText: 'а б в г ґ д е є ж з и і ї й к л м н о\nmultiple\n lines\n in\n pre\n\nп р с т у ф х ц ч ш щ ю я ь\n\n',
},
'preIntroducesASpace': {
description: 'pre should be on a new line not preceded by a space',
input: `<html><body><p>
1
<pre>preline
</pre></p></body></html>`,
wantHTML: '<!DOCTYPE HTML><html><body>1<br>preline<br><br><br></body></html>',
wantText: '1\npreline\n\n\n',
},
'dontDeleteSpaceInsideElements': {
description: 'Preserve spaces inside elements',
input: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceOutsideElements': {
description: 'Preserve spaces outside elements',
input: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s</em> !<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceAtEndOfElement': {
description: 'Preserve spaces at the end of an element',
input: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space <em>s </em>!<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
'dontDeleteSpaceAtBeginOfElements': {
description: 'Preserve spaces at the start of an element',
input: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
wantHTML: '<!DOCTYPE HTML><html><body>Need more space<em> s</em> !<br><br></body></html>',
wantText: 'Need more space s !\n\n',
},
};
describe(__filename, function () {
Object.keys(testImports).forEach((testName) => {
describe(testName, function () {
const testPadId = makeid();
const test = testImports[testName];
if (test.disabled) {
return xit(`DISABLED: ${testName}`, function (done) {
done();
});
}
it('createPad', function (done) {
api.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
it('setHTML', function (done) {
api.get(`${endPoint('setHTML')}&padID=${testPadId}&html=${encodeURIComponent(test.input)}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error(`Error:${testName}`);
})
.expect('Content-Type', /json/)
.expect(200, done);
});
it('getHTML', function (done) {
api.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect((res) => {
const gotHtml = res.body.data.html;
if (gotHtml !== test.wantHTML) {
throw new Error(`HTML received from export is not the one we were expecting.
Test Name:
${testName}
Got:
${JSON.stringify(gotHtml)}
Want:
${JSON.stringify(test.wantHTML)}
Which is a different version of the originally imported one:
${test.input}`);
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
it('getText', function (done) {
api.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect((res) => {
const gotText = res.body.data.text;
if (gotText !== test.wantText) {
throw new Error(`Text received from export is not the one we were expecting.
Test Name:
${testName}
Got:
${JSON.stringify(gotText)}
Want:
${JSON.stringify(test.wantText)}
Which is a different version of the originally imported one:
${test.input}`);
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
});
function endPoint(point, version) {
version = version || apiVersion;
return `/api/${version}/${point}?apikey=${apiKey}`;
}
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

View file

@ -0,0 +1,369 @@
'use strict';
/*
* Import and Export tests for the /p/whateverPadId/import and /p/whateverPadId/export endpoints.
*/
const assert = require('assert').strict;
const common = require('../../common');
const fs = require('fs');
const settings = require('../../../../node/utils/Settings');
const superagent = require('superagent');
const padManager = require('../../../../node/db/PadManager');
const plugins = require('../../../../static/js/pluginfw/plugin_defs');
const padText = fs.readFileSync('../tests/backend/specs/api/test.txt');
const etherpadDoc = fs.readFileSync('../tests/backend/specs/api/test.etherpad');
const wordDoc = fs.readFileSync('../tests/backend/specs/api/test.doc');
const wordXDoc = fs.readFileSync('../tests/backend/specs/api/test.docx');
const odtDoc = fs.readFileSync('../tests/backend/specs/api/test.odt');
const pdfDoc = fs.readFileSync('../tests/backend/specs/api/test.pdf');
let agent;
const apiKey = common.apiKey;
const apiVersion = 1;
const testPadId = makeid();
const testPadIdEnc = encodeURIComponent(testPadId);
describe(__filename, function () {
before(async function () { agent = await common.init(); });
describe('Connectivity', function () {
it('can connect', async function () {
await agent.get('/api/')
.expect(200)
.expect('Content-Type', /json/);
});
});
describe('API Versioning', function () {
it('finds the version tag', async function () {
await agent.get('/api/')
.expect(200)
.expect((res) => assert(res.body.currentVersion));
});
});
/*
Tests
-----
Test.
/ Create a pad
/ Set pad contents
/ Try export pad in various formats
/ Get pad contents and ensure it matches imported contents
Test.
/ Try to export a pad that doesn't exist // Expect failure
Test.
/ Try to import an unsupported file to a pad that exists
-- TODO: Test.
Try to import to a file and abort it half way through
Test.
Try to import to files of varying size.
Example Curl command for testing import URI:
curl -s -v --form file=@/home/jose/test.txt http://127.0.0.1:9001/p/foo/import
*/
describe('Imports and Exports', function () {
const backups = {};
beforeEach(async function () {
backups.hooks = {};
for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {
backups.hooks[hookName] = plugins.hooks[hookName];
plugins.hooks[hookName] = [];
}
// Note: This is a shallow copy.
backups.settings = Object.assign({}, settings);
settings.requireAuthentication = false;
settings.requireAuthorization = false;
settings.users = {user: {password: 'user-password'}};
});
afterEach(async function () {
Object.assign(plugins.hooks, backups.hooks);
// Note: This does not unset settings that were added.
Object.assign(settings, backups.settings);
});
it('creates a new Pad, imports content to it, checks that content', async function () {
await agent.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => assert.equal(res.body.code, 0));
await agent.post(`/p/${testPadId}/import`)
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(200);
await agent.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect(200)
.expect((res) => assert.equal(res.body.data.text, padText.toString()));
});
it('gets read only pad Id and exports the html and text for this pad', async function () {
const ro = await agent.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
.expect(200)
.expect((res) => assert.ok(JSON.parse(res.text).data.readOnlyID));
const readOnlyId = JSON.parse(ro.text).data.readOnlyID;
await agent.get(`/p/${readOnlyId}/export/html`)
.expect(200)
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
await agent.get(`/p/${readOnlyId}/export/txt`)
.expect(200)
.expect((res) => assert(res.text.indexOf('This is the') !== -1));
});
describe('Import/Export tests requiring AbiWord/LibreOffice', function () {
before(async function () {
if ((!settings.abiword || settings.abiword.indexOf('/') === -1) &&
(!settings.soffice || settings.soffice.indexOf('/') === -1)) {
this.skip();
}
});
// For some reason word import does not work in testing..
// TODO: fix support for .doc files..
it('Tries to import .doc that uses soffice or abiword', async function () {
await agent.post(`/p/${testPadId}/import`)
.attach('file', wordDoc, {filename: '/test.doc', contentType: 'application/msword'})
.expect(200)
.expect(/FrameCall\('undefined', 'ok'\);/);
});
it('exports DOC', async function () {
await agent.get(`/p/${testPadId}/export/doc`)
.buffer(true).parse(superagent.parse['application/octet-stream'])
.expect(200)
.expect((res) => assert(res.body.length >= 9000));
});
it('Tries to import .docx that uses soffice or abiword', async function () {
await agent.post(`/p/${testPadId}/import`)
.attach('file', wordXDoc, {
filename: '/test.docx',
contentType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
})
.expect(200)
.expect(/FrameCall\('undefined', 'ok'\);/);
});
it('exports DOC from imported DOCX', async function () {
await agent.get(`/p/${testPadId}/export/doc`)
.buffer(true).parse(superagent.parse['application/octet-stream'])
.expect(200)
.expect((res) => assert(res.body.length >= 9100));
});
it('Tries to import .pdf that uses soffice or abiword', async function () {
await agent.post(`/p/${testPadId}/import`)
.attach('file', pdfDoc, {filename: '/test.pdf', contentType: 'application/pdf'})
.expect(200)
.expect(/FrameCall\('undefined', 'ok'\);/);
});
it('exports PDF', async function () {
await agent.get(`/p/${testPadId}/export/pdf`)
.buffer(true).parse(superagent.parse['application/octet-stream'])
.expect(200)
.expect((res) => assert(res.body.length >= 1000));
});
it('Tries to import .odt that uses soffice or abiword', async function () {
await agent.post(`/p/${testPadId}/import`)
.attach('file', odtDoc, {filename: '/test.odt', contentType: 'application/odt'})
.expect(200)
.expect(/FrameCall\('undefined', 'ok'\);/);
});
it('exports ODT', async function () {
await agent.get(`/p/${testPadId}/export/odt`)
.buffer(true).parse(superagent.parse['application/octet-stream'])
.expect(200)
.expect((res) => assert(res.body.length >= 7000));
});
}); // End of AbiWord/LibreOffice tests.
it('Tries to import .etherpad', async function () {
await agent.post(`/p/${testPadId}/import`)
.attach('file', etherpadDoc, {
filename: '/test.etherpad',
contentType: 'application/etherpad',
})
.expect(200)
.expect(/FrameCall\('true', 'ok'\);/);
});
it('exports Etherpad', async function () {
await agent.get(`/p/${testPadId}/export/etherpad`)
.buffer(true).parse(superagent.parse.text)
.expect(200)
.expect(/hello/);
});
it('exports HTML for this Etherpad file', async function () {
await agent.get(`/p/${testPadId}/export/html`)
.expect(200)
.expect('content-type', 'text/html; charset=utf-8')
.expect(/<ul class="bullet"><li><ul class="bullet"><li>hello<\/ul><\/li><\/ul>/);
});
it('Tries to import unsupported file type', async function () {
settings.allowUnknownFileEnds = false;
await agent.post(`/p/${testPadId}/import`)
.attach('file', padText, {filename: '/test.xasdasdxx', contentType: 'weirdness/jobby'})
.expect(200)
.expect((res) => assert.doesNotMatch(res.text, /FrameCall\('undefined', 'ok'\);/));
});
describe('Import authorization checks', function () {
let authorize;
const deleteTestPad = async () => {
if (await padManager.doesPadExist(testPadId)) {
const pad = await padManager.getPad(testPadId);
await pad.remove();
}
};
const createTestPad = async (text) => {
const pad = await padManager.getPad(testPadId);
if (text) await pad.setText(text);
return pad;
};
beforeEach(async function () {
await deleteTestPad();
settings.requireAuthorization = true;
authorize = () => true;
plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}];
});
afterEach(async function () {
await deleteTestPad();
});
it('!authn !exist -> create', async function () {
await agent.post(`/p/${testPadIdEnc}/import`)
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(200);
assert(await padManager.doesPadExist(testPadId));
const pad = await padManager.getPad(testPadId);
assert.equal(pad.text(), padText.toString());
});
it('!authn exist -> replace', async function () {
const pad = await createTestPad('before import');
await agent.post(`/p/${testPadIdEnc}/import`)
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(200);
assert(await padManager.doesPadExist(testPadId));
assert.equal(pad.text(), padText.toString());
});
it('authn anonymous !exist -> fail', async function () {
settings.requireAuthentication = true;
await agent.post(`/p/${testPadIdEnc}/import`)
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(401);
assert(!(await padManager.doesPadExist(testPadId)));
});
it('authn anonymous exist -> fail', async function () {
settings.requireAuthentication = true;
const pad = await createTestPad('before import\n');
await agent.post(`/p/${testPadIdEnc}/import`)
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(401);
assert.equal(pad.text(), 'before import\n');
});
it('authn user create !exist -> create', async function () {
settings.requireAuthentication = true;
await agent.post(`/p/${testPadIdEnc}/import`)
.auth('user', 'user-password')
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(200);
assert(await padManager.doesPadExist(testPadId));
const pad = await padManager.getPad(testPadId);
assert.equal(pad.text(), padText.toString());
});
it('authn user modify !exist -> fail', async function () {
settings.requireAuthentication = true;
authorize = () => 'modify';
await agent.post(`/p/${testPadIdEnc}/import`)
.auth('user', 'user-password')
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(403);
assert(!(await padManager.doesPadExist(testPadId)));
});
it('authn user readonly !exist -> fail', async function () {
settings.requireAuthentication = true;
authorize = () => 'readOnly';
await agent.post(`/p/${testPadIdEnc}/import`)
.auth('user', 'user-password')
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(403);
assert(!(await padManager.doesPadExist(testPadId)));
});
it('authn user create exist -> replace', async function () {
settings.requireAuthentication = true;
const pad = await createTestPad('before import\n');
await agent.post(`/p/${testPadIdEnc}/import`)
.auth('user', 'user-password')
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(200);
assert.equal(pad.text(), padText.toString());
});
it('authn user modify exist -> replace', async function () {
settings.requireAuthentication = true;
authorize = () => 'modify';
const pad = await createTestPad('before import\n');
await agent.post(`/p/${testPadIdEnc}/import`)
.auth('user', 'user-password')
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(200);
assert.equal(pad.text(), padText.toString());
});
it('authn user readonly exist -> fail', async function () {
const pad = await createTestPad('before import\n');
settings.requireAuthentication = true;
authorize = () => 'readOnly';
await agent.post(`/p/${testPadIdEnc}/import`)
.auth('user', 'user-password')
.attach('file', padText, {filename: '/test.txt', contentType: 'text/plain'})
.expect(403);
assert.equal(pad.text(), 'before import\n');
});
});
});
}); // End of tests.
const endPoint = (point, version) => {
version = version || apiVersion;
return `/api/${version}/${point}?apikey=${apiKey}`;
};
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

View file

@ -0,0 +1,51 @@
/*
* Tests for the instance-level APIs
*
* Section "GLOBAL FUNCTIONS" in src/node/db/API.js
*/
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const supertest = require('supertest');
const api = supertest(`http://${settings.ip}:${settings.port}`);
const apiKey = common.apiKey;
const apiVersion = '1.2.14';
describe(__filename, function () {
describe('Connectivity for instance-level API tests', function () {
it('can connect', function (done) {
api.get('/api/')
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getStats', function () {
it('Gets the stats of a running instance', function (done) {
api.get(endPoint('getStats'))
.expect((res) => {
if (res.body.code !== 0) throw new Error('getStats() failed');
if (!(('totalPads' in res.body.data) && (typeof res.body.data.totalPads === 'number'))) {
throw new Error(`Response to getStats() does not contain field totalPads, or it's not a number: ${JSON.stringify(res.body.data)}`);
}
if (!(('totalSessions' in res.body.data) && (typeof res.body.data.totalSessions === 'number'))) {
throw new Error(`Response to getStats() does not contain field totalSessions, or it's not a number: ${JSON.stringify(res.body.data)}`);
}
if (!(('totalActivePads' in res.body.data) && (typeof res.body.data.totalActivePads === 'number'))) {
throw new Error(`Response to getStats() does not contain field totalActivePads, or it's not a number: ${JSON.stringify(res.body.data)}`);
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
});
var endPoint = function (point, version) {
version = version || apiVersion;
return `/api/${version}/${point}?apikey=${apiKey}`;
};

View file

@ -0,0 +1,845 @@
/*
* ACHTUNG: there is a copied & modified version of this file in
* <basedir>/tests/container/specs/api/pad.js
*
* TODO: unify those two files, and merge in a single one.
*/
const async = require('async');
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const supertest = require('supertest');
const api = supertest(`http://${settings.ip}:${settings.port}`);
const apiKey = common.apiKey;
let apiVersion = 1;
const testPadId = makeid();
let lastEdited = '';
const text = generateLongText();
/*
* Html document with nested lists of different types, to test its import and
* verify it is exported back correctly
*/
const ulHtml = '<!doctype html><html><body><ul class="bullet"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class="bullet"><li>3</li><li>4</li></ul></li></ul><ol class="number"><li>item<ol class="number"><li>item1</li><li>item2</li></ol></li></ol></body></html>';
/*
* When exported back, Etherpad produces an html which is not exactly the same
* textually, but at least it remains standard compliant and has an equal DOM
* structure.
*/
const expectedHtml = '<!doctype html><html><body><ul class="bullet"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class="bullet"><li>3</li><li>4</ul></li></ul><ol start="1" class="number"><li>item<ol start="2" class="number"><li>item1</li><li>item2</ol></li></ol></body></html>';
/*
* Html document with space between list items, to test its import and
* verify it is exported back correctly
*/
const ulSpaceHtml = '<!doctype html><html><body><ul class="bullet"> <li>one</li></ul></body></html>';
/*
* When exported back, Etherpad produces an html which is not exactly the same
* textually, but at least it remains standard compliant and has an equal DOM
* structure.
*/
const expectedSpaceHtml = '<!doctype html><html><body><ul class="bullet"><li>one</ul></body></html>';
describe(__filename, function () {
describe('Connectivity', function () {
it('can connect', function (done) {
api.get('/api/')
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('API Versioning', function () {
it('finds the version tag', function (done) {
api.get('/api/')
.expect((res) => {
apiVersion = res.body.currentVersion;
if (!res.body.currentVersion) throw new Error('No version set in API');
return;
})
.expect(200, done);
});
});
describe('Permission', function () {
it('errors with invalid APIKey', function (done) {
// This is broken because Etherpad doesn't handle HTTP codes properly see #2343
// If your APIKey is password you deserve to fail all tests anyway
const permErrorURL = `/api/${apiVersion}/createPad?apikey=password&padID=test`;
api.get(permErrorURL)
.expect(401, done);
});
});
/* Pad Tests Order of execution
-> deletePad -- This gives us a guaranteed clear environment
-> createPad
-> getRevisions -- Should be 0
-> getSavedRevisionsCount(padID) -- Should be 0
-> listSavedRevisions(padID) -- Should be an empty array
-> getHTML -- Should be the default pad text in HTML format
-> deletePad -- Should just delete a pad
-> getHTML -- Should return an error
-> createPad(withText)
-> getText -- Should have the text specified above as the pad text
-> setText
-> getText -- Should be the text set before
-> getRevisions -- Should be 0 still?
-> saveRevision
-> getSavedRevisionsCount(padID) -- Should be 0 still?
-> listSavedRevisions(padID) -- Should be an empty array still ?
-> padUsersCount -- Should be 0
-> getReadOnlyId -- Should be a value
-> listAuthorsOfPad(padID) -- should be empty array?
-> getLastEdited(padID) -- Should be when pad was made
-> setText(padId)
-> getLastEdited(padID) -- Should be when setText was performed
-> padUsers(padID) -- Should be when setText was performed
-> setText(padId, "hello world")
-> getLastEdited(padID) -- Should be when pad was made
-> getText(padId) -- Should be "hello world"
-> movePad(padID, newPadId) -- Should provide consistent pad data
-> getText(newPadId) -- Should be "hello world"
-> movePad(newPadID, originalPadId) -- Should provide consistent pad data
-> getText(originalPadId) -- Should be "hello world"
-> getLastEdited(padID) -- Should not be 0
-> appendText(padID, "hello")
-> getText(padID) -- Should be "hello worldhello"
-> setHTML(padID) -- Should fail on invalid HTML
-> setHTML(padID) *3 -- Should fail on invalid HTML
-> getHTML(padID) -- Should return HTML close to posted HTML
-> createPad -- Tries to create pads with bad url characters
*/
describe('deletePad', function () {
it('deletes a Pad', function (done) {
api.get(`${endPoint('deletePad')}&padID=${testPadId}`)
.expect('Content-Type', /json/)
.expect(200, done); // @TODO: we shouldn't expect 200 here since the pad may not exist
});
});
describe('createPad', function () {
it('creates a new Pad', function (done) {
api.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getRevisionsCount', function () {
it('gets revision count of Pad', function (done) {
api.get(`${endPoint('getRevisionsCount')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to get Revision Count');
if (res.body.data.revisions !== 0) throw new Error('Incorrect Revision Count');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getSavedRevisionsCount', function () {
it('gets saved revisions count of Pad', function (done) {
api.get(`${endPoint('getSavedRevisionsCount')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to get Saved Revisions Count');
if (res.body.data.savedRevisions !== 0) throw new Error('Incorrect Saved Revisions Count');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('listSavedRevisions', function () {
it('gets saved revision list of Pad', function (done) {
api.get(`${endPoint('listSavedRevisions')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to get Saved Revisions List');
if (!res.body.data.savedRevisions.equals([])) throw new Error('Incorrect Saved Revisions List');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getHTML', function () {
it('get the HTML of Pad', function (done) {
api.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.html.length <= 1) throw new Error('Unable to get the HTML');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('listAllPads', function () {
it('list all pads', function (done) {
api.get(endPoint('listAllPads'))
.expect((res) => {
if (res.body.data.padIDs.includes(testPadId) !== true) {
throw new Error('Unable to find pad in pad list');
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('deletePad', function () {
it('deletes a Pad', function (done) {
api.get(`${endPoint('deletePad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Deletion failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('listAllPads', function () {
it('list all pads', function (done) {
api.get(endPoint('listAllPads'))
.expect((res) => {
if (res.body.data.padIDs.includes(testPadId) !== false) {
throw new Error('Test pad should not be in pads list');
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getHTML', function () {
it('get the HTML of a Pad -- Should return a failure', function (done) {
api.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 1) throw new Error('Pad deletion failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('createPad', function () {
it('creates a new Pad with text', function (done) {
api.get(`${endPoint('createPad')}&padID=${testPadId}&text=testText`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Creation failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getText', function () {
it('gets the Pad text and expect it to be testText with \n which is a line break', function (done) {
api.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.text !== 'testText\n') throw new Error('Pad Creation with text');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setText', function () {
it('creates a new Pad with text', function (done) {
api.post(endPoint('setText'))
.send({
padID: testPadId,
text: 'testTextTwo',
})
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad setting text failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getText', function () {
it('gets the Pad text', function (done) {
api.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.text !== 'testTextTwo\n') throw new Error('Setting Text');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getRevisionsCount', function () {
it('gets Revision Count of a Pad', function (done) {
api.get(`${endPoint('getRevisionsCount')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.revisions !== 1) throw new Error('Unable to get text revision count');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('saveRevision', function () {
it('saves Revision', function (done) {
api.get(`${endPoint('saveRevision')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to save Revision');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getSavedRevisionsCount', function () {
it('gets saved revisions count of Pad', function (done) {
api.get(`${endPoint('getSavedRevisionsCount')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to get Saved Revisions Count');
if (res.body.data.savedRevisions !== 1) throw new Error('Incorrect Saved Revisions Count');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('listSavedRevisions', function () {
it('gets saved revision list of Pad', function (done) {
api.get(`${endPoint('listSavedRevisions')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Unable to get Saved Revisions List');
if (!res.body.data.savedRevisions.equals([1])) throw new Error('Incorrect Saved Revisions List');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('padUsersCount', function () {
it('gets User Count of a Pad', function (done) {
api.get(`${endPoint('padUsersCount')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.padUsersCount !== 0) throw new Error('Incorrect Pad User count');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getReadOnlyID', function () {
it('Gets the Read Only ID of a Pad', function (done) {
api.get(`${endPoint('getReadOnlyID')}&padID=${testPadId}`)
.expect((res) => {
if (!res.body.data.readOnlyID) throw new Error('No Read Only ID for Pad');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('listAuthorsOfPad', function () {
it('Get Authors of the Pad', function (done) {
api.get(`${endPoint('listAuthorsOfPad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.authorIDs.length !== 0) throw new Error('# of Authors of pad is not 0');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getLastEdited', function () {
it('Get When Pad was left Edited', function (done) {
api.get(`${endPoint('getLastEdited')}&padID=${testPadId}`)
.expect((res) => {
if (!res.body.data.lastEdited) {
throw new Error('# of Authors of pad is not 0');
} else {
lastEdited = res.body.data.lastEdited;
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setText', function () {
it('creates a new Pad with text', function (done) {
api.post(endPoint('setText'))
.send({
padID: testPadId,
text: 'testTextTwo',
})
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad setting text failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getLastEdited', function () {
it('Get When Pad was left Edited', function (done) {
api.get(`${endPoint('getLastEdited')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.lastEdited <= lastEdited) {
throw new Error('Editing A Pad is not updating when it was last edited');
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('padUsers', function () {
it('gets User Count of a Pad', function (done) {
api.get(`${endPoint('padUsers')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.padUsers.length !== 0) throw new Error('Incorrect Pad Users');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('deletePad', function () {
it('deletes a Pad', function (done) {
api.get(`${endPoint('deletePad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Deletion failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
const originalPadId = testPadId;
const newPadId = makeid();
const copiedPadId = makeid();
describe('createPad', function () {
it('creates a new Pad with text', function (done) {
api.get(`${endPoint('createPad')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Creation failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setText', function () {
it('Sets text on a pad Id', function (done) {
api.post(`${endPoint('setText')}&padID=${testPadId}`)
.field({text})
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Set Text failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getText', function () {
it('Gets text on a pad Id', function (done) {
api.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Get Text failed');
if (res.body.data.text !== `${text}\n`) throw new Error('Pad Text not set properly');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setText', function () {
it('Sets text on a pad Id including an explicit newline', function (done) {
api.post(`${endPoint('setText')}&padID=${testPadId}`)
.field({text: `${text}\n`})
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Set Text failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getText', function () {
it("Gets text on a pad Id and doesn't have an excess newline", function (done) {
api.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Get Text failed');
if (res.body.data.text !== `${text}\n`) throw new Error('Pad Text not set properly');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getLastEdited', function () {
it('Gets when pad was last edited', function (done) {
api.get(`${endPoint('getLastEdited')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.lastEdited === 0) throw new Error('Get Last Edited Failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('movePad', function () {
it('Move a Pad to a different Pad ID', function (done) {
api.get(`${endPoint('movePad')}&sourceID=${testPadId}&destinationID=${newPadId}&force=true`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Moving Pad Failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getText', function () {
it('Gets text on a pad Id', function (done) {
api.get(`${endPoint('getText')}&padID=${newPadId}`)
.expect((res) => {
if (res.body.data.text !== `${text}\n`) throw new Error('Pad Get Text failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('movePad', function () {
it('Move a Pad to a different Pad ID', function (done) {
api.get(`${endPoint('movePad')}&sourceID=${newPadId}&destinationID=${testPadId}&force=false`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Moving Pad Failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getText', function () {
it('Gets text on a pad Id', function (done) {
api.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.data.text !== `${text}\n`) throw new Error('Pad Get Text failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getLastEdited', function () {
it('Gets when pad was last edited', function (done) {
api.get(`${endPoint('getLastEdited')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.lastEdited === 0) throw new Error('Get Last Edited Failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('appendText', function () {
it('Append text to a pad Id', function (done) {
api.get(`${endPoint('appendText', '1.2.13')}&padID=${testPadId}&text=hello`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Append Text failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getText', function () {
it('Gets text on a pad Id', function (done) {
api.get(`${endPoint('getText')}&padID=${testPadId}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Pad Get Text failed');
if (res.body.data.text !== `${text}hello\n`) throw new Error('Pad Text not set properly');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setHTML', function () {
it('Sets the HTML of a Pad attempting to pass ugly HTML', function (done) {
const html = '<div><b>Hello HTML</title></head></div>';
api.post(endPoint('setHTML'))
.send({
padID: testPadId,
html,
})
.expect((res) => {
if (res.body.code !== 0) throw new Error("Crappy HTML Can't be Imported[we weren't able to sanitize it']");
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setHTML', function () {
it('Sets the HTML of a Pad with complex nested lists of different types', function (done) {
api.post(endPoint('setHTML'))
.send({
padID: testPadId,
html: ulHtml,
})
.expect((res) => {
if (res.body.code !== 0) throw new Error('List HTML cant be imported');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getHTML', function () {
it('Gets back the HTML of a Pad with complex nested lists of different types', function (done) {
api.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect((res) => {
const receivedHtml = res.body.data.html.replace('<br></body>', '</body>').toLowerCase();
if (receivedHtml !== expectedHtml) {
throw new Error(`HTML received from export is not the one we were expecting.
Received:
${receivedHtml}
Expected:
${expectedHtml}
Which is a slightly modified version of the originally imported one:
${ulHtml}`);
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('setHTML', function () {
it('Sets the HTML of a Pad with white space between list items', function (done) {
api.get(`${endPoint('setHTML')}&padID=${testPadId}&html=${ulSpaceHtml}`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('List HTML cant be imported');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('getHTML', function () {
it('Gets back the HTML of a Pad with complex nested lists of different types', function (done) {
api.get(`${endPoint('getHTML')}&padID=${testPadId}`)
.expect((res) => {
const receivedHtml = res.body.data.html.replace('<br></body>', '</body>').toLowerCase();
if (receivedHtml !== expectedSpaceHtml) {
throw new Error(`HTML received from export is not the one we were expecting.
Received:
${receivedHtml}
Expected:
${expectedSpaceHtml}
Which is a slightly modified version of the originally imported one:
${ulSpaceHtml}`);
}
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('createPad', function () {
it('errors if pad can be created', function (done) {
const badUrlChars = ['/', '%23', '%3F', '%26'];
async.map(
badUrlChars,
(badUrlChar, cb) => {
api.get(`${endPoint('createPad')}&padID=${badUrlChar}`)
.expect((res) => {
if (res.body.code !== 1) throw new Error('Pad with bad characters was created');
})
.expect('Content-Type', /json/)
.end(cb);
},
done);
});
});
describe('copyPad', function () {
it('copies the content of a existent pad', function (done) {
api.get(`${endPoint('copyPad')}&sourceID=${testPadId}&destinationID=${copiedPadId}&force=true`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Copy Pad Failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
});
describe('copyPadWithoutHistory', function () {
const sourcePadId = makeid();
let newPad;
before(function (done) {
createNewPadWithHtml(sourcePadId, ulHtml, done);
});
beforeEach(function () {
newPad = makeid();
});
it('returns a successful response', function (done) {
api.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}&destinationID=${newPad}&force=false`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Copy Pad Without History Failed');
})
.expect('Content-Type', /json/)
.expect(200, done);
});
// this test validates if the source pad's text and attributes are kept
it('creates a new pad with the same content as the source pad', function (done) {
api.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}&destinationID=${newPad}&force=false`)
.expect((res) => {
if (res.body.code !== 0) throw new Error('Copy Pad Without History Failed');
})
.end(() => {
api.get(`${endPoint('getHTML')}&padID=${newPad}`)
.expect((res) => {
const receivedHtml = res.body.data.html.replace('<br><br></body>', '</body>').toLowerCase();
if (receivedHtml !== expectedHtml) {
throw new Error(`HTML received from export is not the one we were expecting.
Received:
${receivedHtml}
Expected:
${expectedHtml}
Which is a slightly modified version of the originally imported one:
${ulHtml}`);
}
})
.expect(200, done);
});
});
context('when try copy a pad with a group that does not exist', function () {
const padId = makeid();
const padWithNonExistentGroup = `notExistentGroup$${padId}`;
it('throws an error', function (done) {
api.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}&destinationID=${padWithNonExistentGroup}&force=true`)
.expect((res) => {
// code 1, it means an error has happened
if (res.body.code !== 1) throw new Error('It should report an error');
})
.expect(200, done);
});
});
context('when try copy a pad and destination pad already exist', function () {
const padIdExistent = makeid();
before(function (done) {
createNewPadWithHtml(padIdExistent, ulHtml, done);
});
context('and force is false', function () {
it('throws an error', function (done) {
api.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}&destinationID=${padIdExistent}&force=false`)
.expect((res) => {
// code 1, it means an error has happened
if (res.body.code !== 1) throw new Error('It should report an error');
})
.expect(200, done);
});
});
context('and force is true', function () {
it('returns a successful response', function (done) {
api.get(`${endPoint('copyPadWithoutHistory')}&sourceID=${sourcePadId}&destinationID=${padIdExistent}&force=true`)
.expect((res) => {
// code 1, it means an error has happened
if (res.body.code !== 0) throw new Error('Copy pad without history with force true failed');
})
.expect(200, done);
});
});
});
});
});
/*
-> movePadForce Test
*/
var createNewPadWithHtml = function (padId, html, cb) {
api.get(`${endPoint('createPad')}&padID=${padId}`)
.end(() => {
api.post(endPoint('setHTML'))
.send({
padID: padId,
html,
})
.end(cb);
});
};
var endPoint = function (point, version) {
version = version || apiVersion;
return `/api/${version}/${point}?apikey=${apiKey}`;
};
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
function generateLongText() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 80000; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
// Need this to compare arrays (listSavedRevisions test)
Array.prototype.equals = function (array) {
// if the other array is a falsy value, return
if (!array) return false;
// compare lengths - can save a lot of time
if (this.length != array.length) return false;
for (let i = 0, l = this.length; i < l; i++) {
// Check if we have nested arrays
if (this[i] instanceof Array && array[i] instanceof Array) {
// recurse into the nested arrays
if (!this[i].equals(array[i])) return false;
} else if (this[i] != array[i]) {
// Warning - two different object instances will never be equal: {x:20} != {x:20}
return false;
}
}
return true;
};

View file

@ -0,0 +1,377 @@
const assert = require('assert').strict;
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const supertest = require('supertest');
const api = supertest(`http://${settings.ip}:${settings.port}`);
const apiKey = common.apiKey;
let apiVersion = 1;
let groupID = '';
let authorID = '';
let sessionID = '';
let padID = makeid();
describe(__filename, function () {
describe('API Versioning', function () {
it('errors if can not connect', async function () {
await api.get('/api/')
.expect(200)
.expect((res) => {
assert(res.body.currentVersion);
apiVersion = res.body.currentVersion;
});
});
});
// BEGIN GROUP AND AUTHOR TESTS
// ///////////////////////////////////
// ///////////////////////////////////
/* Tests performed
-> createGroup() -- should return a groupID
-> listSessionsOfGroup(groupID) -- should be 0
-> deleteGroup(groupID)
-> createGroupIfNotExistsFor(groupMapper) -- should return a groupID
-> createAuthor([name]) -- should return an authorID
-> createAuthorIfNotExistsFor(authorMapper [, name]) -- should return an authorID
-> getAuthorName(authorID) -- should return a name IE "john"
-> createSession(groupID, authorID, validUntil)
-> getSessionInfo(sessionID)
-> listSessionsOfGroup(groupID) -- should be 1
-> deleteSession(sessionID)
-> getSessionInfo(sessionID) -- should have author id etc in
-> listPads(groupID) -- should be empty array
-> createGroupPad(groupID, padName [, text])
-> listPads(groupID) -- should be empty array
-> getPublicStatus(padId)
-> setPublicStatus(padId, status)
-> getPublicStatus(padId)
-> listPadsOfAuthor(authorID)
*/
describe('API: Group creation and deletion', function () {
it('createGroup', async function () {
await api.get(endPoint('createGroup'))
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.groupID);
groupID = res.body.data.groupID;
});
});
it('listSessionsOfGroup for empty group', async function () {
await api.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data, null);
});
});
it('deleteGroup', async function () {
await api.get(`${endPoint('deleteGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
});
});
it('createGroupIfNotExistsFor', async function () {
await api.get(`${endPoint('createGroupIfNotExistsFor')}&groupMapper=management`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.groupID);
});
});
// Test coverage for https://github.com/ether/etherpad-lite/issues/4227
// Creates a group, creates 2 sessions, 2 pads and then deletes the group.
it('createGroup', async function () {
await api.get(endPoint('createGroup'))
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.groupID);
groupID = res.body.data.groupID;
});
});
it('createAuthor', async function () {
await api.get(endPoint('createAuthor'))
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.authorID);
authorID = res.body.data.authorID;
});
});
it('createSession', async function () {
await api.get(`${endPoint('createSession')
}&authorID=${authorID}&groupID=${groupID}&validUntil=999999999999`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.sessionID);
sessionID = res.body.data.sessionID;
});
});
it('createSession', async function () {
await api.get(`${endPoint('createSession')
}&authorID=${authorID}&groupID=${groupID}&validUntil=999999999999`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.sessionID);
sessionID = res.body.data.sessionID;
});
});
it('createGroupPad', async function () {
await api.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x1234567`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
});
});
it('createGroupPad', async function () {
await api.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=x12345678`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
});
});
it('deleteGroup', async function () {
await api.get(`${endPoint('deleteGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
});
});
// End of coverage for https://github.com/ether/etherpad-lite/issues/4227
});
describe('API: Author creation', function () {
it('createGroup', async function () {
await api.get(endPoint('createGroup'))
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.groupID);
groupID = res.body.data.groupID;
});
});
it('createAuthor', async function () {
await api.get(endPoint('createAuthor'))
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.authorID);
});
});
it('createAuthor with name', async function () {
await api.get(`${endPoint('createAuthor')}&name=john`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.authorID);
authorID = res.body.data.authorID; // we will be this author for the rest of the tests
});
});
it('createAuthorIfNotExistsFor', async function () {
await api.get(`${endPoint('createAuthorIfNotExistsFor')}&authorMapper=chris`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.authorID);
});
});
it('getAuthorName', async function () {
await api.get(`${endPoint('getAuthorName')}&authorID=${authorID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data, 'john');
});
});
});
describe('API: Sessions', function () {
it('createSession', async function () {
await api.get(`${endPoint('createSession')
}&authorID=${authorID}&groupID=${groupID}&validUntil=999999999999`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.sessionID);
sessionID = res.body.data.sessionID;
});
});
it('getSessionInfo', async function () {
await api.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert(res.body.data.groupID);
assert(res.body.data.authorID);
assert(res.body.data.validUntil);
});
});
it('listSessionsOfGroup', async function () {
await api.get(`${endPoint('listSessionsOfGroup')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(typeof res.body.data, 'object');
});
});
it('deleteSession', async function () {
await api.get(`${endPoint('deleteSession')}&sessionID=${sessionID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
});
});
it('getSessionInfo of deleted session', async function () {
await api.get(`${endPoint('getSessionInfo')}&sessionID=${sessionID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 1);
});
});
});
describe('API: Group pad management', function () {
it('listPads', async function () {
await api.get(`${endPoint('listPads')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data.padIDs.length, 0);
});
});
it('createGroupPad', async function () {
await api.get(`${endPoint('createGroupPad')}&groupID=${groupID}&padName=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
padID = res.body.data.padID;
});
});
it('listPads after creating a group pad', async function () {
await api.get(`${endPoint('listPads')}&groupID=${groupID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data.padIDs.length, 1);
});
});
});
describe('API: Pad security', function () {
it('getPublicStatus', async function () {
await api.get(`${endPoint('getPublicStatus')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data.publicStatus, false);
});
});
it('setPublicStatus', async function () {
await api.get(`${endPoint('setPublicStatus')}&padID=${padID}&publicStatus=true`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
});
});
it('getPublicStatus after changing public status', async function () {
await api.get(`${endPoint('getPublicStatus')}&padID=${padID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data.publicStatus, true);
});
});
});
// NOT SURE HOW TO POPULAT THIS /-_-\
// /////////////////////////////////////
// /////////////////////////////////////
describe('API: Misc', function () {
it('listPadsOfAuthor', async function () {
await api.get(`${endPoint('listPadsOfAuthor')}&authorID=${authorID}`)
.expect(200)
.expect('Content-Type', /json/)
.expect((res) => {
assert.equal(res.body.code, 0);
assert.equal(res.body.data.padIDs.length, 0);
});
});
});
});
const endPoint = function (point) {
return `/api/${apiVersion}/${point}?apikey=${apiKey}`;
};
function makeid() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
{"pad:Pd4b1Kgvv9qHZZtj8yzl":{"atext":{"text":"*hello\n","attribs":"*0*1*5*3*4+1*0+5|1+1"},"pool":{"numToAttrib":{"0":["author","a.ElbBWNTxmtRrfFqn"],"1":["insertorder","first"],"2":["list","bullet1"],"3":["lmkr","1"],"4":["start","1"],"5":["list","bullet2"]},"nextNum":6},"head":5,"chatHead":-1,"publicStatus":false,"passwordHash":null,"savedRevisions":[]},"globalAuthor:a.ElbBWNTxmtRrfFqn":{"colorId":50,"name":null,"timestamp":1595111151414,"padIDs":"Pd4b1Kgvv9qHZZtj8yzl"},"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:0":{"changeset":"Z:1>bj|7+bj$Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https://etherpad.org\n\nWarning: DirtyDB is used. This is fine for testing but not recommended for production. -- To suppress these warning messages change suppressErrorsInPadText to true in your settings.json\n","meta":{"author":"","timestamp":1595111092400,"pool":{"numToAttrib":{},"attribToNum":{},"nextNum":0},"atext":{"text":"Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https://etherpad.org\n\nWarning: DirtyDB is used. This is fine for testing but not recommended for production. -- To suppress these warning messages change suppressErrorsInPadText to true in your settings.json\n\n","attribs":"|8+bk"}}},"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:1":{"changeset":"Z:bk<bj|7-bj$","meta":{"author":"a.ElbBWNTxmtRrfFqn","timestamp":1595111112138}},"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:2":{"changeset":"Z:1>1*0*1*2*3*4+1$*","meta":{"author":"a.ElbBWNTxmtRrfFqn","timestamp":1595111119434}},"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:3":{"changeset":"Z:2>0*5*4=1$","meta":{"author":"a.ElbBWNTxmtRrfFqn","timestamp":1595111127471}},"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:4":{"changeset":"Z:2>2=1*0+2$he","meta":{"author":"a.ElbBWNTxmtRrfFqn","timestamp":1595111128230}},"pad:Pd4b1Kgvv9qHZZtj8yzl:revs:5":{"changeset":"Z:4>3=3*0+3$llo","meta":{"author":"a.ElbBWNTxmtRrfFqn","timestamp":1595111128727}}}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
This is the contents we insert into pads when testing the import functions. Thanks for using Etherpad!

View file

@ -0,0 +1,71 @@
'use strict';
const assert = require('assert');
const os = require('os');
const fs = require('fs');
const path = require('path');
let TidyHtml;
let Settings;
const npm = require('npm/lib/npm.js');
const nodeify = require('nodeify');
describe(__filename, function () {
describe('tidyHtml', function () {
before(function (done) {
npm.load({}, (err) => {
assert.ok(!err);
TidyHtml = require('../../../../node/utils/TidyHtml');
Settings = require('../../../../node/utils/Settings');
return done();
});
});
const tidy = (file, callback) => nodeify(TidyHtml.tidy(file), callback);
it('Tidies HTML', function (done) {
// If the user hasn't configured Tidy, we skip this tests as it's required for this test
if (!Settings.tidyHtml) {
this.skip();
}
// Try to tidy up a bad HTML file
const tmpDir = os.tmpdir();
const tmpFile = path.join(tmpDir, `tmp_${Math.floor(Math.random() * 1000000)}.html`);
fs.writeFileSync(tmpFile, '<html><body><p>a paragraph</p><li>List without outer UL</li>trailing closing p</p></body></html>');
tidy(tmpFile, (err) => {
assert.ok(!err);
// Read the file again
const cleanedHtml = fs.readFileSync(tmpFile).toString();
const expectedHtml = [
'<title></title>',
'</head>',
'<body>',
'<p>a paragraph</p>',
'<ul>',
'<li>List without outer UL</li>',
'<li style="list-style: none">trailing closing p</li>',
'</ul>',
'</body>',
'</html>',
].join('\n');
assert.notStrictEqual(cleanedHtml.indexOf(expectedHtml), -1);
done();
});
});
it('can deal with errors', function (done) {
// If the user hasn't configured Tidy, we skip this tests as it's required for this test
if (!Settings.tidyHtml) {
this.skip();
}
tidy('/some/none/existing/file.html', (err) => {
assert.ok(err);
done();
});
});
});
});

View file

@ -0,0 +1,147 @@
/**
* caching_middleware is responsible for serving everything under path `/javascripts/`
* That includes packages as defined in `src/node/utils/tar.json` and probably also plugin code
*
*/
const common = require('../common');
const assert = require('assert').strict;
const url = require('url');
const queryString = require('querystring');
const settings = require('../../../node/utils/Settings');
let agent;
/**
* Hack! Returns true if the resource is not plaintext
* The file should start with the callback method, so we need the
* URL.
*
* @param {string} fileContent the response body
* @param {URI} resource resource URI
* @returns {boolean} if it is plaintext
*/
function isPlaintextResponse(fileContent, resource) {
// callback=require.define&v=1234
const query = url.parse(resource).query;
// require.define
const jsonp = queryString.parse(query).callback;
// returns true if the first letters in fileContent equal the content of `jsonp`
return fileContent.substring(0, jsonp.length) === jsonp;
}
/**
* A hack to disable `superagent`'s auto unzip functionality
*
* @param {Request} request
*/
function disableAutoDeflate(request) {
request._shouldUnzip = function () {
return false;
};
}
describe(__filename, function () {
const backups = {};
const fantasyEncoding = 'brainwaves'; // non-working encoding until https://github.com/visionmedia/superagent/pull/1560 is resolved
const packages = [
'/javascripts/lib/ep_etherpad-lite/static/js/ace2_common.js?callback=require.define',
'/javascripts/lib/ep_etherpad-lite/static/js/ace2_inner.js?callback=require.define',
'/javascripts/lib/ep_etherpad-lite/static/js/pad.js?callback=require.define',
'/javascripts/lib/ep_etherpad-lite/static/js/timeslider.js?callback=require.define',
];
before(async function () {
agent = await common.init();
});
beforeEach(async function () {
backups.settings = {};
backups.settings.minify = settings.minify;
});
afterEach(async function () {
Object.assign(settings, backups.settings);
});
context('when minify is false', function () {
before(async function () {
settings.minify = false;
});
it('gets packages uncompressed without Accept-Encoding gzip', async function () {
await Promise.all(packages.map(async (resource) => agent.get(resource)
.set('Accept-Encoding', fantasyEncoding)
.use(disableAutoDeflate)
.then((res) => {
assert.match(res.header['content-type'], /application\/javascript/);
assert.equal(res.header['content-encoding'], undefined);
assert.equal(isPlaintextResponse(res.text, resource), true);
return;
})));
});
it('gets packages compressed with Accept-Encoding gzip', async function () {
await Promise.all(packages.map(async (resource) => agent.get(resource)
.set('Accept-Encoding', 'gzip')
.use(disableAutoDeflate)
.then((res) => {
assert.match(res.header['content-type'], /application\/javascript/);
assert.equal(res.header['content-encoding'], 'gzip');
assert.equal(isPlaintextResponse(res.text, resource), false);
return;
})));
});
it('does not cache content-encoding headers', async function () {
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.then((res) => assert.equal(res.header['content-encoding'], undefined));
await agent.get(packages[0])
.set('Accept-Encoding', 'gzip')
.then((res) => assert.equal(res.header['content-encoding'], 'gzip'));
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.then((res) => assert.equal(res.header['content-encoding'], undefined));
});
});
context('when minify is true', function () {
before(async function () {
settings.minify = true;
});
it('gets packages uncompressed without Accept-Encoding gzip', async function () {
await Promise.all(packages.map(async (resource) => agent.get(resource)
.set('Accept-Encoding', fantasyEncoding)
.use(disableAutoDeflate)
.then((res) => {
assert.match(res.header['content-type'], /application\/javascript/);
assert.equal(res.header['content-encoding'], undefined);
assert.equal(isPlaintextResponse(res.text, resource), true);
return;
})));
});
it('gets packages compressed with Accept-Encoding gzip', async function () {
await Promise.all(packages.map(async (resource) => agent.get(resource)
.set('Accept-Encoding', 'gzip')
.use(disableAutoDeflate)
.then((res) => {
assert.match(res.header['content-type'], /application\/javascript/);
assert.equal(res.header['content-encoding'], 'gzip');
assert.equal(isPlaintextResponse(res.text, resource), false);
return;
})));
});
it('does not cache content-encoding headers', async function () {
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.then((res) => assert.equal(res.header['content-encoding'], undefined));
await agent.get(packages[0])
.set('Accept-Encoding', 'gzip')
.then((res) => assert.equal(res.header['content-encoding'], 'gzip'));
await agent.get(packages[0])
.set('Accept-Encoding', fantasyEncoding)
.then((res) => assert.equal(res.header['content-encoding'], undefined));
});
});
});

View file

@ -0,0 +1,324 @@
'use strict';
/*
* While importexport tests target the `setHTML` API endpoint, which is nearly identical to what
* happens when a user manually imports a document via the UI, the contentcollector tests here don't
* use rehype to process the document. Rehype removes spaces and newĺines were applicable, so the
* expected results here can differ from importexport.js.
*
* If you add tests here, please also add them to importexport.js
*/
const AttributePool = require('../../../static/js/AttributePool');
const assert = require('assert').strict;
const cheerio = require('cheerio');
const contentcollector = require('../../../static/js/contentcollector');
const tests = {
nestedLi: {
description: 'Complex nested Li',
html: '<!doctype html><html><body><ol><li>one</li><li><ol><li>1.1</li></ol></li><li>two</li></ol></body></html>',
wantLineAttribs: [
'*0*1*2*3+1+3', '*0*4*2*5+1+3', '*0*1*2*5+1+3',
],
wantText: [
'*one', '*1.1', '*two',
],
},
complexNest: {
description: 'Complex list of different types',
html: '<!doctype html><html><body><ul class="bullet"><li>one</li><li>two</li><li>0</li><li>1</li><li>2<ul class="bullet"><li>3</li><li>4</li></ul></li></ul><ol class="number"><li>item<ol class="number"><li>item1</li><li>item2</li></ol></li></ol></body></html>',
wantLineAttribs: [
'*0*1*2+1+3',
'*0*1*2+1+3',
'*0*1*2+1+1',
'*0*1*2+1+1',
'*0*1*2+1+1',
'*0*3*2+1+1',
'*0*3*2+1+1',
'*0*4*2*5+1+4',
'*0*6*2*7+1+5',
'*0*6*2*7+1+5',
],
wantText: [
'*one',
'*two',
'*0',
'*1',
'*2',
'*3',
'*4',
'*item',
'*item1',
'*item2',
],
},
ul: {
description: 'Tests if uls properly get attributes',
html: '<html><body><ul><li>a</li><li>b</li></ul><div>div</div><p>foo</p></body></html>',
wantLineAttribs: ['*0*1*2+1+1', '*0*1*2+1+1', '+3', '+3'],
wantText: ['*a', '*b', 'div', 'foo'],
},
ulIndented: {
description: 'Tests if indented uls properly get attributes',
html: '<html><body><ul><li>a</li><ul><li>b</li></ul><li>a</li></ul><p>foo</p></body></html>',
wantLineAttribs: ['*0*1*2+1+1', '*0*3*2+1+1', '*0*1*2+1+1', '+3'],
wantText: ['*a', '*b', '*a', 'foo'],
},
ol: {
description: 'Tests if ols properly get line numbers when in a normal OL',
html: '<html><body><ol><li>a</li><li>b</li><li>c</li></ol><p>test</p></body></html>',
wantLineAttribs: ['*0*1*2*3+1+1', '*0*1*2*3+1+1', '*0*1*2*3+1+1', '+4'],
wantText: ['*a', '*b', '*c', 'test'],
noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',
},
lineDoBreakInOl: {
description: 'A single completely empty line break within an ol should reset count if OL is closed off..',
html: '<html><body><ol><li>should be 1</li></ol><p>hello</p><ol><li>should be 1</li><li>should be 2</li></ol><p></p></body></html>',
wantLineAttribs: ['*0*1*2*3+1+b', '+5', '*0*1*2*4+1+b', '*0*1*2*4+1+b', ''],
wantText: ['*should be 1', 'hello', '*should be 1', '*should be 2', ''],
noteToSelf: "Shouldn't include attribute marker in the <p> line",
},
testP: {
description: 'A single <p></p> should create a new line',
html: '<html><body><p></p><p></p></body></html>',
wantLineAttribs: ['', ''],
wantText: ['', ''],
noteToSelf: '<p></p>should create a line break but not break numbering',
},
nestedOl: {
description: 'Tests if ols properly get line numbers when in a normal OL',
html: '<html><body>a<ol><li>b<ol><li>c</li></ol></ol>notlist<p>foo</p></body></html>',
wantLineAttribs: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1', '+7', '+3'],
wantText: ['a', '*b', '*c', 'notlist', 'foo'],
noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',
},
nestedOl2: {
description: 'First item being an UL then subsequent being OL will fail',
html: '<html><body><ul><li>a<ol><li>b</li><li>c</li></ol></li></ul></body></html>',
wantLineAttribs: ['+1', '*0*1*2*3+1+1', '*0*4*2*5+1+1'],
wantText: ['a', '*b', '*c'],
noteToSelf: 'Ensure empty P does not induce line attribute marker, wont this break the editor?',
disabled: true,
},
lineDontBreakOL: {
description: 'A single completely empty line break within an ol should NOT reset count',
html: '<html><body><ol><li>should be 1</li><p></p><li>should be 2</li><li>should be 3</li></ol><p></p></body></html>',
wantLineAttribs: [],
wantText: ['*should be 1', '*should be 2', '*should be 3'],
noteToSelf: "<p></p>should create a line break but not break numbering -- This is what I can't get working!",
disabled: true,
},
ignoreAnyTagsOutsideBody: {
description: 'Content outside body should be ignored',
html: '<html><head><title>title</title><style></style></head><body>empty<br></body></html>',
wantLineAttribs: ['+5'],
wantText: ['empty'],
},
lineWithMultipleSpaces: {
description: 'Multiple spaces should be preserved',
html: '<html><body>Text with more than one space.<br></body></html>',
wantLineAttribs: ['+10'],
wantText: ['Text with more than one space.'],
},
lineWithMultipleNonBreakingAndNormalSpaces: {
description: 'non-breaking and normal space should be preserved',
html: '<html><body>Text&nbsp;with&nbsp; more&nbsp;&nbsp;&nbsp;than &nbsp;one space.<br></body></html>',
wantLineAttribs: ['+10'],
wantText: ['Text with more than one space.'],
},
multiplenbsp: {
description: 'Multiple nbsp should be preserved',
html: '<html><body>&nbsp;&nbsp;<br></body></html>',
wantLineAttribs: ['+2'],
wantText: [' '],
},
multipleNonBreakingSpaceBetweenWords: {
description: 'Multiple nbsp between words ',
html: '<html><body>&nbsp;&nbsp;word1&nbsp;&nbsp;word2&nbsp;&nbsp;&nbsp;word3<br></body></html>',
wantLineAttribs: ['+m'],
wantText: [' word1 word2 word3'],
},
nonBreakingSpacePreceededBySpaceBetweenWords: {
description: 'A non-breaking space preceded by a normal space',
html: '<html><body> &nbsp;word1 &nbsp;word2 &nbsp;word3<br></body></html>',
wantLineAttribs: ['+l'],
wantText: [' word1 word2 word3'],
},
nonBreakingSpaceFollowededBySpaceBetweenWords: {
description: 'A non-breaking space followed by a normal space',
html: '<html><body>&nbsp; word1&nbsp; word2&nbsp; word3<br></body></html>',
wantLineAttribs: ['+l'],
wantText: [' word1 word2 word3'],
},
spacesAfterNewline: {
description: 'Don\'t collapse spaces that follow a newline',
html: '<!doctype html><html><body>something<br> something<br></body></html>',
wantLineAttribs: ['+9', '+m'],
wantText: ['something', ' something'],
},
spacesAfterNewlineP: {
description: 'Don\'t collapse spaces that follow a empty paragraph',
html: '<!doctype html><html><body>something<p></p> something<br></body></html>',
wantLineAttribs: ['+9', '', '+m'],
wantText: ['something', '', ' something'],
},
spacesAtEndOfLine: {
description: 'Don\'t collapse spaces that preceed/follow a newline',
html: '<html><body>something <br> something<br></body></html>',
wantLineAttribs: ['+l', '+m'],
wantText: ['something ', ' something'],
},
spacesAtEndOfLineP: {
description: 'Don\'t collapse spaces that preceed/follow a empty paragraph',
html: '<html><body>something <p></p> something<br></body></html>',
wantLineAttribs: ['+l', '', '+m'],
wantText: ['something ', '', ' something'],
},
nonBreakingSpacesAfterNewlines: {
description: 'Don\'t collapse non-breaking spaces that follow a newline',
html: '<html><body>something<br>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantLineAttribs: ['+9', '+c'],
wantText: ['something', ' something'],
},
nonBreakingSpacesAfterNewlinesP: {
description: 'Don\'t collapse non-breaking spaces that follow a paragraph',
html: '<html><body>something<p></p>&nbsp;&nbsp;&nbsp;something<br></body></html>',
wantLineAttribs: ['+9', '', '+c'],
wantText: ['something', '', ' something'],
},
preserveSpacesInsideElements: {
description: 'Preserve all spaces when multiple are present',
html: '<html><body>Need <span> more </span> space<i> s </i> !<br></body></html>',
wantLineAttribs: ['+h*0+4+2'],
wantText: ['Need more space s !'],
},
preserveSpacesAcrossNewlines: {
description: 'Newlines and multiple spaces across newlines should be preserved',
html: `
<html><body>Need
<span> more </span>
space
<i> s </i>
!<br></body></html>`,
wantLineAttribs: ['+19*0+4+b'],
wantText: ['Need more space s !'],
},
multipleNewLinesAtBeginning: {
description: 'Multiple new lines at the beginning should be preserved',
html: '<html><body><br><br><p></p><p></p>first line<br><br>second line<br></body></html>',
wantLineAttribs: ['', '', '', '', '+a', '', '+b'],
wantText: ['', '', '', '', 'first line', '', 'second line'],
},
multiLineParagraph: {
description: 'A paragraph with multiple lines should not loose spaces when lines are combined',
html: `<html><body><p>
а б в г ґ д е є ж з и і ї й к л м н о
п р с т у ф х ц ч ш щ ю я ь</p>
</body></html>`,
wantLineAttribs: ['+1t'],
wantText: ['а б в г ґ д е є ж з и і ї й к л м н о п р с т у ф х ц ч ш щ ю я ь'],
},
multiLineParagraphWithPre: {
description: 'lines in preformatted text should be kept intact',
html: `<html><body><p>
а б в г ґ д е є ж з и і ї й к л м н о</p><pre>multiple
lines
in
pre
</pre><p>п р с т у ф х ц ч ш щ ю я
ь</p>
</body></html>`,
wantLineAttribs: ['+11', '+8', '+5', '+2', '+3', '+r'],
wantText: [
'а б в г ґ д е є ж з и і ї й к л м н о',
'multiple',
'lines',
'in',
'pre',
'п р с т у ф х ц ч ш щ ю я ь',
],
},
preIntroducesASpace: {
description: 'pre should be on a new line not preceded by a space',
html: `<html><body><p>
1
</p><pre>preline
</pre></body></html>`,
wantLineAttribs: ['+6', '+7'],
wantText: [' 1 ', 'preline'],
},
dontDeleteSpaceInsideElements: {
description: 'Preserve spaces on the beginning and end of a element',
html: '<html><body>Need<span> more </span>space<i> s </i>!<br></body></html>',
wantLineAttribs: ['+f*0+3+1'],
wantText: ['Need more space s !'],
},
dontDeleteSpaceOutsideElements: {
description: 'Preserve spaces outside elements',
html: '<html><body>Need <span>more</span> space <i>s</i> !<br></body></html>',
wantLineAttribs: ['+g*0+1+2'],
wantText: ['Need more space s !'],
},
dontDeleteSpaceAtEndOfElement: {
description: 'Preserve spaces at the end of an element',
html: '<html><body>Need <span>more </span>space <i>s </i>!<br></body></html>',
wantLineAttribs: ['+g*0+2+1'],
wantText: ['Need more space s !'],
},
dontDeleteSpaceAtBeginOfElements: {
description: 'Preserve spaces at the start of an element',
html: '<html><body>Need<span> more</span> space<i> s</i> !<br></body></html>',
wantLineAttribs: ['+f*0+2+2'],
wantText: ['Need more space s !'],
},
};
describe(__filename, function () {
for (const test of Object.keys(tests)) {
const testObj = tests[test];
describe(test, function () {
if (testObj.disabled) {
return xit('DISABLED:', test, function (done) {
done();
});
}
it(testObj.description, async function () {
const $ = cheerio.load(testObj.html); // Load HTML into Cheerio
const doc = $('body')[0]; // Creates a dom-like representation of HTML
// Create an empty attribute pool
const apool = new AttributePool();
// Convert a dom tree into a list of lines and attribute liens
// using the content collector object
const cc = contentcollector.makeContentCollector(true, null, apool);
cc.collectContent(doc);
const result = cc.finish();
const gotAttributes = result.lineAttribs;
const wantAttributes = testObj.wantLineAttribs;
const gotText = new Array(result.lines);
const wantText = testObj.wantText;
assert.deepEqual(gotText[0], wantText);
assert.deepEqual(gotAttributes, wantAttributes);
});
});
}
});
function arraysEqual(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
if (a.length !== b.length) return false;
// If you don't care about the order of the elements inside
// the array, you should sort both arrays here.
// Please note that calling sort on an array will modify that array.
// you might want to clone your array first.
for (let i = 0; i < a.length; ++i) {
if (a[i] !== b[i]) return false;
}
return true;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
const assert = require('assert').strict;
const promises = require('../../../node/utils/promises');
describe(__filename, function () {
describe('promises.timesLimit', function () {
let wantIndex = 0;
const testPromises = [];
const makePromise = (index) => {
// Make sure index increases by one each time.
assert.equal(index, wantIndex++);
// Save the resolve callback (so the test can trigger resolution)
// and the promise itself (to wait for resolve to take effect).
const p = {};
const promise = new Promise((resolve) => {
p.resolve = resolve;
});
p.promise = promise;
testPromises.push(p);
return p.promise;
};
const total = 11;
const concurrency = 7;
const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise);
it('honors concurrency', async function () {
assert.equal(wantIndex, concurrency);
});
it('creates another when one completes', async function () {
const {promise, resolve} = testPromises.shift();
resolve();
await promise;
assert.equal(wantIndex, concurrency + 1);
});
it('creates the expected total number of promises', async function () {
while (testPromises.length > 0) {
// Resolve them in random order to ensure that the resolution order doesn't matter.
const i = Math.floor(Math.random() * Math.floor(testPromises.length));
const {promise, resolve} = testPromises.splice(i, 1)[0];
resolve();
await promise;
}
assert.equal(wantIndex, total);
});
it('resolves', async function () {
await timesLimitPromise;
});
it('does not create too many promises if total < concurrency', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
const total = 7;
const concurrency = 11;
const timesLimitPromise = promises.timesLimit(total, concurrency, makePromise);
while (testPromises.length > 0) {
const {promise, resolve} = testPromises.pop();
resolve();
await promise;
}
await timesLimitPromise;
assert.equal(wantIndex, total);
});
it('accepts total === 0, concurrency > 0', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
await promises.timesLimit(0, concurrency, makePromise);
assert.equal(wantIndex, 0);
});
it('accepts total === 0, concurrency === 0', async function () {
wantIndex = 0;
assert.equal(testPromises.length, 0);
await promises.timesLimit(0, 0, makePromise);
assert.equal(wantIndex, 0);
});
it('rejects total > 0, concurrency === 0', async function () {
await assert.rejects(promises.timesLimit(total, 0, makePromise), RangeError);
});
});
});

View file

@ -0,0 +1,357 @@
'use strict';
const assert = require('assert').strict;
const common = require('../common');
const io = require('socket.io-client');
const padManager = require('../../../node/db/PadManager');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const setCookieParser = require('set-cookie-parser');
const settings = require('../../../node/utils/Settings');
const logger = common.logger;
// Waits for and returns the next named socket.io event. Rejects if there is any error while waiting
// (unless waiting for that error event).
const getSocketEvent = async (socket, event) => {
const errorEvents = [
'error',
'connect_error',
'connect_timeout',
'reconnect_error',
'reconnect_failed',
];
const handlers = {};
let timeoutId;
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error(`timed out waiting for ${event} event`)), 1000);
for (const event of errorEvents) {
handlers[event] = (errorString) => {
logger.debug(`socket.io ${event} event: ${errorString}`);
reject(new Error(errorString));
};
}
// This will overwrite one of the above handlers if the user is waiting for an error event.
handlers[event] = (...args) => {
logger.debug(`socket.io ${event} event`);
if (args.length > 1) return resolve(args);
resolve(args[0]);
};
Object.entries(handlers).forEach(([event, handler]) => socket.on(event, handler));
}).finally(() => {
clearTimeout(timeoutId);
Object.entries(handlers).forEach(([event, handler]) => socket.off(event, handler));
});
};
// Establishes a new socket.io connection. Passes the cookies from the `set-cookie` header(s) in
// `res` (which may be nullish) to the server. Returns a socket.io Socket object.
const connect = async (res) => {
// Convert the `set-cookie` header(s) into a `cookie` header.
const resCookies = (res == null) ? {} : setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies).map(
([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`).join('; ');
logger.debug('socket.io connecting...');
const socket = io(`${common.baseUrl}/`, {
forceNew: true, // Different tests will have different query parameters.
path: '/socket.io',
// socketio.js-client on node.js doesn't support cookies (see https://git.io/JU8u9), so the
// express_sid cookie must be passed as a query parameter.
query: {cookie: reqCookieHdr},
});
try {
await getSocketEvent(socket, 'connect');
} catch (e) {
socket.close();
throw e;
}
logger.debug('socket.io connected');
return socket;
};
// Helper function to exchange CLIENT_READY+CLIENT_VARS messages for the named pad.
// Returns the CLIENT_VARS message from the server.
const handshake = async (socket, padID) => {
logger.debug('sending CLIENT_READY...');
socket.send({
component: 'pad',
type: 'CLIENT_READY',
padId: padID,
sessionID: null,
token: 't.12345',
protocolVersion: 2,
});
logger.debug('waiting for CLIENT_VARS response...');
const msg = await getSocketEvent(socket, 'message');
logger.debug('received CLIENT_VARS message');
return msg;
};
describe(__filename, function () {
let agent;
let authorize;
const backups = {};
const cleanUpPads = async () => {
const padIds = ['pad', 'other-pad', 'päd'];
await Promise.all(padIds.map(async (padId) => {
if (await padManager.doesPadExist(padId)) {
const pad = await padManager.getPad(padId);
await pad.remove();
}
}));
};
let socket;
before(async function () { agent = await common.init(); });
beforeEach(async function () {
backups.hooks = {};
for (const hookName of ['preAuthorize', 'authenticate', 'authorize']) {
backups.hooks[hookName] = plugins.hooks[hookName];
plugins.hooks[hookName] = [];
}
backups.settings = {};
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users']) {
backups.settings[setting] = settings[setting];
}
settings.editOnly = false;
settings.requireAuthentication = false;
settings.requireAuthorization = false;
settings.users = {
admin: {password: 'admin-password', is_admin: true},
user: {password: 'user-password'},
};
assert(socket == null);
authorize = () => true;
plugins.hooks.authorize = [{hook_fn: (hookName, {req}, cb) => cb([authorize(req)])}];
await cleanUpPads();
});
afterEach(async function () {
if (socket) socket.close();
socket = null;
await cleanUpPads();
Object.assign(plugins.hooks, backups.hooks);
Object.assign(settings, backups.settings);
});
describe('Normal accesses', function () {
it('!authn anonymous cookie /p/pad -> 200, ok', async function () {
const res = await agent.get('/p/pad').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('!authn !cookie -> ok', async function () {
socket = await connect(null);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('!authn user /p/pad -> 200, ok', async function () {
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('authn user /p/pad -> 200, ok', async function () {
settings.requireAuthentication = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('authz user /p/pad -> 200, ok', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
it('supports pad names with characters that must be percent-encoded', async function () {
settings.requireAuthentication = true;
// requireAuthorization is set to true here to guarantee that the user's padAuthorizations
// object is populated. Technically this isn't necessary because the user's padAuthorizations
// is currently populated even if requireAuthorization is false, but setting this to true
// ensures the test remains useful if the implementation ever changes.
settings.requireAuthorization = true;
const encodedPadId = encodeURIComponent('päd');
const res = await agent.get(`/p/${encodedPadId}`).auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'päd');
assert.equal(clientVars.type, 'CLIENT_VARS');
});
});
describe('Abnormal access attempts', function () {
it('authn anonymous /p/pad -> 401, error', async function () {
settings.requireAuthentication = true;
const res = await agent.get('/p/pad').expect(401);
// Despite the 401, try to create the pad via a socket.io connection anyway.
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('authn !cookie -> error', async function () {
settings.requireAuthentication = true;
socket = await connect(null);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('authorization bypass attempt -> error', async function () {
// Only allowed to access /p/pad.
authorize = (req) => req.path === '/p/pad';
settings.requireAuthentication = true;
settings.requireAuthorization = true;
// First authenticate and establish a session.
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
// Accessing /p/other-pad should fail, despite the successful fetch of /p/pad.
const message = await handshake(socket, 'other-pad');
assert.equal(message.accessStatus, 'deny');
});
});
describe('Authorization levels via authorize hook', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it("level='create' -> can create", async function () {
authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('level=true -> can create', async function () {
authorize = () => true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='modify' -> can modify", async function () {
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'modify';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it("level='create' settings.editOnly=true -> unable to create", async function () {
authorize = () => 'create';
settings.editOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='modify' settings.editOnly=false -> unable to create", async function () {
authorize = () => 'modify';
settings.editOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to create", async function () {
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it("level='readOnly' -> unable to modify", async function () {
await padManager.getPad('pad'); // Create the pad.
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
});
describe('Authorization levels via user settings', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
});
it('user.canCreate = true -> can create and modify', async function () {
settings.users.user.canCreate = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.canCreate = false -> unable to create', async function () {
settings.users.user.canCreate = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to create', async function () {
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user.readOnly = true -> unable to modify', async function () {
await padManager.getPad('pad'); // Create the pad.
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, true);
});
it('user.readOnly = false -> can create and modify', async function () {
settings.users.user.readOnly = false;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const clientVars = await handshake(socket, 'pad');
assert.equal(clientVars.type, 'CLIENT_VARS');
assert.equal(clientVars.data.readonly, false);
});
it('user.readOnly = true, user.canCreate = true -> unable to create', async function () {
settings.users.user.canCreate = true;
settings.users.user.readOnly = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
});
describe('Authorization level interaction between authorize hook and user settings', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it('authorize hook does not elevate level from user settings', async function () {
settings.users.user.readOnly = true;
authorize = () => 'create';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
it('user settings does not elevate level from authorize hook', async function () {
settings.users.user.readOnly = false;
settings.users.user.canCreate = true;
authorize = () => 'readOnly';
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await connect(res);
const message = await handshake(socket, 'pad');
assert.equal(message.accessStatus, 'deny');
});
});
});

View file

@ -0,0 +1,25 @@
const common = require('../common');
const settings = require('../../../node/utils/Settings');
describe(__filename, function () {
let agent;
const backups = {};
before(async function () { agent = await common.init(); });
beforeEach(async function () {
backups.settings = {};
for (const setting of ['requireAuthentication', 'requireAuthorization']) {
backups.settings[setting] = settings[setting];
}
settings.requireAuthentication = false;
settings.requireAuthorization = false;
});
afterEach(async function () {
Object.assign(settings, backups.settings);
});
describe('/javascript', function () {
it('/javascript -> 200', async function () {
await agent.get('/javascript').expect(200);
});
});
});

View file

@ -0,0 +1,481 @@
'use strict';
const assert = require('assert').strict;
const common = require('../common');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
const settings = require('../../../node/utils/Settings');
describe(__filename, function () {
let agent;
const backups = {};
const authHookNames = ['preAuthorize', 'authenticate', 'authorize'];
const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure'];
const makeHook = (hookName, hookFn) => ({
hook_fn: hookFn,
hook_fn_name: `fake_plugin/${hookName}`,
hook_name: hookName,
part: {plugin: 'fake_plugin'},
});
before(async function () { agent = await common.init(); });
beforeEach(async function () {
backups.hooks = {};
for (const hookName of authHookNames.concat(failHookNames)) {
backups.hooks[hookName] = plugins.hooks[hookName];
plugins.hooks[hookName] = [];
}
backups.settings = {};
for (const setting of ['requireAuthentication', 'requireAuthorization', 'users']) {
backups.settings[setting] = settings[setting];
}
settings.requireAuthentication = false;
settings.requireAuthorization = false;
settings.users = {
admin: {password: 'admin-password', is_admin: true},
user: {password: 'user-password'},
};
});
afterEach(async function () {
Object.assign(plugins.hooks, backups.hooks);
Object.assign(settings, backups.settings);
});
describe('webaccess: without plugins', function () {
it('!authn !authz anonymous / -> 200', async function () {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
await agent.get('/').expect(200);
});
it('!authn !authz anonymous /admin/ -> 401', async function () {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
await agent.get('/admin/').expect(401);
});
it('authn !authz anonymous / -> 401', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').expect(401);
});
it('authn !authz user / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').auth('user', 'user-password').expect(200);
});
it('authn !authz user /admin/ -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/admin/').auth('user', 'user-password').expect(403);
});
it('authn !authz admin / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/').auth('admin', 'admin-password').expect(200);
});
it('authn !authz admin /admin/ -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
});
it('authn authz user / -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/').auth('user', 'user-password').expect(403);
});
it('authn authz user /admin/ -> 403', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/admin/').auth('user', 'user-password').expect(403);
});
it('authn authz admin / -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/').auth('admin', 'admin-password').expect(200);
});
it('authn authz admin /admin/ -> 200', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
});
describe('login fails if password is nullish', function () {
for (const adminPassword of [undefined, null]) {
// https://tools.ietf.org/html/rfc7617 says that the username and password are sent as
// base64(username + ':' + password), but there's nothing stopping a malicious user from
// sending just base64(username) (no colon). The lack of colon could throw off credential
// parsing, resulting in successful comparisons against a null or undefined password.
for (const creds of ['admin', 'admin:']) {
it(`admin password: ${adminPassword} credentials: ${creds}`, async function () {
settings.users.admin.password = adminPassword;
const encCreds = Buffer.from(creds).toString('base64');
await agent.get('/admin/').set('Authorization', `Basic ${encCreds}`).expect(401);
});
}
}
});
});
describe('webaccess: preAuthorize, authenticate, and authorize hooks', function () {
let callOrder;
const Handler = class {
constructor(hookName, suffix) {
this.called = false;
this.hookName = hookName;
this.innerHandle = () => [];
this.id = hookName + suffix;
this.checkContext = () => {};
}
handle(hookName, context, cb) {
assert.equal(hookName, this.hookName);
assert(context != null);
assert(context.req != null);
assert(context.res != null);
assert(context.next != null);
this.checkContext(context);
assert(!this.called);
this.called = true;
callOrder.push(this.id);
return cb(this.innerHandle(context.req));
}
};
const handlers = {};
beforeEach(async function () {
callOrder = [];
for (const hookName of authHookNames) {
// Create two handlers for each hook to test deferral to the next function.
const h0 = new Handler(hookName, '_0');
const h1 = new Handler(hookName, '_1');
handlers[hookName] = [h0, h1];
plugins.hooks[hookName] = [
makeHook(hookName, h0.handle.bind(h0)),
makeHook(hookName, h1.handle.bind(h1)),
];
}
});
describe('preAuthorize', function () {
beforeEach(async function () {
settings.requireAuthentication = false;
settings.requireAuthorization = false;
});
it('defers if it returns []', async function () {
await agent.get('/').expect(200);
// Note: The preAuthorize hook always runs even if requireAuthorization is false.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('bypasses authenticate and authorize hooks when true is returned', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks when false is returned', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('bypasses authenticate and authorize hooks for static content, defers', async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
await agent.get('/static/robots.txt').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('cannot grant access to /admin', async function () {
handlers.preAuthorize[0].innerHandle = () => [true];
await agent.get('/admin/').expect(401);
// Notes:
// * preAuthorize[1] is called despite preAuthorize[0] returning a non-empty list because
// 'true' entries are ignored for /admin/* requests.
// * The authenticate hook always runs for /admin/* requests even if
// settings.requireAuthentication is false.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('can deny access to /admin', async function () {
handlers.preAuthorize[0].innerHandle = () => [false];
await agent.get('/admin/').auth('admin', 'admin-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0']);
});
it('runs preAuthzFailure hook when access is denied', async function () {
handlers.preAuthorize[0].innerHandle = () => [false];
let called = false;
plugins.hooks.preAuthzFailure = [makeHook('preAuthzFailure', (hookName, {req, res}, cb) => {
assert.equal(hookName, 'preAuthzFailure');
assert(req != null);
assert(res != null);
assert(!called);
called = true;
res.status(200).send('injected');
return cb([true]);
})];
await agent.get('/admin/').auth('admin', 'admin-password').expect(200, 'injected');
assert(called);
});
it('returns 500 if an exception is thrown', async function () {
handlers.preAuthorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
});
});
describe('authenticate', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = false;
});
it('is not called if !requireAuthentication and not /admin/*', async function () {
settings.requireAuthentication = false;
await agent.get('/').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1']);
});
it('is called if !requireAuthentication and /admin/*', async function () {
settings.requireAuthentication = false;
await agent.get('/admin/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('defers if empty list returned', async function () {
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('does not defer if return [true], 200', async function () {
handlers.authenticate[0].innerHandle = (req) => { req.session.user = {}; return [true]; };
await agent.get('/').expect(200);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('does not defer if return [false], 401', async function () {
handlers.authenticate[0].innerHandle = (req) => [false];
await agent.get('/').expect(401);
// Note: authenticate_1 was not called because authenticate_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('falls back to HTTP basic auth', async function () {
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('passes settings.users in context', async function () {
handlers.authenticate[0].checkContext = ({users}) => {
assert.equal(users, settings.users);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('passes user, password in context if provided', async function () {
handlers.authenticate[0].checkContext = ({username, password}) => {
assert.equal(username, 'user');
assert.equal(password, 'user-password');
};
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('does not pass user, password in context if not provided', async function () {
handlers.authenticate[0].checkContext = ({username, password}) => {
assert(username == null);
assert(password == null);
};
await agent.get('/').expect(401);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('errors if req.session.user is not created', async function () {
handlers.authenticate[0].innerHandle = () => [true];
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
it('returns 500 if an exception is thrown', async function () {
handlers.authenticate[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0', 'preAuthorize_1', 'authenticate_0']);
});
});
describe('authorize', function () {
beforeEach(async function () {
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
it('is not called if !requireAuthorization (non-/admin)', async function () {
settings.requireAuthorization = false;
await agent.get('/').auth('user', 'user-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('is not called if !requireAuthorization (/admin)', async function () {
settings.requireAuthorization = false;
await agent.get('/admin/').auth('admin', 'admin-password').expect(200);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1']);
});
it('defers if empty list returned', async function () {
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0',
'authorize_1']);
});
it('does not defer if return [true], 200', async function () {
handlers.authorize[0].innerHandle = () => [true];
await agent.get('/').auth('user', 'user-password').expect(200);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
it('does not defer if return [false], 403', async function () {
handlers.authorize[0].innerHandle = (req) => [false];
await agent.get('/').auth('user', 'user-password').expect(403);
// Note: authorize_1 was not called because authorize_0 handled it.
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
it('passes req.path in context', async function () {
handlers.authorize[0].checkContext = ({resource}) => {
assert.equal(resource, '/');
};
await agent.get('/').auth('user', 'user-password').expect(403);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0',
'authorize_1']);
});
it('returns 500 if an exception is thrown', async function () {
handlers.authorize[0].innerHandle = () => { throw new Error('exception test'); };
await agent.get('/').auth('user', 'user-password').expect(500);
assert.deepEqual(callOrder, ['preAuthorize_0',
'preAuthorize_1',
'authenticate_0',
'authenticate_1',
'authorize_0']);
});
});
});
describe('webaccess: authnFailure, authzFailure, authFailure hooks', function () {
const Handler = class {
constructor(hookName) {
this.hookName = hookName;
this.shouldHandle = false;
this.called = false;
}
handle(hookName, context, cb) {
assert.equal(hookName, this.hookName);
assert(context != null);
assert(context.req != null);
assert(context.res != null);
assert(!this.called);
this.called = true;
if (this.shouldHandle) {
context.res.status(200).send(this.hookName);
return cb([true]);
}
return cb([]);
}
};
const handlers = {};
beforeEach(async function () {
failHookNames.forEach((hookName) => {
const handler = new Handler(hookName);
handlers[hookName] = handler;
plugins.hooks[hookName] = [makeHook(hookName, handler.handle.bind(handler))];
});
settings.requireAuthentication = true;
settings.requireAuthorization = true;
});
// authn failure tests
it('authn fail, no hooks handle -> 401', async function () {
await agent.get('/').expect(401);
assert(handlers.authnFailure.called);
assert(!handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authn fail, authnFailure handles', async function () {
handlers.authnFailure.shouldHandle = true;
await agent.get('/').expect(200, 'authnFailure');
assert(handlers.authnFailure.called);
assert(!handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
it('authn fail, authFailure handles', async function () {
handlers.authFailure.shouldHandle = true;
await agent.get('/').expect(200, 'authFailure');
assert(handlers.authnFailure.called);
assert(!handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authnFailure trumps authFailure', async function () {
handlers.authnFailure.shouldHandle = true;
handlers.authFailure.shouldHandle = true;
await agent.get('/').expect(200, 'authnFailure');
assert(handlers.authnFailure.called);
assert(!handlers.authFailure.called);
});
// authz failure tests
it('authz fail, no hooks handle -> 403', async function () {
await agent.get('/').auth('user', 'user-password').expect(403);
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authz fail, authzFailure handles', async function () {
handlers.authzFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
it('authz fail, authFailure handles', async function () {
handlers.authFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authFailure');
assert(!handlers.authnFailure.called);
assert(handlers.authzFailure.called);
assert(handlers.authFailure.called);
});
it('authzFailure trumps authFailure', async function () {
handlers.authzFailure.shouldHandle = true;
handlers.authFailure.shouldHandle = true;
await agent.get('/').auth('user', 'user-password').expect(200, 'authzFailure');
assert(handlers.authzFailure.called);
assert(!handlers.authFailure.called);
});
});
});