Testing #307

Merged
bones7242 merged 16 commits from testing into master 2017-12-19 00:30:32 +01:00
13 changed files with 323 additions and 160 deletions

View file

@ -1,2 +1,3 @@
node_modules/ node_modules/
public/ public/
test

View file

@ -18,7 +18,7 @@
], ],
"semi": [ "semi": [
"error", "error",
"always", "always",
{ "omitLastInOneLineBlock": true } { "omitLastInOneLineBlock": true }
], ],
"key-spacing": [ "key-spacing": [

View file

@ -21,6 +21,11 @@ spee.ch is a single-serving site that reads and publishes images and videos to a
* To run hot, use `nodemon` instead of `node` * To run hot, use `nodemon` instead of `node`
* visit [localhost:3000](http://localhost:3000) * visit [localhost:3000](http://localhost:3000)
## Tests
* Spee.ch uses `mocha` with `chai` for testing.
* To run all tests that do not require LBC, run `npm test -- --grep @usesLbc --invert`
* To run all tests, including those that require LBC (like publishing), simply run `npm test`
## API ## API
#### GET #### GET

View file

@ -34,9 +34,9 @@ module.exports = {
}); });
}); });
}, },
authenticateOrSkip (skipAuth, channelName, channelPassword) { authenticateIfNoUserToken (channelName, channelPassword, user) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (skipAuth) { if (user || !channelName) {
return resolve(true); return resolve(true);
} }
return resolve(module.exports.authenticateChannelCredentials(channelName, channelPassword)); return resolve(module.exports.authenticateChannelCredentials(channelName, channelPassword));

View file

@ -2,6 +2,7 @@ const logger = require('winston');
const db = require('../models'); const db = require('../models');
const lbryApi = require('../helpers/lbryApi.js'); const lbryApi = require('../helpers/lbryApi.js');
const publishHelpers = require('../helpers/publishHelpers.js'); const publishHelpers = require('../helpers/publishHelpers.js');
const config = require('../config/speechConfig.js');
module.exports = { module.exports = {
publish (publishParams, fileName, fileType) { publish (publishParams, fileName, fileType) {
@ -85,4 +86,45 @@ module.exports = {
}); });
}); });
}, },
checkClaimNameAvailability (name) {
return new Promise((resolve, reject) => {
// find any records where the name is used
db.File.findAll({ where: { name } })
.then(result => {
if (result.length >= 1) {
const claimAddress = config.wallet.lbryClaimAddress;
// filter out any results that were not published from spee.ch's wallet address
const filteredResult = result.filter((claim) => {
return (claim.address === claimAddress);
});
// return based on whether any non-spee.ch claims were left
if (filteredResult.length >= 1) {
resolve(false);
} else {
resolve(true);
}
} else {
resolve(true);
}
})
.catch(error => {
reject(error);
});
});
},
checkChannelAvailability (name) {
return new Promise((resolve, reject) => {
// find any records where the name is used
db.Channel.findAll({ where: { channelName: name } })
.then(result => {
if (result.length >= 1) {
return resolve(false);
}
resolve(true);
})
.catch(error => {
reject(error);
});
});
},
}; };

View file

