sync/test/web/routes/account/data.js

583 lines
18 KiB
JavaScript

const assert = require('assert');
const sinon = require('sinon');
const express = require('express');
const { AccountDB } = require('../../../../lib/db/account');
const { ChannelDB } = require('../../../../lib/db/channel');
const { AccountController } = require('../../../../lib/controller/account');
const { AccountDataRoute } = require('../../../../lib/web/routes/account/data');
const http = require('http');
const expressBabelDecorators = require('@calzoneman/express-babel-decorators');
const nodeurl = require('url');
const Promise = require('bluebird');
const bodyParser = require('body-parser');
const { CSRFError } = require('../../../../lib/errors');
const { EventEmitter } = require('events');
const TEST_PORT = 10111;
const URL_BASE = `http://localhost:${TEST_PORT}`;
function request(method, url, additionalOptions) {
if (!additionalOptions) additionalOptions = {};
const { body } = additionalOptions;
if (body) {
delete additionalOptions.body;
if (!additionalOptions.headers) {
additionalOptions.headers = {
'Accept': 'application/json'
};
}
additionalOptions.headers['Content-Type'] = 'application/json';
}
return new Promise((resolve, reject) => {
const options = {
headers: {
'Accept': 'application/json'
},
method
};
Object.assign(options, nodeurl.parse(url), additionalOptions);
const req = http.request(options);
req.on('error', error => {
reject(error);
});
req.on('response', res => {
let buffer = '';
res.setEncoding('utf8');
res.on('data', data => {
buffer += data;
});
res.on('end', () => {
res.body = buffer;
resolve(res);
});
});
if (body) {
req.write(JSON.stringify(body));
}
req.end();
});
}
describe('AccountDataRoute', () => {
let accountDB;
let channelDB;
let csrfVerify;
let verifySessionAsync;
let server;
let app;
let signedCookies;
let accountDataRoute;
beforeEach(() => {
let realAccountDB = new AccountDB();
let realChannelDB = new ChannelDB();
accountDB = sinon.mock(realAccountDB);
channelDB = sinon.mock(realChannelDB);
csrfVerify = sinon.stub();
verifySessionAsync = sinon.stub();
verifySessionAsync.withArgs('test_auth_cookie').resolves({ name: 'test' });
signedCookies = {
auth: 'test_auth_cookie'
};
app = express();
app.use((req, res, next) => {
req.signedCookies = signedCookies;
next();
});
app.use(bodyParser.json({
limit: '1kb'
}));
accountDataRoute = new AccountDataRoute(
new AccountController(realAccountDB, new EventEmitter()),
realChannelDB,
csrfVerify,
verifySessionAsync
);
expressBabelDecorators.bind(app, accountDataRoute);
server = http.createServer(app);
server.listen(TEST_PORT);
});
afterEach(() => {
server.close();
});
function checkDefaults(route, method) {
it('rejects requests that don\'t accept JSON', () => {
return request(method, `${URL_BASE}${route}`, {
headers: { 'Accept': 'text/plain' }
}).then(res => {
assert.strictEqual(res.statusCode, 406);
assert.deepStrictEqual(
res.body,
'Not Acceptable'
);
});
});
it('rejects requests with no auth cookie', () => {
signedCookies.auth = null;
return request(method, `${URL_BASE}${route}`).then(res => {
assert.strictEqual(res.statusCode, 401);
const response = JSON.parse(res.body);
assert.deepStrictEqual(
response,
{ error: 'Authorization required' }
);
});
});
it('rejects requests with invalid auth cookie', () => {
signedCookies.auth = 'invalid';
verifySessionAsync.withArgs('invalid').rejects(new Error('Invalid'));
return request(method, `${URL_BASE}${route}`).then(res => {
assert.strictEqual(res.statusCode, 403);
const response = JSON.parse(res.body);
assert.deepStrictEqual(
response,
{ error: 'Invalid' }
);
assert(verifySessionAsync.calledWith('invalid'));
});
});
it('rejects requests with mismatched auth cookie', () => {
signedCookies.auth = 'mismatch';
verifySessionAsync.withArgs('mismatch').resolves({ name: 'not_test' });
return request(method, `${URL_BASE}${route}`).then(res => {
assert.strictEqual(res.statusCode, 403);
const response = JSON.parse(res.body);
assert.deepStrictEqual(
response,
{ error: 'Session username does not match' }
);
assert(verifySessionAsync.calledWith('mismatch'));
});
});
it('rejects requests with invalid CSRF tokens', () => {
csrfVerify.throws(new CSRFError('CSRF'));
return request(method, `${URL_BASE}${route}`).then(res => {
assert.strictEqual(res.statusCode, 403);
const response = JSON.parse(res.body);
assert.deepStrictEqual(
response,
{ error: 'Invalid CSRF token' }
);
assert(csrfVerify.called);
});
});
it('rejects requests with an internal CSRF handling error', () => {
csrfVerify.throws(new Error('broken'));
return request(method, `${URL_BASE}${route}`).then(res => {
assert.strictEqual(res.statusCode, 503);
const response = JSON.parse(res.body);
assert.deepStrictEqual(
response,
{ error: 'Internal error' }
);
assert(csrfVerify.called);
});
});
}
describe('#getAccount', () => {
it('serves a valid request', () => {
accountDB.expects('getByName').withArgs('test').returns({
name: 'test',
email: 'test@example.com',
profile: { text: 'blah', image: 'image.jpeg' },
time: new Date('2017-09-01T00:00:00.000Z'),
extraData: 'foo'
});
return request('GET', `${URL_BASE}/account/data/test`)
.then(res => {
assert.strictEqual(res.statusCode, 200);
const response = JSON.parse(res.body);
assert.deepStrictEqual(
response,
{
result: {
name: 'test',
email: 'test@example.com',
profile: { text: 'blah', image: 'image.jpeg' },
time: '2017-09-01T00:00:00.000Z'
}
}
);
assert(verifySessionAsync.calledWith(signedCookies.auth));
assert(csrfVerify.called);
accountDB.verify();
});
});
checkDefaults('/account/data/test', 'GET');
});
describe('#updateAccount', () => {
it('updates email', () => {
accountDB.expects('getByName').withArgs('test').returns({
name: 'test',
password: '$2a$10$c26sbtkVlYlFUBdSxzQGhenZvdPBI2fvTPOmVRyrBuaD.8j7iyoNm',
email: 'test@example.com',
profile: { text: 'blah', image: 'image.jpeg' },
time: new Date('2017-09-01T00:00:00.000Z')
});
accountDB.expects('updateByName').withArgs(
'test',
{ email: 'test_new@example.com' }
);
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
password: 'test',
updates: {
email: 'test_new@example.com'
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 204);
accountDB.verify();
});
});
it('updates profile', () => {
accountDB.expects('updateByName').withArgs(
'test',
{
profile: {
text: 'testing',
image: 'https://example.com/image.jpg'
}
}
);
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
profile: {
text: 'testing',
image: 'https://example.com/image.jpg'
}
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 204);
accountDB.verify();
});
});
it('rejects invalid email address', () => {
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
password: 'test',
updates: {
email: 'not!!valid'
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Invalid email address'
);
accountDB.verify();
});
});
it('rejects request to change email with no password', () => {
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
email: 'test_new@example.com'
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Password required'
);
accountDB.verify();
});
});
it('rejects invalid password', () => {
accountDB.expects('getByName').withArgs('test').returns({
name: 'test',
password: '$2a$10$c26sbtkVlYlFUBdSxzQGhenZvdPBI2fvTPOmVRyrBuaD.8j7iyoNm',
email: 'test@example.com',
profile: { text: 'blah', image: 'image.jpeg' },
time: new Date('2017-09-01T00:00:00.000Z')
});
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
password: 'wrong',
updates: {
email: 'test_new@example.com'
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Invalid password'
);
accountDB.verify();
});
});
it('rejects non-existing user', () => {
accountDB.expects('getByName').withArgs('test').returns(null);
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
password: 'test',
updates: {
email: 'test_new@example.com'
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'User does not exist'
);
accountDB.verify();
});
});
it('rejects invalid input', () => {
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: ['not correct']
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Malformed input'
);
accountDB.verify();
});
});
it('rejects invalid profile', () => {
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
profile: 'not valid'
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Invalid profile'
);
accountDB.verify();
});
});
it('rejects wrongly typed profile text', () => {
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
profile: {
text: ['wrong'],
image: 'https://example.com'
}
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Invalid profile'
);
accountDB.verify();
});
});
it('rejects too long profile text', () => {
let longText = ''; for (let i = 0; i < 256; i++) longText += 'a';
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
profile: {
text: longText,
image: 'https://example.com'
}
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Profile text must not exceed 255 characters'
);
accountDB.verify();
});
});
it('rejects wrongly typed profile image', () => {
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
profile: {
text: 'test',
image: 42
}
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Invalid profile'
);
accountDB.verify();
});
});
it('rejects too long profile image', () => {
let longText = 'https://'; for (let i = 0; i < 256; i++) longText += 'a';
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
profile: {
text: 'test',
image: longText
}
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Profile image URL must not exceed 255 characters'
);
accountDB.verify();
});
});
it('rejects non-https profile image', () => {
return request('PATCH', `${URL_BASE}/account/data/test`, {
body: {
updates: {
profile: {
text: 'test',
image: 'http://example.com/image.jpg'
}
}
}
}).then(res => {
assert.strictEqual(res.statusCode, 400);
assert.strictEqual(
JSON.parse(res.body).error,
'Profile image URL must start with "https:"'
);
accountDB.verify();
});
});
checkDefaults('/account/data/test', 'PATCH');
});
describe('#createChannel', () => {
checkDefaults('/account/data/test/channels/test_channel', 'POST');
});
describe('#deleteChannel', () => {
checkDefaults('/account/data/test/channels/test_channel', 'DELETE');
});
describe('#listChannels', () => {
it('serves a valid request', () => {
channelDB.expects('listByOwner').withArgs('test').returns([{
name: 'test_channel',
owner: 'test',
time: new Date('2017-09-01T00:00:00.000Z'),
last_loaded: new Date('2017-09-01T01:00:00.000Z'),
owner_last_seen: new Date('2017-09-01T02:00:00.000Z'),
extraData: 'foo'
}]);
return request('GET', `${URL_BASE}/account/data/test/channels`)
.then(res => {
assert.strictEqual(res.statusCode, 200);
const response = JSON.parse(res.body);
assert.deepStrictEqual(
response,
{
result: [{
name: 'test_channel',
owner: 'test',
time: '2017-09-01T00:00:00.000Z',
last_loaded: '2017-09-01T01:00:00.000Z',
owner_last_seen: '2017-09-01T02:00:00.000Z',
}]
}
);
assert(verifySessionAsync.calledWith(signedCookies.auth));
assert(csrfVerify.called);
channelDB.verify();
});
});
checkDefaults('/account/data/test/channels', 'GET');
});
});