diff --git a/.eslintignore b/.eslintignore index 18d5e871..07d60219 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ node_modules/ -public/ \ No newline at end of file +public/ +test diff --git a/.eslintrc b/.eslintrc index 25af7e9b..4a69de57 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,7 +18,7 @@ ], "semi": [ "error", - "always", + "always", { "omitLastInOneLineBlock": true } ], "key-spacing": [ diff --git a/README.md b/README.md index 5669b79c..14b3f048 100644 --- a/README.md +++ b/README.md @@ -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` * 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 #### GET diff --git a/auth/authentication.js b/auth/authentication.js index f23d702e..7feb7c0f 100644 --- a/auth/authentication.js +++ b/auth/authentication.js @@ -34,9 +34,9 @@ module.exports = { }); }); }, - authenticateOrSkip (skipAuth, channelName, channelPassword) { + authenticateIfNoUserToken (channelName, channelPassword, user) { return new Promise((resolve, reject) => { - if (skipAuth) { + if (user || !channelName) { return resolve(true); } return resolve(module.exports.authenticateChannelCredentials(channelName, channelPassword)); diff --git a/controllers/publishController.js b/controllers/publishController.js index fa82a525..e71167f4 100644 --- a/controllers/publishController.js +++ b/controllers/publishController.js @@ -2,6 +2,7 @@ const logger = require('winston'); const db = require('../models'); const lbryApi = require('../helpers/lbryApi.js'); const publishHelpers = require('../helpers/publishHelpers.js'); +const config = require('../config/speechConfig.js'); module.exports = { 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); + }); + }); + }, }; diff --git a/helpers/publishHelpers.js b/helpers/publishHelpers.js index 75fff5de..233410db 100644 --- a/helpers/publishHelpers.js +++ b/helpers/publishHelpers.js @@ -1,41 +1,87 @@ const logger = require('winston'); const fs = require('fs'); -const db = require('../models'); const { site, wallet } = require('../config/speechConfig.js'); module.exports = { - validateApiPublishRequest (body, files) { - if (!body) { - throw new Error('no body found in request'); - } - if (!body.name) { + parsePublishApiRequestBody ({name, nsfw, license, title, description, thumbnail}) { + // validate name + if (!name) { throw new Error('no name field found in request'); } - if (!files) { - throw new Error('no files found in request'); + const invalidNameCharacters = /[^A-Za-z0-9,-]/.exec(name); + 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'); } - }, - validatePublishSubmission (file, claimName) { - try { - module.exports.validateFile(file); - module.exports.validateClaimName(claimName); - } catch (error) { - throw error; + if (!file.path) { + throw new Error('no file path found'); } - }, - validateFile (file) { - if (!file) { - logger.debug('publish > file validation > no file found'); - throw new Error('no file provided'); + if (!file.type) { + throw new Error('no file type found'); } - // check the file name + if (!file.size) { + throw new Error('no file type found'); + } + // validate the file name if (/'/.test(file.name)) { logger.debug('publish > file validation > file name had apostrophe in it'); 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 switch (file.type) { case 'image/jpeg': @@ -64,21 +110,6 @@ module.exports = { } 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) { logger.debug(`Creating Publish Parameters`); // provide defaults for title @@ -127,45 +158,5 @@ module.exports = { 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); - }); - }); - }, + }; diff --git a/package.json b/package.json index 9bf792f2..3a326898 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "a single-serving site that reads and publishes images to and from the LBRY blockchain", "main": "speech.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "mocha --recursive", + "test-all": "mocha --recursive", "start": "node speech.js", "lint": "eslint .", "fix": "eslint . --fix", @@ -40,6 +41,8 @@ "nodemon": "^1.11.0", "passport": "^0.4.0", "passport-local": "^1.0.0", + "request": "^2.83.0", + "request-promise": "^4.2.2", "sequelize": "^4.1.0", "sequelize-cli": "^3.0.0-3", "sleep": "^5.1.1", @@ -48,6 +51,8 @@ "winston-slack-webhook": "billbitt/winston-slack-webhook" }, "devDependencies": { + "chai": "^4.1.2", + "chai-http": "^3.0.0", "eslint": "3.19.0", "eslint-config-standard": "10.2.1", "eslint-plugin-import": "^2.2.0", @@ -55,6 +60,7 @@ "eslint-plugin-promise": "3.5.0", "eslint-plugin-react": "6.10.3", "eslint-plugin-standard": "3.0.1", - "husky": "^0.13.4" + "husky": "^0.13.4", + "mocha": "^4.0.1" } } diff --git a/public/assets/js/publishFileFunctions.js b/public/assets/js/publishFileFunctions.js index 15a4c5a9..41922e08 100644 --- a/public/assets/js/publishFileFunctions.js +++ b/public/assets/js/publishFileFunctions.js @@ -78,17 +78,20 @@ const publishFileFunctions = { const licenseInput = document.getElementById('publish-license'); const nsfwInput = document.getElementById('publish-nsfw'); const thumbnailInput = document.getElementById('claim-thumbnail-input'); - - return { + const channelName = this.returnNullOrChannel(); + let metadata = { name: nameInput.value.trim(), - channelName: this.returnNullOrChannel(), title: titleInput.value.trim(), description: descriptionInput.value.trim(), license: licenseInput.value.trim(), nsfw: nsfwInput.checked, type: stagedFiles[0].type, thumbnail: thumbnailInput.value.trim(), + }; + if (channelName) { + metadata['channelName'] = channelName; } + return metadata; }, appendDataToFormData: function (file, metadata) { var fd = new FormData(); @@ -132,8 +135,6 @@ const publishFileFunctions = { } else { that.showFilePublishFailure(JSON.parse(xhr.response).message); } - } else { - console.log('xhr.readyState', xhr.readyState, 'xhr.status', xhr.status); } }; // Initiate a multipart/form-data upload @@ -143,7 +144,7 @@ const publishFileFunctions = { publishStagedFile: function (event) { event.preventDefault(); // prevent default so this script can handle submission const metadata = this.createMetadata(); - const that = this; // note: necessary ? + const that = this; const fileSelectionInputError = document.getElementById('input-error-file-selection'); const claimNameError = document.getElementById('input-error-claim-name'); const channelSelectError = document.getElementById('input-error-channel-select'); diff --git a/routes/api-routes.js b/routes/api-routes.js index e47ec7e1..bd9ac3e1 100644 --- a/routes/api-routes.js +++ b/routes/api-routes.js @@ -3,11 +3,11 @@ const multipart = require('connect-multiparty'); const { files, site } = require('../config/speechConfig.js'); const multipartMiddleware = multipart({uploadDir: files.uploadDirectory}); 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 { 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 { authenticateOrSkip } = require('../auth/authentication.js'); +const { authenticateIfNoUserToken } = require('../auth/authentication.js'); function addGetResultsToFileData (fileInfo, getResult) { fileInfo.fileName = getResult.file_name; @@ -125,60 +125,21 @@ module.exports = (app) => { }); // route to run a publish request on the daemon 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; - // validate that mandatory parts of the request are present + logger.debug('api/claim-publish body:', body); + 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 { - 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) { - logger.debug('publish request rejected, insufficient request parameters'); - res.status(400).json({success: false, message: error.message}); - return; + logger.debug('publish request rejected, insufficient request parameters', error); + return res.status(400).json({success: false, message: error.message}); } - 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 - authenticateOrSkip(skipAuth, channelName, channelPassword) + authenticateIfNoUserToken(channelName, channelPassword, user) .then(authenticated => { if (!authenticated) { throw new Error('Authentication failed, you do not have access to that channel'); diff --git a/test/end-to-end/end-to-end.tests.js b/test/end-to-end/end-to-end.tests.js new file mode 100644 index 00000000..d26d5853 --- /dev/null +++ b/test/end-to-end/end-to-end.tests.js @@ -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); + }); + + }); + + +}); diff --git a/test/mock-data/bird.jpeg b/test/mock-data/bird.jpeg new file mode 100644 index 00000000..cd1b140c Binary files /dev/null and b/test/mock-data/bird.jpeg differ diff --git a/test/unit/publishHelpers.test.js b/test/unit/publishHelpers.test.js new file mode 100644 index 00000000..358e989a --- /dev/null +++ b/test/unit/publishHelpers.test.js @@ -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'); + }); +}); diff --git a/testpage.html b/testpage.html deleted file mode 100644 index 2d29d095..00000000 --- a/testpage.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - Test Page - - - - - - - - - - - -