@ -1,41 +1,87 @@
const logger = require('winston'); const logger = require('winston');
const fs = require('fs'); const fs = require('fs');
const db = require('../models');
const { site, wallet } = require('../config/speechConfig.js'); const { site, wallet } = require('../config/speechConfig.js');
module.exports = { module.exports = {
validateApiPublishRequest (body, files) { parsePublishApiRequestBody ({name, nsfw, license, title, description, thumbnail}) {
if (!body) { // validate name
throw new Error('no body found in request'); if (!name) {
}
if (!body.name) {
throw new Error('no name field found in request'); throw new Error('no name field found in request');
} }
if (!files) { const invalidNameCharacters = /[^A-Za-z0-9,-]/.exec(name);
throw new Error('no files found in request'); if (invalidNameCharacters) {
throw new Error('The claim name you provided is not allowed. Only the following characters are allowed: A-Z, a-z, 0-9, and "-"');
} }
if (!files.file) { // optional parameters
nsfw = (nsfw === 'true');
license = license || null;
title = title || null;
description = description || null;
thumbnail = thumbnail || null;
// return results
return {
name,
nsfw,
license,
title,
description,
thumbnail,
};
},
parsePublishApiRequestFiles ({file}) {
logger.debug('file', file);
// make sure a file was provided
if (!file) {
throw new Error('no file with key of [file] found in request'); throw new Error('no file with key of [file] found in request');
} }
}, if (!file.path) {
validatePublishSubmission (file, claimName) { throw new Error('no file path found');
try {
module.exports.validateFile(file);
module.exports.validateClaimName(claimName);
} catch (error) {
throw error;
} }
}, if (!file.type) {
validateFile (file) { throw new Error('no file type found');
if (!file) {
logger.debug('publish > file validation > no file found');
throw new Error('no file provided');
} }
// check the file name if (!file.size) {
throw new Error('no file type found');
}
// validate the file name
if (/'/.test(file.name)) { if (/'/.test(file.name)) {
logger.debug('publish > file validation > file name had apostrophe in it'); logger.debug('publish > file validation > file name had apostrophe in it');
throw new Error('apostrophes are not allowed in the file name'); throw new Error('apostrophes are not allowed in the file name');
} }
// validate the file
module.exports.validateFileTypeAndSize(file);
// return results
return {
fileName: file.name,
filePath: file.path,
fileType: file.type,
};
},
parsePublishApiChannel ({channelName, channelPassword}, user) {
logger.debug('publish api parser input:', {channelName, channelPassword, user});
// if anonymous or '' provided, publish will be anonymous (even if client is logged in)
// if a channel name is provided...
if (channelName) {
// make sure a password was provided if no user token is provided
if (!user && !channelPassword) {
throw new Error('Unauthenticated channel name provided without password');
}
// if request comes from the client with a token
// ensure this publish uses that channel name
if (user) {
channelName = user.channelName;
} ;
// add the @ if the channel name is missing it
if (channelName.indexOf('@') !== 0) {
channelName = `@${channelName}`;
}
}
return {
channelName,
channelPassword,
};
},
validateFileTypeAndSize (file) {
// check file type and size // check file type and size
switch (file.type) { switch (file.type) {
case 'image/jpeg': case 'image/jpeg':
@ -64,21 +110,6 @@ module.exports = {
} }
return file; return file;
}, },
validateClaimName (claimName) {
const invalidCharacters = /[^A-Za-z0-9,-]/.exec(claimName);
if (invalidCharacters) {
throw new Error('The claim name you provided is not allowed. Only the following characters are allowed: A-Z, a-z, 0-9, and "-"');
}
},
cleanseChannelName (channelName) {
if (!channelName) {
return null;
}
if (channelName.indexOf('@') !== 0) {
channelName = `@${channelName}`;
}
return channelName;
},
createPublishParams (filePath, name, title, description, license, nsfw, thumbnail, channelName) { createPublishParams (filePath, name, title, description, license, nsfw, thumbnail, channelName) {
logger.debug(`Creating Publish Parameters`); logger.debug(`Creating Publish Parameters`);
// provide defaults for title // provide defaults for title
@ -127,45 +158,5 @@ module.exports = {
logger.debug(`successfully deleted ${filePath}`); logger.debug(`successfully deleted ${filePath}`);
}); });
}, },
checkClaimNameAvailability (name) {
return new Promise((resolve, reject) => {
// find any records where the name is used
db.File.findAll({ where: { name } })
.then(result => {
if (result.length >= 1) {
const claimAddress = wallet.lbryClaimAddress;
// filter out any results that were not published from the site's wallet address
const filteredResult = result.filter((claim) => {
return (claim.address === claimAddress);
});
// return based on whether any claims were left
if (filteredResult.length >= 1) {
resolve(false);
} else {
resolve(true);
}
} else {
resolve(true);
}
})
.catch(error => {
reject(error);
});
});
},
checkChannelAvailability (name) {
return new Promise((resolve, reject) => {
// find any records where the name is used
db.Channel.findAll({ where: { channelName: name } })
.then(result => {
if (result.length >= 1) {
return resolve(false);
}
resolve(true);
})
.catch(error => {
reject(error);
});
});
},
}; };

View file

@ -4,7 +4,8 @@
"description": "a single-serving site that reads and publishes images to and from the LBRY blockchain", "description": "a single-serving site that reads and publishes images to and from the LBRY blockchain",
"main": "speech.js", "main": "speech.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "mocha --recursive",
"test-all": "mocha --recursive",
"start": "node speech.js", "start": "node speech.js",
"lint": "eslint .", "lint": "eslint .",
"fix": "eslint . --fix", "fix": "eslint . --fix",
@ -40,6 +41,8 @@
"nodemon": "^1.11.0", "nodemon": "^1.11.0",
"passport": "^0.4.0", "passport": "^0.4.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"request": "^2.83.0",
"request-promise": "^4.2.2",
"sequelize": "^4.1.0", "sequelize": "^4.1.0",
"sequelize-cli": "^3.0.0-3", "sequelize-cli": "^3.0.0-3",
"sleep": "^5.1.1", "sleep": "^5.1.1",
@ -48,6 +51,8 @@
"winston-slack-webhook": "billbitt/winston-slack-webhook" "winston-slack-webhook": "billbitt/winston-slack-webhook"
}, },
"devDependencies": { "devDependencies": {
"chai": "^4.1.2",
"chai-http": "^3.0.0",
"eslint": "3.19.0", "eslint": "3.19.0",
"eslint-config-standard": "10.2.1", "eslint-config-standard": "10.2.1",
"eslint-plugin-import": "^2.2.0", "eslint-plugin-import": "^2.2.0",
@ -55,6 +60,7 @@
"eslint-plugin-promise": "3.5.0", "eslint-plugin-promise": "3.5.0",
"eslint-plugin-react": "6.10.3", "eslint-plugin-react": "6.10.3",
"eslint-plugin-standard": "3.0.1", "eslint-plugin-standard": "3.0.1",
"husky": "^0.13.4" "husky": "^0.13.4",
"mocha": "^4.0.1"
} }
} }

View file

@ -78,17 +78,20 @@ const publishFileFunctions = {
const licenseInput = document.getElementById('publish-license'); const licenseInput = document.getElementById('publish-license');
const nsfwInput = document.getElementById('publish-nsfw'); const nsfwInput = document.getElementById('publish-nsfw');
const thumbnailInput = document.getElementById('claim-thumbnail-input'); const thumbnailInput = document.getElementById('claim-thumbnail-input');
const channelName = this.returnNullOrChannel();
return { let metadata = {
name: nameInput.value.trim(), name: nameInput.value.trim(),
channelName: this.returnNullOrChannel(),
title: titleInput.value.trim(), title: titleInput.value.trim(),
description: descriptionInput.value.trim(), description: descriptionInput.value.trim(),
license: licenseInput.value.trim(), license: licenseInput.value.trim(),
nsfw: nsfwInput.checked, nsfw: nsfwInput.checked,
type: stagedFiles[0].type, type: stagedFiles[0].type,
thumbnail: thumbnailInput.value.trim(), thumbnail: thumbnailInput.value.trim(),
};
if (channelName) {
metadata['channelName'] = channelName;
} }
return metadata;
}, },
appendDataToFormData: function (file, metadata) { appendDataToFormData: function (file, metadata) {
var fd = new FormData(); var fd = new FormData();
@ -132,8 +135,6 @@ const publishFileFunctions = {
} else { } else {
that.showFilePublishFailure(JSON.parse(xhr.response).message); that.showFilePublishFailure(JSON.parse(xhr.response).message);
} }
} else {
console.log('xhr.readyState', xhr.readyState, 'xhr.status', xhr.status);
} }
}; };
// Initiate a multipart/form-data upload // Initiate a multipart/form-data upload
@ -143,7 +144,7 @@ const publishFileFunctions = {
publishStagedFile: function (event) { publishStagedFile: function (event) {
event.preventDefault(); // prevent default so this script can handle submission event.preventDefault(); // prevent default so this script can handle submission
const metadata = this.createMetadata(); const metadata = this.createMetadata();
const that = this; // note: necessary ? const that = this;
const fileSelectionInputError = document.getElementById('input-error-file-selection'); const fileSelectionInputError = document.getElementById('input-error-file-selection');
const claimNameError = document.getElementById('input-error-claim-name'); const claimNameError = document.getElementById('input-error-claim-name');
const channelSelectError = document.getElementById('input-error-channel-select'); const channelSelectError = document.getElementById('input-error-channel-select');

View file

@ -3,11 +3,11 @@ const multipart = require('connect-multiparty');
const { files, site } = require('../config/speechConfig.js'); const { files, site } = require('../config/speechConfig.js');
const multipartMiddleware = multipart({uploadDir: files.uploadDirectory}); const multipartMiddleware = multipart({uploadDir: files.uploadDirectory});
const db = require('../models'); const db = require('../models');
const { publish } = require('../controllers/publishController.js'); const { checkClaimNameAvailability, checkChannelAvailability, publish } = require('../controllers/publishController.js');
const { getClaimList, resolveUri, getClaim } = require('../helpers/lbryApi.js'); const { getClaimList, resolveUri, getClaim } = require('../helpers/lbryApi.js');
const { createPublishParams, validateApiPublishRequest, validatePublishSubmission, cleanseChannelName, checkClaimNameAvailability, checkChannelAvailability } = require('../helpers/publishHelpers.js'); const { createPublishParams, parsePublishApiRequestBody, parsePublishApiRequestFiles, parsePublishApiChannel } = require('../helpers/publishHelpers.js');
const errorHandlers = require('../helpers/errorHandlers.js'); const errorHandlers = require('../helpers/errorHandlers.js');
const { authenticateOrSkip } = require('../auth/authentication.js'); const { authenticateIfNoUserToken } = require('../auth/authentication.js');
function addGetResultsToFileData (fileInfo, getResult) { function addGetResultsToFileData (fileInfo, getResult) {
fileInfo.fileName = getResult.file_name; fileInfo.fileName = getResult.file_name;
@ -125,60 +125,21 @@ module.exports = (app) => {
}); });
// route to run a publish request on the daemon // route to run a publish request on the daemon
app.post('/api/claim-publish', multipartMiddleware, ({ body, files, ip, originalUrl, user }, res) => { app.post('/api/claim-publish', multipartMiddleware, ({ body, files, ip, originalUrl, user }, res) => {
let file, fileName, filePath, fileType, name, nsfw, license, title, description, thumbnail, anonymous, skipAuth, channelName, channelPassword; logger.debug('api/claim-publish body:', body);
// validate that mandatory parts of the request are present logger.debug('api/claim-publish files:', files);
let name, fileName, filePath, fileType, nsfw, license, title, description, thumbnail, channelName, channelPassword;
// validate the body and files of the request
try { try {
validateApiPublishRequest(body, files); // validateApiPublishRequest(body, files);
({name, nsfw, license, title, description, thumbnail} = parsePublishApiRequestBody(body));
({fileName, filePath, fileType} = parsePublishApiRequestFiles(files));
({channelName, channelPassword} = parsePublishApiChannel(body, user));
} catch (error) { } catch (error) {
logger.debug('publish request rejected, insufficient request parameters'); logger.debug('publish request rejected, insufficient request parameters', error);
res.status(400).json({success: false, message: error.message}); return res.status(400).json({success: false, message: error.message});
return;
} }
logger.debug('publish req.files:', files);
// validate file, name, license, and nsfw
file = files.file;
fileName = file.path.substring(file.path.lastIndexOf('/') + 1);
filePath = file.path;
fileType = file.type;
name = body.name;
nsfw = (body.nsfw === 'true');
try {
validatePublishSubmission(file, name, nsfw);
} catch (error) {
logger.debug('publish request rejected');
res.status(400).json({success: false, message: error.message});
return;
}
// optional inputs
license = body.license || null;
title = body.title || null;
description = body.description || null;
thumbnail = body.thumbnail || null;
anonymous = (body.channelName === 'null') || (body.channelName === undefined);
if (user) {
channelName = user.channelName || null;
} else {
channelName = body.channelName || null;
}
channelPassword = body.channelPassword || null;
skipAuth = false;
// case 1: publish from client, client logged in
if (user) {
skipAuth = true;
if (anonymous) {
channelName = null;
}
// case 2: publish from api or client, client not logged in
} else {
if (anonymous) {
skipAuth = true;
channelName = null;
}
}
channelName = cleanseChannelName(channelName);
logger.debug(`name: ${name}, license: ${license} title: "${title}" description: "${description}" channelName: "${channelName}" channelPassword: "${channelPassword}" nsfw: "${nsfw}"`);
// check channel authorization // check channel authorization
authenticateOrSkip(skipAuth, channelName, channelPassword) authenticateIfNoUserToken(channelName, channelPassword, user)
.then(authenticated => { .then(authenticated => {
if (!authenticated) { if (!authenticated) {
throw new Error('Authentication failed, you do not have access to that channel'); throw new Error('Authentication failed, you do not have access to that channel');

View file

@ -0,0 +1,110 @@
const chai = require('chai');
const expect = chai.expect;
const chaiHttp = require('chai-http');
const { host } = require('../../config/speechConfig.js').site;
const requestTimeout = 20000;
const publishTimeout = 120000;
const fs = require('fs');
chai.use(chaiHttp);
function testFor200StatusResponse (host, url) {
return it(`should receive a status code 200 within ${requestTimeout}ms`, function (done) {
chai.request(host)
.get(url)
.end(function (err, res) {
expect(err).to.be.null;
expect(res).to.have.status(200);
done();
});
}).timeout(requestTimeout);
}
function testShowRequestFor200StatusResponse (host, url) {
return it(`should receive a status code 200 within ${requestTimeout}ms`, function (done) {
chai.request(host)
.get(url)
.set('accept', 'text/html')
.end(function (err, res) {
expect(err).to.be.null;
expect(res).to.have.status(200);
done();
});
}).timeout(requestTimeout);
}
describe('end-to-end', function () {
describe('serve requests not from browser', function () {
const claimUrl = '/doitlive.jpg';
const claimUrlWithShortClaimId = '/d/doitlive.jpg';
const claimUrlWithLongClaimId = '/ca3023187e901df9e9aabd95d6ae09b6cc69b3f0/doitlive.jpg';
describe(claimUrl, function () {
testFor200StatusResponse(host, claimUrl);
});
describe(claimUrlWithShortClaimId, function () {
testFor200StatusResponse(host, claimUrlWithShortClaimId);
});
describe(claimUrlWithLongClaimId, function () {
testFor200StatusResponse(host, claimUrlWithShortClaimId);
});
});
describe('show requests from browser', function () {
const claimUrl = '/doitlive';
const claimUrlWithShortClaimId = '/d/doitlive';
const claimUrlWithLongClaimId = '/ca3023187e901df9e9aabd95d6ae09b6cc69b3f0/doitlive';
describe(claimUrl, function () {
testShowRequestFor200StatusResponse(host, claimUrl);
});
describe(claimUrlWithShortClaimId, function () {
testShowRequestFor200StatusResponse(host, claimUrlWithShortClaimId);
});
describe(claimUrlWithLongClaimId, function () {
testShowRequestFor200StatusResponse(host, claimUrlWithShortClaimId);
});
});
describe('serve requests browser (show lite)', function () {
const claimUrl = '/doitlive.jpg';
const claimUrlWithShortClaimId = '/d/doitlive.jpg';
const claimUrlWithLongClaimId = '/ca3023187e901df9e9aabd95d6ae09b6cc69b3f0/doitlive.jpg';
describe(claimUrl, function () {
testShowRequestFor200StatusResponse(host, claimUrl);
});
describe(claimUrlWithShortClaimId, function () {
testShowRequestFor200StatusResponse(host, claimUrlWithShortClaimId);
});
describe(claimUrlWithLongClaimId, function () {
testShowRequestFor200StatusResponse(host, claimUrlWithShortClaimId);
});
});
describe('publish', function () {
const publishUrl = '/api/claim-publish';
const date = new Date();
const name = `test-publish-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}-${date.getTime()}`;
const filePath = './test/mock-data/bird.jpeg';
const fileName = 'byrd.jpeg';
describe(publishUrl, function () {
it(`should receive a status code 200 within ${publishTimeout}ms @usesLbc`, function (done) {
chai.request(host)
.post(publishUrl)
.type('form')
.attach('file', fs.readFileSync(filePath), fileName)
.field('name', name)
.end(function (err, res) {
// expect(err).to.be.null;
expect(res).to.have.status(200);
done();
});
}).timeout(publishTimeout);
});
});
});

BIN
test/mock-data/bird.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View file

@ -0,0 +1,63 @@
const chai = require('chai');
const expect = chai.expect;
describe('publishHelpers.js', function () {
const publishHelpers = require('../../helpers/publishHelpers.js');
describe('#parsePublishApiRequestBody()', function () {
it('should throw an error if no body', function () {
expect(publishHelpers.parsePublishApiRequestBody.bind(this, null)).to.throw();
});
it('should throw an error if no body.name', function () {
const bodyNoName = {};
expect(publishHelpers.parsePublishApiRequestBody.bind(this, bodyNoName)).to.throw();
});
});
describe('#parsePublishApiRequestFiles()', function () {
it('should throw an error if no files', function () {
expect(publishHelpers.parsePublishApiRequestFiles.bind(this, null)).to.throw();
});
it('should throw an error if no files.file', function () {
const filesNoFile = {};
expect(publishHelpers.parsePublishApiRequestFiles.bind(this, filesNoFile)).to.throw();
});
it('should throw an error if file.size is too large', function () {
const filesTooBig = {
file: {
name: 'file.jpg',
path: '/path/to/file.jpg',
type: 'image/jpg',
size: 10000001,
},
};
expect(publishHelpers.parsePublishApiRequestFiles.bind(this, filesTooBig)).to.throw();
});
it('should throw error if not an accepted file type', function () {
const filesWrongType = {
file: {
name: 'file.jpg',
path: '/path/to/file.jpg',
type: 'someType/ext',
size: 10000000,
},
};
expect(publishHelpers.parsePublishApiRequestFiles.bind(this, filesWrongType)).to.throw();
});
it('should throw NO error if no problems', function () {
const filesNoProblems = {
file: {
name: 'file.jpg',
path: '/path/to/file.jpg',
type: 'image/jpg',
size: 10000000,
},
};
expect(publishHelpers.parsePublishApiRequestFiles.bind(this, filesNoProblems)).to.not.throw();
});
});
describe('#parsePublishApiChannel()', function () {
it('should pass the tests I write here');
});
});

View file

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test Page</title>
</head>
<body>
<img src="https://dev1.spee.ch/zackmath"/>
<!--<img src="https://dev1.spee.ch/8/zackmath"/>-->
<!--<img src="https://dev1.spee.ch/zackmath.ext"/>-->
<!--<img src="https://dev1.spee.ch/8/zackmath.ext"/>-->
<video width="50%" controls poster="https://dev1.spee.ch/assets/img/video_thumb_default.png" src="https://dev1.spee.ch/LBRY-Hype"></video>
<!--<video width="50%" controls poster="https://dev1.spee.ch/assets/img/video_thumb_default.png" src="https://staging.spee.ch/a/LBRY-Hype"></video>-->
<!--<video width="50%" controls poster="https://dev1.spee.ch/assets/img/video_thumb_default.png" src="https://staging.spee.ch/LBRY-Hype.test"></video>-->
<!--<video width="50%" controls poster="https://dev1.spee.ch/assets/img/video_thumb_default.png" src="https://staging.spee.ch/a/LBRY-Hype.test"></video>-->
</body>
</html>