diff --git a/jest.setup.js b/jest.setup.js index 7c9d2b77..da036785 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,5 +1,8 @@ import { configure } from 'enzyme' import Adapter from 'enzyme-adapter-react-16' import '@babel/polyfill' +import mongoose from 'mongoose' + +mongoose.Promise = global.Promise; configure({ adapter: new Adapter() }) diff --git a/server/controllers/__mocks__/aws.controller.js b/server/controllers/__mocks__/aws.controller.js new file mode 100644 index 00000000..9e5709c4 --- /dev/null +++ b/server/controllers/__mocks__/aws.controller.js @@ -0,0 +1,5 @@ +export const getObjectKey = jest.mock(); +export const deleteObjectsFromS3 = jest.fn(); +export const signS3 = jest.fn(); +export const copyObjectInS3 = jest.fn(); +export const listObjectsInS3ForUser = jest.fn(); diff --git a/server/controllers/__test__/project.controller.test.js b/server/controllers/__test__/project.controller.test.js deleted file mode 100644 index e0852e61..00000000 --- a/server/controllers/__test__/project.controller.test.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * @jest-environment node - */ -import { Response } from 'jest-express'; - -import { createMock } from '../../models/project'; -import createProject from '../project.controller/createProject'; - -jest.mock('../../models/project'); - -describe('project.controller', () => { - describe('createProject()', () => { - let ProjectMock; - - beforeEach(() => { - ProjectMock = createMock(); - }); - - afterEach(() => { - ProjectMock.restore(); - }); - - it('fails if create fails', (done) => { - const error = new Error('An error'); - - ProjectMock - .expects('create') - .rejects(error); - - const request = { user: {} }; - const response = new Response(); - - const promise = createProject(request, response); - - function expectations() { - expect(response.json).toHaveBeenCalledWith({ success: false }); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - - it('extracts parameters from request body', (done) => { - const request = { - user: { _id: 'abc123' }, - body: { - name: 'Wriggly worm', - files: [{ name: 'file.js', content: 'var hello = true;' }] - } - }; - const response = new Response(); - - - ProjectMock - .expects('create') - .withArgs({ - user: 'abc123', - name: 'Wriggly worm', - files: [{ name: 'file.js', content: 'var hello = true;' }] - }) - .resolves(); - - const promise = createProject(request, response); - - function expectations() { - expect(response.json).toHaveBeenCalled(); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - - // TODO: This should be extracted to a new model object - // so the controllers just have to call a single - // method for this operation - it('populates referenced user on project creation', (done) => { - const request = { user: { _id: 'abc123' } }; - const response = new Response(); - - const result = { - _id: 'abc123', - id: 'abc123', - name: 'Project name', - serveSecure: false, - files: [] - }; - - const resultWithUser = { - ...result, - user: {} - }; - - ProjectMock - .expects('create') - .withArgs({ user: 'abc123' }) - .resolves(result); - - ProjectMock - .expects('populate') - .withArgs(result) - .yields(null, resultWithUser) - .resolves(resultWithUser); - - const promise = createProject(request, response); - - function expectations() { - const doc = response.json.mock.calls[0][0]; - - expect(response.json).toHaveBeenCalled(); - - expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - - it('fails if referenced user population fails', (done) => { - const request = { user: { _id: 'abc123' } }; - const response = new Response(); - - const result = { - _id: 'abc123', - id: 'abc123', - name: 'Project name', - serveSecure: false, - files: [] - }; - - const error = new Error('An error'); - - ProjectMock - .expects('create') - .resolves(result); - - ProjectMock - .expects('populate') - .yields(error) - .resolves(error); - - const promise = createProject(request, response); - - function expectations() { - expect(response.json).toHaveBeenCalledWith({ success: false }); - - done(); - } - - promise.then(expectations, expectations).catch(expectations); - }); - }); -}); diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index ae029918..bf59a865 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -2,7 +2,6 @@ import archiver from 'archiver'; import format from 'date-fns/format'; import isUrl from 'is-url'; import jsdom, { serializeDocument } from 'jsdom'; -import isBefore from 'date-fns/is_before'; import isAfter from 'date-fns/is_after'; import request from 'request'; import slugify from 'slugify'; @@ -10,9 +9,10 @@ import Project from '../models/project'; import User from '../models/user'; import { resolvePathToFile } from '../utils/filePath'; import generateFileSystemSafeName from '../utils/generateFileSystemSafeName'; -import { deleteObjectsFromS3, getObjectKey } from './aws.controller'; -export { default as createProject } from './project.controller/createProject'; +export { default as createProject, apiCreateProject } from './project.controller/createProject'; +export { default as deleteProject } from './project.controller/deleteProject'; +export { default as getProjectsForUser, apiGetProjectsForUser } from './project.controller/getProjectsForUser'; export function updateProject(req, res) { Project.findById(req.params.project_id, (findProjectErr, project) => { @@ -84,37 +84,6 @@ export function getProject(req, res) { }); } -function deleteFilesFromS3(files) { - deleteObjectsFromS3(files.filter((file) => { - if (file.url) { - if (!process.env.S3_DATE || ( - process.env.S3_DATE && - isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) { - return true; - } - } - return false; - }) - .map(file => getObjectKey(file.url))); -} - -export function deleteProject(req, res) { - Project.findById(req.params.project_id, (findProjectErr, project) => { - if (!project.user.equals(req.user._id)) { - res.status(403).json({ success: false, message: 'Session does not match owner of project.' }); - return; - } - deleteFilesFromS3(project.files); - Project.remove({ _id: req.params.project_id }, (removeProjectError) => { - if (removeProjectError) { - res.status(404).send({ message: 'Project with that id does not exist' }); - return; - } - res.json({ success: true }); - }); - }); -} - export function getProjectsForUserId(userId) { return new Promise((resolve, reject) => { Project.find({ user: userId }) @@ -157,10 +126,6 @@ export function getProjectAsset(req, res) { }); } -export function getProjectsForUserName(username) { - -} - export function getProjects(req, res) { if (req.user) { getProjectsForUserId(req.user._id) @@ -173,24 +138,6 @@ export function getProjects(req, res) { } } -export function getProjectsForUser(req, res) { - if (req.params.username) { - User.findOne({ username: req.params.username }, (err, user) => { - if (!user) { - res.status(404).json({ message: 'User with that username does not exist.' }); - return; - } - Project.find({ user: user._id }) - .sort('-createdAt') - .select('name files id createdAt updatedAt') - .exec((innerErr, projects) => res.json(projects)); - }); - } else { - // could just move this to client side - res.json([]); - } -} - export function projectExists(projectId, callback) { Project.findById(projectId, (err, project) => ( project ? callback(true) : callback(false) diff --git a/server/controllers/project.controller/__test__/createProject.test.js b/server/controllers/project.controller/__test__/createProject.test.js new file mode 100644 index 00000000..68025a0c --- /dev/null +++ b/server/controllers/project.controller/__test__/createProject.test.js @@ -0,0 +1,391 @@ +/** + * @jest-environment node + */ +import { Response } from 'jest-express'; + +import Project, { createMock, createInstanceMock } from '../../../models/project'; +import createProject, { apiCreateProject } from '../createProject'; + +jest.mock('../../../models/project'); + +describe('project.controller', () => { + describe('createProject()', () => { + let ProjectMock; + + beforeEach(() => { + ProjectMock = createMock(); + }); + + afterEach(() => { + ProjectMock.restore(); + }); + + it('fails if create fails', (done) => { + const error = new Error('An error'); + + ProjectMock + .expects('create') + .rejects(error); + + const request = { user: {} }; + const response = new Response(); + + const promise = createProject(request, response); + + function expectations() { + expect(response.json).toHaveBeenCalledWith({ success: false }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('extracts parameters from request body', (done) => { + const request = { + user: { _id: 'abc123' }, + body: { + name: 'Wriggly worm', + files: [{ name: 'file.js', content: 'var hello = true;' }] + } + }; + const response = new Response(); + + + ProjectMock + .expects('create') + .withArgs({ + user: 'abc123', + name: 'Wriggly worm', + files: [{ name: 'file.js', content: 'var hello = true;' }] + }) + .resolves(); + + const promise = createProject(request, response); + + function expectations() { + expect(response.json).toHaveBeenCalled(); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + // TODO: This should be extracted to a new model object + // so the controllers just have to call a single + // method for this operation + it('populates referenced user on project creation', (done) => { + const request = { user: { _id: 'abc123' } }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + const resultWithUser = { + ...result, + user: {} + }; + + ProjectMock + .expects('create') + .withArgs({ user: 'abc123' }) + .resolves(result); + + ProjectMock + .expects('populate') + .withArgs(result) + .yields(null, resultWithUser) + .resolves(resultWithUser); + + const promise = createProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + expect(response.json).toHaveBeenCalled(); + + expect(JSON.parse(JSON.stringify(doc))).toMatchObject(resultWithUser); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('fails if referenced user population fails', (done) => { + const request = { user: { _id: 'abc123' } }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + const error = new Error('An error'); + + ProjectMock + .expects('create') + .resolves(result); + + ProjectMock + .expects('populate') + .yields(error) + .resolves(error); + + const promise = createProject(request, response); + + function expectations() { + expect(response.json).toHaveBeenCalledWith({ success: false }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); + + describe('apiCreateProject()', () => { + let ProjectMock; + let ProjectInstanceMock; + + beforeEach(() => { + ProjectMock = createMock(); + ProjectInstanceMock = createInstanceMock(); + }); + + afterEach(() => { + ProjectMock.restore(); + ProjectInstanceMock.restore(); + }); + + it('returns 201 with id of created sketch', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'My sketch', + files: {} + } + }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + ProjectInstanceMock.expects('isSlugUnique') + .resolves({ isUnique: true, conflictingIds: [] }); + + ProjectInstanceMock.expects('save') + .resolves(new Project(result)); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + expect(response.status).toHaveBeenCalledWith(201); + expect(response.json).toHaveBeenCalled(); + + expect(JSON.parse(JSON.stringify(doc))).toMatchObject({ + id: 'abc123' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('fails if slug is not unique', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'My sketch', + slug: 'a-slug', + files: {} + } + }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + // slug: 'a-slug', + serveSecure: false, + files: [] + }; + + ProjectInstanceMock.expects('isSlugUnique') + .resolves({ isUnique: false, conflictingIds: ['cde123'] }); + + ProjectInstanceMock.expects('save') + .resolves(new Project(result)); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + expect(response.status).toHaveBeenCalledWith(409); + expect(response.json).toHaveBeenCalled(); + + expect(JSON.parse(JSON.stringify(doc))).toMatchObject({ + message: 'Sketch Validation Failed', + detail: 'Slug "a-slug" is not unique. Check cde123' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('fails if user does not have permission', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { + username: 'dana', + }, + body: { + name: 'My sketch', + slug: 'a-slug', + files: {} + } + }; + const response = new Response(); + + const result = { + _id: 'abc123', + id: 'abc123', + name: 'Project name', + serveSecure: false, + files: [] + }; + + ProjectInstanceMock.expects('isSlugUnique') + .resolves({ isUnique: true, conflictingIds: [] }); + + ProjectInstanceMock.expects('save') + .resolves(new Project(result)); + + const promise = apiCreateProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(401); + expect(response.json).toHaveBeenCalled(); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns validation errors on files input', (done) => { + const request = { + user: { username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'My sketch', + files: { + 'index.html': { + // missing content or url + } + } + } + }; + const response = new Response(); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + const responseBody = JSON.parse(JSON.stringify(doc)); + + expect(response.status).toHaveBeenCalledWith(422); + expect(responseBody.message).toBe('File Validation Failed'); + expect(responseBody.detail).not.toBeNull(); + expect(responseBody.errors.length).toBe(1); + expect(responseBody.errors).toEqual([ + { name: 'index.html', message: 'missing \'url\' or \'content\'' } + ]); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('rejects file parameters not in object format', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'Wriggly worm', + files: [{ name: 'file.js', content: 'var hello = true;' }] + } + }; + const response = new Response(); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + const responseBody = JSON.parse(JSON.stringify(doc)); + + expect(response.status).toHaveBeenCalledWith(422); + expect(responseBody.message).toBe('File Validation Failed'); + expect(responseBody.detail).toBe('\'files\' must be an object'); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('rejects if files object in not provided', (done) => { + const request = { + user: { _id: 'abc123', username: 'alice' }, + params: { username: 'alice' }, + body: { + name: 'Wriggly worm', + // files: {} is missing + } + }; + const response = new Response(); + + const promise = apiCreateProject(request, response); + + function expectations() { + const doc = response.json.mock.calls[0][0]; + + const responseBody = JSON.parse(JSON.stringify(doc)); + + expect(response.status).toHaveBeenCalledWith(422); + expect(responseBody.message).toBe('File Validation Failed'); + expect(responseBody.detail).toBe('\'files\' must be an object'); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); +}); diff --git a/server/controllers/project.controller/__test__/deleteProject.test.js b/server/controllers/project.controller/__test__/deleteProject.test.js new file mode 100644 index 00000000..84a6824f --- /dev/null +++ b/server/controllers/project.controller/__test__/deleteProject.test.js @@ -0,0 +1,119 @@ +/** + * @jest-environment node + */ +import { Request, Response } from 'jest-express'; + +import Project, { createMock, createInstanceMock } from '../../../models/project'; +import User from '../../../models/user'; +import deleteProject from '../../project.controller/deleteProject'; +import { deleteObjectsFromS3 } from '../../aws.controller'; + + +jest.mock('../../../models/project'); +jest.mock('../../aws.controller'); + +describe('project.controller', () => { + describe('deleteProject()', () => { + let ProjectMock; + let ProjectInstanceMock; + + beforeEach(() => { + ProjectMock = createMock(); + ProjectInstanceMock = createInstanceMock(); + }); + + afterEach(() => { + ProjectMock.restore(); + ProjectInstanceMock.restore(); + }); + + it('returns 403 if project is not owned by authenticated user', (done) => { + const user = new User(); + const project = new Project(); + project.user = user; + + const request = new Request(); + request.setParams({ project_id: project._id }); + request.user = { _id: 'abc123' }; + + const response = new Response(); + + ProjectMock + .expects('findById') + .resolves(project); + + const promise = deleteProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(403); + expect(response.json).toHaveBeenCalledWith({ + message: 'Authenticated user does not match owner of project' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 404 if project does not exist', (done) => { + const user = new User(); + const project = new Project(); + project.user = user; + + const request = new Request(); + request.setParams({ project_id: project._id }); + request.user = { _id: 'abc123' }; + + const response = new Response(); + + ProjectMock + .expects('findById') + .resolves(null); + + const promise = deleteProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ + message: 'Project with that id does not exist' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('deletes project and dependent files from S3 ', (done) => { + const user = new User(); + const project = new Project(); + project.user = user; + + const request = new Request(); + request.setParams({ project_id: project._id }); + request.user = { _id: user._id }; + + const response = new Response(); + + ProjectMock + .expects('findById') + .resolves(project); + + ProjectInstanceMock.expects('remove') + .yields(); + + const promise = deleteProject(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).not.toHaveBeenCalled(); + expect(deleteObjectsFromS3).toHaveBeenCalled(); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); +}); diff --git a/server/controllers/project.controller/__test__/getProjectsForUser.test.js b/server/controllers/project.controller/__test__/getProjectsForUser.test.js new file mode 100644 index 00000000..05a0cf71 --- /dev/null +++ b/server/controllers/project.controller/__test__/getProjectsForUser.test.js @@ -0,0 +1,151 @@ +/** + * @jest-environment node + */ +import { Request, Response } from 'jest-express'; + +import { createMock } from '../../../models/user'; +import getProjectsForUser, { apiGetProjectsForUser } from '../../project.controller/getProjectsForUser'; + +jest.mock('../../../models/user'); +jest.mock('../../aws.controller'); + +describe('project.controller', () => { + let UserMock; + + beforeEach(() => { + UserMock = createMock(); + }); + + afterEach(() => { + UserMock.restore(); + }); + + describe('getProjectsForUser()', () => { + it('returns empty array user not supplied as parameter', (done) => { + const request = new Request(); + request.setParams({}); + const response = new Response(); + + const promise = getProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith([]); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 404 if user does not exist', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(null, null); + + const promise = getProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 500 on other errors', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(new Error(), null); + + const promise = getProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); + + describe('apiGetProjectsForUser()', () => { + it('returns validation error if user id not provided', (done) => { + const request = new Request(); + request.setParams({}); + const response = new Response(); + + const promise = apiGetProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(422); + expect(response.json).toHaveBeenCalledWith({ + message: 'Username not provided' + }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + + it('returns 404 if user does not exist', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(null, null); + + const promise = apiGetProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith({ message: 'User with that username does not exist.' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + + it('returns 500 on other errors', (done) => { + const request = new Request(); + request.setParams({ username: 'abc123' }); + const response = new Response(); + + UserMock + .expects('findOne') + .withArgs({ username: 'abc123' }) + .yields(new Error(), null); + + const promise = apiGetProjectsForUser(request, response); + + function expectations() { + expect(response.status).toHaveBeenCalledWith(500); + expect(response.json).toHaveBeenCalledWith({ message: 'Error fetching projects' }); + + done(); + } + + promise.then(expectations, expectations).catch(expectations); + }); + }); +}); diff --git a/server/controllers/project.controller/createProject.js b/server/controllers/project.controller/createProject.js index 2cf34e32..e4040268 100644 --- a/server/controllers/project.controller/createProject.js +++ b/server/controllers/project.controller/createProject.js @@ -1,4 +1,5 @@ import Project from '../../models/project'; +import { toModel, FileValidationError, ProjectValidationError } from '../../domain-objects/Project'; export default function createProject(req, res) { let projectValues = { @@ -30,3 +31,67 @@ export default function createProject(req, res) { .then(populateUserData) .catch(sendFailure); } + +// TODO: What happens if you don't supply any files? +export function apiCreateProject(req, res) { + const params = Object.assign({ user: req.user._id }, req.body); + + function sendValidationErrors(err, type, code = 422) { + res.status(code).json({ + message: `${type} Validation Failed`, + detail: err.message, + errors: err.files, + }); + } + + // TODO: Error handling to match spec + function sendFailure(err) { + res.status(500).end(); + } + + function handleErrors(err) { + if (err instanceof FileValidationError) { + sendValidationErrors(err, 'File', err.code); + } else if (err instanceof ProjectValidationError) { + sendValidationErrors(err, 'Sketch', err.code); + } else { + sendFailure(); + } + } + + function checkUserHasPermission() { + if (req.user.username !== req.params.username) { + console.log('no permission'); + const error = new ProjectValidationError(`'${req.user.username}' does not have permission to create for '${req.params.username}'`); + error.code = 401; + + throw error; + } + } + + try { + checkUserHasPermission(); + + const model = toModel(params); + + return model.isSlugUnique() + .then(({ isUnique, conflictingIds }) => { + if (isUnique) { + return model.save() + .then((newProject) => { + res.status(201).json({ id: newProject.id }); + }); + } + + const error = new ProjectValidationError(`Slug "${model.slug}" is not unique. Check ${conflictingIds.join(', ')}`); + error.code = 409; + + throw error; + }) + .then(checkUserHasPermission) + .catch(handleErrors); + } catch (err) { + handleErrors(err); + return Promise.reject(err); + } +} diff --git a/server/controllers/project.controller/deleteProject.js b/server/controllers/project.controller/deleteProject.js new file mode 100644 index 00000000..dd980b23 --- /dev/null +++ b/server/controllers/project.controller/deleteProject.js @@ -0,0 +1,57 @@ +import isBefore from 'date-fns/is_before'; +import Project from '../../models/project'; +import { deleteObjectsFromS3, getObjectKey } from '../aws.controller'; +import createApplicationErrorClass from '../../utils/createApplicationErrorClass'; + +const ProjectDeletionError = createApplicationErrorClass('ProjectDeletionError'); + +function deleteFilesFromS3(files) { + deleteObjectsFromS3(files.filter((file) => { + if (file.url) { + if (!process.env.S3_DATE || ( + process.env.S3_DATE && + isBefore(new Date(process.env.S3_DATE), new Date(file.createdAt)))) { + return true; + } + } + return false; + }) + .map(file => getObjectKey(file.url))); +} + +export default function deleteProject(req, res) { + function sendFailure(error) { + res.status(error.code).json({ message: error.message }); + } + + function sendProjectNotFound() { + sendFailure(new ProjectDeletionError('Project with that id does not exist', { code: 404 })); + } + + function handleProjectDeletion(project) { + if (project == null) { + sendProjectNotFound(); + return; + } + + if (!project.user.equals(req.user._id)) { + sendFailure(new ProjectDeletionError('Authenticated user does not match owner of project', { code: 403 })); + return; + } + + deleteFilesFromS3(project.files); + + project.remove((removeProjectError) => { + if (removeProjectError) { + sendProjectNotFound(); + return; + } + + res.status(200).end(); + }); + } + + return Project.findById(req.params.project_id) + .then(handleProjectDeletion) + .catch(sendFailure); +} diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js new file mode 100644 index 00000000..1d9a0e34 --- /dev/null +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -0,0 +1,72 @@ +import Project from '../../models/project'; +import User from '../../models/user'; +import { toApi as toApiProjectObject } from '../../domain-objects/Project'; +import createApplicationErrorClass from '../../utils/createApplicationErrorClass'; + +const UserNotFoundError = createApplicationErrorClass('UserNotFoundError'); + +function getProjectsForUserName(username) { + return new Promise((resolve, reject) => { + User.findOne({ username }, (err, user) => { + if (err) { + reject(err); + return; + } + + if (!user) { + reject(new UserNotFoundError()); + return; + } + + Project.find({ user: user._id }) + .sort('-createdAt') + .select('name files id createdAt updatedAt') + .exec((innerErr, projects) => { + if (innerErr) { + reject(innerErr); + return; + } + + resolve(projects); + }); + }); + }); +} + +export default function getProjectsForUser(req, res) { + if (req.params.username) { + return getProjectsForUserName(req.params.username) + .then(projects => res.json(projects)) + .catch((err) => { + if (err instanceof UserNotFoundError) { + res.status(404).json({ message: 'User with that username does not exist.' }); + } else { + res.status(500).json({ message: 'Error fetching projects' }); + } + }); + } + + // could just move this to client side + res.status(200).json([]); + return Promise.resolve(); +} + +export function apiGetProjectsForUser(req, res) { + if (req.params.username) { + return getProjectsForUserName(req.params.username) + .then((projects) => { + const asApiObjects = projects.map(p => toApiProjectObject(p)); + res.json({ sketches: asApiObjects }); + }) + .catch((err) => { + if (err instanceof UserNotFoundError) { + res.status(404).json({ message: 'User with that username does not exist.' }); + } else { + res.status(500).json({ message: 'Error fetching projects' }); + } + }); + } + + res.status(422).json({ message: 'Username not provided' }); + return Promise.resolve(); +} diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.js index a496373b..7e396288 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.js @@ -1,73 +1,40 @@ /* @jest-environment node */ import last from 'lodash/last'; +import { Request, Response } from 'jest-express'; + +import User, { createMock, createInstanceMock } from '../../../models/user'; import { createApiKey, removeApiKey } from '../../user.controller/apiKey'; jest.mock('../../../models/user'); -/* - Create a mock object representing an express Response -*/ -const createResponseMock = function createResponseMock(done) { - const json = jest.fn(() => { - if (done) { done(); } - }); - - const status = jest.fn(() => ({ json })); - - return { - status, - json - }; -}; - -/* - Create a mock of the mongoose User model -*/ -const createUserMock = function createUserMock() { - const apiKeys = []; - let nextId = 0; - - apiKeys.push = ({ label, hashedKey }) => { - const id = nextId; - nextId += 1; - const publicFields = { id, label }; - const allFields = { ...publicFields, hashedKey }; - - Object.defineProperty(allFields, 'toObject', { - value: () => publicFields, - enumerable: false - }); - - return Array.prototype.push.call(apiKeys, allFields); - }; - - apiKeys.pull = ({ _id }) => { - const index = apiKeys.findIndex(({ id }) => id === _id); - return apiKeys.splice(index, 1); - }; - - return { - apiKeys, - save: jest.fn(callback => callback()) - }; -}; - -const User = require('../../../models/user').default; - describe('user.controller', () => { + let UserMock; + let UserInstanceMock; + beforeEach(() => { - User.__setFindById(null, null); + UserMock = createMock(); + UserInstanceMock = createInstanceMock(); }); + afterEach(() => { + UserMock.restore(); + UserInstanceMock.restore(); + }); + + describe('createApiKey', () => { it('returns an error if user doesn\'t exist', () => { const request = { user: { id: '1234' } }; - const response = createResponseMock(); + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, null); createApiKey(request, response); - expect(User.findById.mock.calls[0][0]).toBe('1234'); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ error: 'User not found' @@ -75,10 +42,13 @@ describe('user.controller', () => { }); it('returns an error if label not provided', () => { - User.__setFindById(undefined, createUserMock()); - const request = { user: { id: '1234' }, body: {} }; - const response = createResponseMock(); + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, new User()); createApiKey(request, response); @@ -89,46 +59,59 @@ describe('user.controller', () => { }); it('returns generated API key to the user', (done) => { - let response; + const request = new Request(); + request.setBody({ label: 'my key' }); + request.user = { id: '1234' }; - const request = { - user: { id: '1234' }, - body: { label: 'my key' } - }; + const response = new Response(); - const user = createUserMock(); + const user = new User(); - const checkExpecations = () => { + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, user); + + UserInstanceMock.expects('save') + .yields(); + + function expectations() { const lastKey = last(user.apiKeys); expect(lastKey.label).toBe('my key'); expect(typeof lastKey.hashedKey).toBe('string'); - expect(response.json).toHaveBeenCalledWith({ - apiKeys: [ - { id: 0, label: 'my key', token: lastKey.hashedKey } - ] + const responseData = response.json.mock.calls[0][0]; + + expect(responseData.apiKeys.length).toBe(1); + expect(responseData.apiKeys[0]).toMatchObject({ + label: 'my key', + token: lastKey.hashedKey, + lastUsedAt: undefined, + createdAt: undefined }); done(); - }; + } - response = createResponseMock(checkExpecations); + const promise = createApiKey(request, response); - User.__setFindById(undefined, user); - - createApiKey(request, response); + promise.then(expectations, expectations).catch(expectations); }); }); describe('removeApiKey', () => { it('returns an error if user doesn\'t exist', () => { const request = { user: { id: '1234' } }; - const response = createResponseMock(); + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, null); removeApiKey(request, response); - expect(User.findById.mock.calls[0][0]).toBe('1234'); expect(response.status).toHaveBeenCalledWith(404); expect(response.json).toHaveBeenCalledWith({ error: 'User not found' @@ -140,10 +123,13 @@ describe('user.controller', () => { user: { id: '1234' }, params: { keyId: 'not-a-real-key' } }; - const response = createResponseMock(); + const response = new Response(); + const user = new User(); - const user = createUserMock(); - User.__setFindById(undefined, user); + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, user); removeApiKey(request, response); @@ -153,27 +139,41 @@ describe('user.controller', () => { }); }); - it.skip('removes key if it exists', () => { - const request = { - user: { id: '1234' }, - params: { keyId: 0 } - }; - const response = createResponseMock(); - - const user = createUserMock(); - + it('removes key if it exists', () => { + const user = new User(); user.apiKeys.push({ label: 'first key' }); // id 0 user.apiKeys.push({ label: 'second key' }); // id 1 - User.__setFindById(undefined, user); + const firstKeyId = user.apiKeys[0]._id.toString(); + const secondKeyId = user.apiKeys[1]._id.toString(); + + const request = { + user: { id: '1234' }, + params: { keyId: firstKeyId } + }; + const response = new Response(); + + UserMock + .expects('findById') + .withArgs('1234') + .yields(null, user); + + UserInstanceMock + .expects('save') + .yields(); removeApiKey(request, response); expect(response.status).toHaveBeenCalledWith(200); - expect(response.json).toHaveBeenCalledWith({ - apiKeys: [ - { id: 1, label: 'second key' } - ] + + const responseData = response.json.mock.calls[0][0]; + + expect(responseData.apiKeys.length).toBe(1); + expect(responseData.apiKeys[0]).toMatchObject({ + id: secondKeyId, + label: 'second key', + lastUsedAt: undefined, + createdAt: undefined }); }); }); diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.js index eed22e6f..5d45b123 100644 --- a/server/controllers/user.controller/apiKey.js +++ b/server/controllers/user.controller/apiKey.js @@ -19,67 +19,85 @@ function generateApiKey() { } export function createApiKey(req, res) { - User.findById(req.user.id, async (err, user) => { - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; + return new Promise((resolve, reject) => { + function sendFailure(code, error) { + res.status(code).json({ error }); + resolve(); } - if (!req.body.label) { - res.status(400).json({ error: 'Expected field \'label\' was not present in request body' }); - return; - } - - const keyToBeHashed = await generateApiKey(); - - const addedApiKeyIndex = user.apiKeys.push({ label: req.body.label, hashedKey: keyToBeHashed }); - - user.save((saveErr) => { - if (saveErr) { - res.status(500).json({ error: saveErr }); + User.findById(req.user.id, async (err, user) => { + if (!user) { + sendFailure(404, 'User not found'); return; } - const apiKeys = user.apiKeys - .map((apiKey, index) => { - const fields = apiKey.toObject(); - const shouldIncludeToken = index === addedApiKeyIndex - 1; + if (!req.body.label) { + sendFailure(400, 'Expected field \'label\' was not present in request body'); + return; + } - return shouldIncludeToken ? - { ...fields, token: keyToBeHashed } : - fields; - }); + const keyToBeHashed = await generateApiKey(); - res.json({ apiKeys }); + const addedApiKeyIndex = user.apiKeys.push({ label: req.body.label, hashedKey: keyToBeHashed }); + + user.save((saveErr) => { + if (saveErr) { + sendFailure(500, saveErr); + return; + } + + const apiKeys = user.apiKeys + .map((apiKey, index) => { + const fields = apiKey.toObject(); + const shouldIncludeToken = index === addedApiKeyIndex - 1; + + return shouldIncludeToken ? + { ...fields, token: keyToBeHashed } : + fields; + }); + + res.json({ apiKeys }); + resolve(); + }); }); }); } export function removeApiKey(req, res) { - User.findById(req.user.id, (err, user) => { - if (err) { - res.status(500).json({ error: err }); - return; - } - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - const keyToDelete = user.apiKeys.find(key => key.id === req.params.keyId); - if (!keyToDelete) { - res.status(404).json({ error: 'Key does not exist for user' }); - return; + return new Promise((resolve, reject) => { + function sendFailure(code, error) { + res.status(code).json({ error }); + resolve(); } - user.apiKeys.pull({ _id: req.params.keyId }); - - user.save((saveErr) => { - if (saveErr) { - res.status(500).json({ error: saveErr }); + User.findById(req.user.id, (err, user) => { + if (err) { + sendFailure(500, err); return; } - res.status(200).json({ apiKeys: user.apiKeys }); + if (!user) { + sendFailure(404, 'User not found'); + return; + } + + const keyToDelete = user.apiKeys.find(key => key.id === req.params.keyId); + if (!keyToDelete) { + sendFailure(404, 'Key does not exist for user'); + return; + } + + user.apiKeys.pull({ _id: req.params.keyId }); + + user.save((saveErr) => { + if (saveErr) { + sendFailure(500, saveErr); + return; + } + + res.status(200).json({ apiKeys: user.apiKeys }); + resolve(); + }); }); }); } diff --git a/server/domain-objects/Project.js b/server/domain-objects/Project.js new file mode 100644 index 00000000..f0de023f --- /dev/null +++ b/server/domain-objects/Project.js @@ -0,0 +1,133 @@ +import isPlainObject from 'lodash/isPlainObject'; +import pick from 'lodash/pick'; +import Project from '../models/project'; +import createId from '../utils/createId'; +import createApplicationErrorClass from '../utils/createApplicationErrorClass'; +import createDefaultFiles from './createDefaultFiles'; + +export const FileValidationError = createApplicationErrorClass('FileValidationError'); +export const ProjectValidationError = createApplicationErrorClass('ProjectValidationError'); + +/** + * This converts between a mongoose Project model + * and the public API Project object properties + * + */ +export function toApi(model) { + return { + id: model.id, + name: model.name, + }; +} + +/** + * Transforms a tree of files matching the APIs DirectoryContents + * format into the data structure stored in mongodb + * + * - flattens the tree into an array of file/folders + * - each file/folder gets a generated BSON-ID + * - each folder has a `children` array containing the IDs of it's children + */ +function transformFilesInner(tree = {}, parentNode) { + const files = []; + const errors = []; + + Object.entries(tree).forEach(([name, params]) => { + const id = createId(); + const isFolder = params.files != null; + + if (isFolder) { + const folder = { + _id: id, + name, + fileType: 'folder', + children: [] // Initialise an empty folder + }; + + files.push(folder); + + // The recursion will return a list of child files/folders + // It will also push the child's id into `folder.children` + const subFolder = transformFilesInner(params.files, folder); + files.push(...subFolder.files); + errors.push(...subFolder.errors); + } else { + const file = { + _id: id, + name, + fileType: 'file' + }; + + if (typeof params.url === 'string') { + file.url = params.url; + } else if (typeof params.content === 'string') { + file.content = params.content; + } else { + errors.push({ name, message: 'missing \'url\' or \'content\'' }); + } + + files.push(file); + } + + // Push this child's ID onto it's parent's list + // of children + if (parentNode != null) { + parentNode.children.push(id); + } + }); + + return { files, errors }; +} + +export function transformFiles(tree = {}) { + const withRoot = { + root: { + files: tree + } + }; + + const { files, errors } = transformFilesInner(withRoot); + + if (errors.length > 0) { + const message = `${errors.length} files failed validation. See error.files for individual errors. + + Errors: + ${errors.map(e => `* ${e.name}: ${e.message}`).join('\n')} +`; + const error = new FileValidationError(message); + error.files = errors; + + throw error; + } + + return files; +} + +export function containsRootHtmlFile(tree) { + return Object.keys(tree).find(name => /\.html$/.test(name)) != null; +} + +/** + * This converts between the public API's Project object + * properties and a mongoose Project model + * + */ +export function toModel(object) { + let files = []; + let tree = object.files; + + if (isPlainObject(tree)) { + if (!containsRootHtmlFile(tree)) { + tree = Object.assign(createDefaultFiles(), tree); + } + + files = transformFiles(tree); + } else { + throw new FileValidationError('\'files\' must be an object'); + } + + const projectValues = pick(object, ['user', 'name', 'slug']); + projectValues.files = files; + + return new Project(projectValues); +} diff --git a/server/domain-objects/__test__/Project.test.js b/server/domain-objects/__test__/Project.test.js new file mode 100644 index 00000000..0f3c9dd9 --- /dev/null +++ b/server/domain-objects/__test__/Project.test.js @@ -0,0 +1,385 @@ +import find from 'lodash/find'; + +import { containsRootHtmlFile, toModel, transformFiles, FileValidationError } from '../Project'; + +jest.mock('../../utils/createId'); + +// TODO: File name validation +// TODO: File extension validation +// +describe('domain-objects/Project', () => { + describe('containsRootHtmlFile', () => { + it('returns true for at least one root .html', () => { + expect(containsRootHtmlFile({ 'index.html': {} })).toBe(true); + expect(containsRootHtmlFile({ 'another-one.html': {} })).toBe(true); + expect(containsRootHtmlFile({ 'one.html': {}, 'two.html': {} })).toBe(true); + expect(containsRootHtmlFile({ 'one.html': {}, 'sketch.js': {} })).toBe(true); + }); + + it('returns false anything else', () => { + expect(containsRootHtmlFile({ 'sketch.js': {} })).toBe(false); + }); + + it('ignores nested html', () => { + expect(containsRootHtmlFile({ + examples: { + files: { + 'index.html': {} + } + } + })).toBe(false); + }); + }); + + describe('toModel', () => { + it('filters extra properties', () => { + const params = { + name: 'My sketch', + extraThing: 'oopsie', + files: {} + }; + + const model = toModel(params); + + expect(model.name).toBe('My sketch'); + expect(model.extraThing).toBeUndefined(); + }); + + it('throws FileValidationError', () => { + const params = { + files: { + 'index.html': {} // missing content or url + } + }; + + expect(() => toModel(params)).toThrowError(FileValidationError); + }); + + it('throws if files is not an object', () => { + const params = { + files: [] + }; + + expect(() => toModel(params)).toThrowError(FileValidationError); + }); + + it('creates default index.html and dependent files if no root .html is provided', () => { + const params = { + files: {} + }; + + const { files } = toModel(params); + + expect(files.length).toBe(4); + expect(find(files, { name: 'index.html' })).not.toBeUndefined(); + expect(find(files, { name: 'sketch.js' })).not.toBeUndefined(); + expect(find(files, { name: 'style.css' })).not.toBeUndefined(); + }); + + it('does not create default files if any root .html is provided', () => { + const params = { + files: { + 'example.html': { + content: 'Hello!' + } + } + }; + + const { files } = toModel(params); + + expect(files.length).toBe(2); + expect(find(files, { name: 'example.html' })).not.toBeUndefined(); + expect(find(files, { name: 'index.html' })).toBeUndefined(); + expect(find(files, { name: 'sketch.js' })).toBeUndefined(); + expect(find(files, { name: 'style.css' })).toBeUndefined(); + }); + + it('does not overwrite default CSS and JS of the same name if provided', () => { + const params = { + files: { + 'sketch.js': { + content: 'const sketch = true;' + }, + 'style.css': { + content: 'body { outline: 10px solid red; }' + } + } + }; + + const { files } = toModel(params); + + expect(files.length).toBe(4); + expect(find(files, { name: 'index.html' })).not.toBeUndefined(); + + const sketchFile = find(files, { name: 'sketch.js' }); + expect(sketchFile.content).toBe('const sketch = true;'); + + const cssFile = find(files, { name: 'style.css' }); + expect(cssFile.content).toBe('body { outline: 10px solid red; }'); + }); + }); +}); + +describe('transformFiles', () => { + beforeEach(() => { + // eslint-disable-next-line global-require + const { resetMockCreateId } = require('../../utils/createId'); + + resetMockCreateId(); + }); + + it('creates an empty root with no data', () => { + const tree = {}; + + expect(transformFiles(tree)).toEqual([{ + _id: '0', + fileType: 'folder', + name: 'root', + children: [] + }]); + }); + + it('converts tree-shaped files into list', () => { + const tree = { + 'index.html': { + content: 'some contents', + } + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + content: 'some contents', + fileType: 'file', + name: 'index.html' + } + ]); + }); + + it('uses file url over content', () => { + const tree = { + 'script.js': { + url: 'http://example.net/something.js' + } + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + url: 'http://example.net/something.js', + fileType: 'file', + name: 'script.js' + } + ]); + }); + + it('creates folders', () => { + const tree = { + 'a-folder': { + files: {} + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + children: [], + fileType: 'folder', + name: 'a-folder' + } + ]); + }); + + it('walks the tree processing files', () => { + const tree = { + 'index.html': { + content: 'some contents', + }, + 'a-folder': { + files: { + 'data.csv': { + content: 'this,is,data' + }, + 'another.txt': { + content: 'blah blah' + } + } + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1', '2'] + }, + { + _id: '1', + name: 'index.html', + fileType: 'file', + content: 'some contents' + }, + { + _id: '2', + name: 'a-folder', + fileType: 'folder', + children: ['3', '4'] + }, + { + _id: '3', + name: 'data.csv', + fileType: 'file', + content: 'this,is,data' + }, + { + _id: '4', + name: 'another.txt', + fileType: 'file', + content: 'blah blah' + } + ]); + }); + + it('handles deep nesting', () => { + const tree = { + first: { + files: { + second: { + files: { + third: { + files: { + 'hello.js': { + content: 'world!' + } + } + } + } + } + } + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1'] + }, + { + _id: '1', + name: 'first', + fileType: 'folder', + children: ['2'] + }, + { + _id: '2', + name: 'second', + fileType: 'folder', + children: ['3'] + }, + { + _id: '3', + name: 'third', + fileType: 'folder', + children: ['4'] + }, + { + _id: '4', + name: 'hello.js', + fileType: 'file', + content: 'world!' + } + ]); + }); + + + it('allows duplicate names in different folder', () => { + const tree = { + 'index.html': { + content: 'some contents', + }, + 'data': { + files: { + 'index.html': { + content: 'different file' + } + } + }, + }; + + expect(transformFiles(tree)).toEqual([ + { + _id: '0', + fileType: 'folder', + name: 'root', + children: ['1', '2'] + }, + { + _id: '1', + name: 'index.html', + fileType: 'file', + content: 'some contents' + }, + { + _id: '2', + name: 'data', + fileType: 'folder', + children: ['3'] + }, + { + _id: '3', + name: 'index.html', + fileType: 'file', + content: 'different file' + } + ]); + }); + + it('validates files', () => { + const tree = { + 'index.html': {} // missing `content` + }; + + expect(() => transformFiles(tree)).toThrowError(FileValidationError); + }); + + it('collects all file validation errors', () => { + const tree = { + 'index.html': {}, // missing `content` + 'something.js': {} // also missing `content` + }; + + try { + transformFiles(tree); + + // Should not get here + throw new Error('should have thrown before this point'); + } catch (err) { + expect(err).toBeInstanceOf(FileValidationError); + expect(err.files).toEqual([ + { name: 'index.html', message: 'missing \'url\' or \'content\'' }, + { name: 'something.js', message: 'missing \'url\' or \'content\'' } + ]); + } + }); +}); diff --git a/server/domain-objects/createDefaultFiles.js b/server/domain-objects/createDefaultFiles.js new file mode 100644 index 00000000..9164c12a --- /dev/null +++ b/server/domain-objects/createDefaultFiles.js @@ -0,0 +1,48 @@ +const defaultSketch = `function setup() { + createCanvas(400, 400); +} + +function draw() { + background(220); +}`; + +const defaultHTML = + ` + + + + + + + + + + + + + +`; + +const defaultCSS = + `html, body { + margin: 0; + padding: 0; +} +canvas { + display: block; +} +`; + +export default function createDefaultFiles() { + return { + 'index.html': { + content: defaultHTML + }, + 'style.css': { + content: defaultCSS + }, + 'sketch.js': { + content: defaultSketch + } + }; +} diff --git a/server/models/__mocks__/project.js b/server/models/__mocks__/project.js index 7260fcfd..2590046f 100644 --- a/server/models/__mocks__/project.js +++ b/server/models/__mocks__/project.js @@ -11,6 +11,24 @@ export function createMock() { return sinon.mock(Project); } +// Wraps the Project.prototype i.e. the +// instance methods in a mock so +// Project.save() can be mocked +export function createInstanceMock() { + // See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro + Object.defineProperty(Project.prototype, 'save', { + value: Project.prototype.save, + configurable: true, + }); + + Object.defineProperty(Project.prototype, 'remove', { + value: Project.prototype.remove, + configurable: true, + }); + + return sinon.mock(Project.prototype); +} + // Re-export the model, it will be // altered by mockingoose whenever // we call methods on the MockConfig diff --git a/server/models/__mocks__/user.js b/server/models/__mocks__/user.js index 585d8b67..fcd73f32 100644 --- a/server/models/__mocks__/user.js +++ b/server/models/__mocks__/user.js @@ -1,12 +1,31 @@ -let __err = null; -let __user = null; +import sinon from 'sinon'; +import 'sinon-mongoose'; -export default { - __setFindById(err, user) { - __err = err; - __user = user; - }, - findById: jest.fn(async (id, callback) => { - callback(__err, __user); - }) -}; +// Import the actual model to be mocked +const User = jest.requireActual('../user').default; + +// Wrap User in a sinon mock +// The returned object is used to configure +// the mocked model's behaviour +export function createMock() { + return sinon.mock(User); +} + +// Wraps the User.prototype i.e. the +// instance methods in a mock so +// User.save() can be mocked +export function createInstanceMock() { + // See: https://stackoverflow.com/questions/40962960/sinon-mock-of-mongoose-save-method-for-all-future-instances-of-a-model-with-pro + Object.defineProperty(User.prototype, 'save', { + value: User.prototype.save, + configurable: true, + }); + + return sinon.mock(User.prototype); +} + + +// Re-export the model, it will be +// altered by mockingoose whenever +// we call methods on the MockConfig +export default User; diff --git a/server/models/project.js b/server/models/project.js index 8642bc90..bf8c992e 100644 --- a/server/models/project.js +++ b/server/models/project.js @@ -49,8 +49,43 @@ projectSchema.set('toJSON', { projectSchema.pre('save', function generateSlug(next) { const project = this; - project.slug = slugify(project.name, '_'); + + if (!project.slug) { + project.slug = slugify(project.name, '_'); + } + return next(); }); +/** + * Check if slug is unique for this user's projects + */ +projectSchema.methods.isSlugUnique = async function isSlugUnique(cb) { + const project = this; + const hasCallback = typeof cb === 'function'; + + try { + const docsWithSlug = await project.model('Project') + .find({ user: project.user, slug: project.slug }, '_id') + .exec(); + + const result = { + isUnique: docsWithSlug.length === 0, + conflictingIds: docsWithSlug.map(d => d._id) || [] + }; + + if (hasCallback) { + cb(null, result); + } + + return result; + } catch (err) { + if (hasCallback) { + cb(err, null); + } + + throw err; + } +}; + export default mongoose.model('Project', projectSchema); diff --git a/server/routes/api.routes.js b/server/routes/api.routes.js new file mode 100644 index 00000000..60be3494 --- /dev/null +++ b/server/routes/api.routes.js @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import passport from 'passport'; +import * as ProjectController from '../controllers/project.controller'; + +const router = new Router(); + +router.get( + '/:username/sketches', + passport.authenticate('basic', { session: false }), + ProjectController.apiGetProjectsForUser +); + +router.post( + '/:username/sketches', + passport.authenticate('basic', { session: false }), + ProjectController.apiCreateProject +); + +// NOTE: Currently :username will not be checked for ownership +// only the project's owner in the database. +router.delete( + '/:username/sketches/:project_id', + passport.authenticate('basic', { session: false }), + ProjectController.deleteProject +); + +export default router; diff --git a/server/scripts/examples-ml5.js b/server/scripts/examples-ml5.js index 0721fe54..51e1fe5d 100644 --- a/server/scripts/examples-ml5.js +++ b/server/scripts/examples-ml5.js @@ -1,11 +1,7 @@ import fs from 'fs'; import rp from 'request-promise'; import Q from 'q'; -import mongoose from 'mongoose'; -import objectID from 'bson-objectid'; -import shortid from 'shortid'; -import User from '../models/user'; -import Project from '../models/project'; +import { ok } from 'assert'; // TODO: Change branchName if necessary const branchName = 'release'; @@ -14,11 +10,20 @@ const baseUrl = 'https://api.github.com/repos/ml5js/ml5-examples/contents'; const clientId = process.env.GITHUB_ID; const clientSecret = process.env.GITHUB_SECRET; const editorUsername = process.env.ML5_EXAMPLES_USERNAME; +const personalAccessToken = process.env.EDITOR_API_ACCESS_TOKEN; +const editorApiUrl = process.env.EDITOR_API_URL; const headers = { 'User-Agent': 'p5js-web-editor/0.0.1' }; -const requestOptions = { +ok(clientId, 'GITHUB_ID is required'); +ok(clientSecret, 'GITHUB_SECRET is required'); +ok(editorUsername, 'ML5_EXAMPLES_USERNAME is required'); +ok(personalAccessToken, 'EDITOR_API_ACCESS_TOKEN is required'); +ok(editorApiUrl, 'EDITOR_API_URL is required'); + +// +const githubRequestOptions = { url: baseUrl, qs: { client_id: clientId, @@ -29,14 +34,15 @@ const requestOptions = { json: true }; -const mongoConnectionString = process.env.MONGO_URL; -mongoose.connect(mongoConnectionString, { - useMongoClient: true -}); -mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); - process.exit(1); -}); +const editorRequestOptions = { + url: `${editorApiUrl}/${editorUsername}`, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from(`${editorUsername}:${personalAccessToken}`).toString('base64')}` + }, + json: true +}; /** * --------------------------------------------------------- @@ -51,12 +57,52 @@ function flatten(list) { return list.reduce((a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []); } +/** + * Fetch data for a single HTML/JS file, or return + * an url to the file's CDN location + */ +async function fetchFileContent(item) { + const { name } = item; + const file = { url: item.url }; + + // if it is an html or js file + if ( + (file.url != null && name.endsWith('.html')) || + name.endsWith('.js') + ) { + const options = Object.assign({}, githubRequestOptions); + options.url = `${file.url}`; + + if ( + options.url !== undefined || + options.url !== null || + options.url !== '' + ) { + file.content = await rp(options); + // NOTE: remove the URL property if there's content + // Otherwise the p5 editor will try to pull from that url + if (file.content !== null) delete file.url; + } + + return file; + // if it is NOT an html or js file + } + + if (file.url) { + const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${file.url.split(branchName)[1]}`; + file.url = cdnRef; + } + + return file; +} + + /** * STEP 1: Get the top level cateogories */ async function getCategories() { try { - const options = Object.assign({}, requestOptions); + const options = Object.assign({}, githubRequestOptions); options.url = `${options.url}/p5js${branchRef}`; const results = await rp(options); @@ -76,13 +122,18 @@ async function getCategoryExamples(sketchRootList) { const output = []; const sketchRootCategories = sketchRootList.map(async (categories) => { // let options = Object.assign({url: `${requestOptions.url}/${categories.path}${branchRef}`}, requestOptions) - const options = Object.assign({}, requestOptions); + const options = Object.assign({}, githubRequestOptions); options.url = `${options.url}${categories.path}${branchRef}`; // console.log(options) const sketchDirs = await rp(options); - const result = flatten(sketchDirs); - return result; + try { + const result = flatten(sketchDirs); + + return result; + } catch (err) { + return []; + } }); const sketchList = await Q.all(sketchRootCategories); @@ -107,7 +158,7 @@ async function traverseSketchTree(parentObject) { return output; } // let options = `https://api.github.com/repos/ml5js/ml5-examples/contents/${sketches.path}${branchRef}` - const options = Object.assign({}, requestOptions); + const options = Object.assign({}, githubRequestOptions); options.url = `${options.url}${parentObject.path}${branchRef}`; output.tree = await rp(options); @@ -124,7 +175,6 @@ async function traverseSketchTree(parentObject) { * @param {*} categoryExamples - all of the categories in an array */ async function traverseSketchTreeAll(categoryExamples) { - // const sketches = categoryExamples.map(async sketch => await traverseSketchTree(sketch)); const sketches = categoryExamples.map(async sketch => traverseSketchTree(sketch)); const result = await Q.all(sketches); @@ -139,37 +189,24 @@ function traverseAndFormat(parentObject) { const parent = Object.assign({}, parentObject); if (!parentObject.tree) { - const newid = objectID().toHexString(); // returns the files return { name: parent.name, - url: parent.download_url, - content: null, - id: newid, - _id: newid, - fileType: 'file' + url: parent.download_url }; } const subdir = parentObject.tree.map((item) => { - const newid = objectID().toHexString(); if (!item.tree) { // returns the files return { name: item.name, - url: item.download_url, - content: null, - id: newid, - _id: newid, - fileType: 'file' + url: item.download_url }; } const feat = { name: item.name, - id: newid, - _id: newid, - fileType: 'folder', children: traverseAndFormat(item) }; return feat; @@ -178,69 +215,27 @@ function traverseAndFormat(parentObject) { } /** - * Traverse the tree and flatten for project.files[] + * Traverse the tree and download all the content, + * transforming into an object keyed by file/directory name * @param {*} projectFileTree */ -function traverseAndFlatten(projectFileTree) { - const r = objectID().toHexString(); +async function traverseAndDownload(projectFileTree) { + return projectFileTree.reduce( + async (previousPromise, item, idx) => { + const result = await previousPromise; - const projectRoot = { - name: 'root', - id: r, - _id: r, - children: [], - fileType: 'folder' - }; - - let currentParent; - - const output = projectFileTree.reduce( - (result, item, idx) => { - if (idx < projectFileTree.length) { - projectRoot.children.push(item.id); - } - - if (item.fileType === 'file') { - if (item.name === 'sketch.js') { - item.isSelectedFile = true; - } - result.push(item); - } - - // here's where the magic happens *twinkles* - if (item.fileType === 'folder') { - // recursively go down the tree of children - currentParent = traverseAndFlatten(item.children); - // the above will return an array of the children files - // concatenate that with the results - result = result.concat(currentParent); // eslint-disable-line no-param-reassign - // since we want to get the children ids, - // we can map the child ids to the current item - // then push that to our result array to get - // our flat files array. - item.children = item.children.map(child => child.id); - result.push(item); + if (Array.isArray(item.children)) { + result[item.name] = { + files: await traverseAndDownload(item.children) + }; + } else { + result[item.name] = await fetchFileContent(item); } return result; }, - [projectRoot] + {} ); - - // Kind of hacky way to remove all roots other than the starting one - let counter = 0; - output.forEach((item, idx) => { - if (item.name === 'root') { - if (counter === 0) { - counter += 1; - } else { - output.splice(idx, 1); - counter += 1; - } - } - }); - - return output; } /** @@ -249,16 +244,14 @@ function traverseAndFlatten(projectFileTree) { * @param {*} sketch * @param {*} user */ -function formatSketchForStorage(sketch, user) { - const newProject = new Project({ - _id: shortid.generate(), +async function formatSketchForStorage(sketch, user) { + const newProject = { name: sketch.name, - user: user._id, - files: [] // <== add files to this array as file objects and add _id reference to children of root - }); + files: {} // <== add files to this object + }; let projectFiles = traverseAndFormat(sketch); - projectFiles = traverseAndFlatten(projectFiles); + projectFiles = await traverseAndDownload(projectFiles); newProject.files = projectFiles; return newProject; } @@ -271,68 +264,50 @@ function formatSketchForStorageAll(sketchWithItems, user) { sketchList = sketchList.map(sketch => formatSketchForStorage(sketch, user)); - return sketchList; + return Promise.all(sketchList); } /** - * Get all the content for the relevant files in project.files[] - * @param {*} projectObject + * Fetch a list of all projects from the API */ -async function fetchSketchContent(projectObject) { - const output = Object.assign({}, JSON.parse(JSON.stringify(projectObject))); +async function getProjectsList() { + const options = Object.assign({}, editorRequestOptions); + options.url = `${options.url}/sketches`; - const newFiles = output.files.map(async (item, i) => { - // if it is an html or js file - if ( - (item.fileType === 'file' && item.name.endsWith('.html')) || - item.name.endsWith('.js') - ) { - const options = Object.assign({}, requestOptions); - options.url = `${item.url}`; + const results = await rp(options); - if ( - options.url !== undefined || - options.url !== null || - options.url !== '' - ) { - item.content = await rp(options); - // NOTE: remove the URL property if there's content - // Otherwise the p5 editor will try to pull from that url - if (item.content !== null) delete item.url; - } - - return item; - // if it is NOT an html or js file - } - - if (item.url) { - const cdnRef = `https://cdn.jsdelivr.net/gh/ml5js/ml5-examples@${branchName}${ - item.url.split(branchName)[1] - }`; - item.content = cdnRef; - item.url = cdnRef; - } - - return item; - }); - - output.files = await Q.all(newFiles); - return output; + return results.sketches; } /** - * STEP 5 - * Get all the content for the relevant files in project.files[] for all sketches - * @param {*} formattedSketchList + * Delete a project */ -async function fetchSketchContentAll(formattedSketchList) { - let output = formattedSketchList.slice(0); +async function deleteProject(project) { + const options = Object.assign({}, editorRequestOptions); + options.method = 'DELETE'; + options.url = `${options.url}/sketches/${project.id}`; - output = output.map(async item => fetchSketchContent(item)); + const results = await rp(options); - output = await Q.all(output); + return results; +} - return output; +/** + * Create a new project + */ +async function createProject(project) { + try { + const options = Object.assign({}, editorRequestOptions); + options.method = 'POST'; + options.url = `${options.url}/sketches`; + options.body = project; + + const results = await rp(options); + + return results; + } catch (err) { + throw err; + } } /** @@ -342,43 +317,34 @@ async function fetchSketchContentAll(formattedSketchList) { * @param {*} user */ async function createProjectsInP5User(filledProjectList, user) { - const userProjects = await Project.find({ user: user._id }); - const removeProjects = userProjects.map(async project => Project.remove({ _id: project._id })); - await Q.all(removeProjects); - console.log('deleted old projects!'); + console.log('Finding existing projects...'); - const newProjects = filledProjectList.map(async (project) => { - const item = new Project(project); - console.log(`saving ${project.name}`); - await item.save(); - }); - await Q.all(newProjects); - console.log(`Projects saved to User: ${editorUsername}!`); -} + const existingProjects = await getProjectsList(); -/** - * STEP 0 - * CHECK if user exists, ifnot create one - * - */ -async function checkP5User() { - const user = await User.findOne({ username: editorUsername }); + console.log(`Will delete ${existingProjects.length} projects`); - if (!user) { - const ml5user = new User({ - username: editorUsername, - email: process.env.ML5_EXAMPLES_EMAIL, - password: process.env.ML5_EXAMPLES_PASS - }); - - await ml5user.save((saveErr) => { - if (saveErr) throw saveErr; - console.log(`Created a user p5${ml5user}`); + try { + await Q.all(existingProjects.map(deleteProject)); + console.log('deleted old projects!'); + } catch (error) { + console.log('Problem deleting projects'); + console.log(error); + process.exit(1); + } + + try { + const newProjects = filledProjectList.map(async (project) => { + console.log(`saving ${project.name}`); + await createProject(project); }); + await Q.all(newProjects); + console.log(`Projects saved to User: ${editorUsername}!`); + } catch (error) { + console.log('Error saving projects'); + console.log(error); } } - /** * --------------------------------------------------------- * --------------------- main ------------------------------ @@ -394,18 +360,14 @@ async function checkP5User() { * Delete existing and save */ async function make() { - await checkP5User(); - // Get the user - const user = await User.findOne({ - username: editorUsername - }); // Get the categories and their examples const categories = await getCategories(); const categoryExamples = await getCategoryExamples(categories); + const examplesWithResourceTree = await traverseSketchTreeAll(categoryExamples); - const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user); - const filledProjectList = await fetchSketchContentAll(formattedSketchList); - await createProjectsInP5User(filledProjectList, user); + const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree); + + await createProjectsInP5User(formattedSketchList); console.log('done!'); process.exit(); } @@ -418,20 +380,14 @@ async function make() { * Format the sketch files to be save to the db * Delete existing and save */ +// eslint-disable-next-line no-unused-vars async function test() { - await checkP5User(); - // Get the user - const user = await User.findOne({ - username: editorUsername - }); - // read from file while testing const examplesWithResourceTree = JSON.parse(fs.readFileSync('./ml5-examplesWithResourceTree.json')); - const formattedSketchList = formatSketchForStorageAll(examplesWithResourceTree, user); + const formattedSketchList = await formatSketchForStorageAll(examplesWithResourceTree); - const filledProjectList = await fetchSketchContentAll(formattedSketchList); - await createProjectsInP5User(filledProjectList, user); + await createProjectsInP5User(formattedSketchList); console.log('done!'); process.exit(); } diff --git a/server/server.js b/server/server.js index 4483b722..af6ea25a 100644 --- a/server/server.js +++ b/server/server.js @@ -16,6 +16,7 @@ import webpackHotMiddleware from 'webpack-hot-middleware'; import config from '../webpack/config.dev'; // Import all required modules +import api from './routes/api.routes'; import users from './routes/user.routes'; import sessions from './routes/session.routes'; import projects from './routes/project.routes'; @@ -51,7 +52,7 @@ const corsOriginsWhitelist = [ // Run Webpack dev server in development mode if (process.env.NODE_ENV === 'development') { const compiler = webpack(config); - app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config[0].output.publicPath })); + app.use(webpackDevMiddleware(compiler, { lazy: false, noInfo: true, publicPath: config[0].output.publicPath })); app.use(webpackHotMiddleware(compiler)); corsOriginsWhitelist.push(/localhost/); @@ -95,6 +96,7 @@ app.use(session({ app.use(passport.initialize()); app.use(passport.session()); +app.use('/api/v1', requestsOfTypeJSON(), api); app.use('/api', requestsOfTypeJSON(), users); app.use('/api', requestsOfTypeJSON(), sessions); app.use('/api', requestsOfTypeJSON(), files); diff --git a/server/utils/__mocks__/createId.js b/server/utils/__mocks__/createId.js new file mode 100644 index 00000000..919ea252 --- /dev/null +++ b/server/utils/__mocks__/createId.js @@ -0,0 +1,16 @@ +/** + * Creates an increasing numeric ID + * for testing + */ +let nextId = 0; + +export default function mockCreateId() { + const id = nextId; + nextId += 1; + + return String(id); +} + +export function resetMockCreateId() { + nextId = 0; +} diff --git a/server/utils/createApplicationErrorClass.js b/server/utils/createApplicationErrorClass.js new file mode 100644 index 00000000..f949866c --- /dev/null +++ b/server/utils/createApplicationErrorClass.js @@ -0,0 +1,33 @@ +/** + * This is the base class for custom errors in + * the application. + */ +export class ApplicationError extends Error { + constructor(message, extra = {}) { + super(); + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + this.name = 'ApplicationError'; + this.message = message; + this.code = extra.code; + } +} + +/** + * Create a new custom error class e.g. + * const UserNotFoundError = createApplicationErrorClass('UserNotFoundError'); + * + * // Later + * throw new UserNotFoundError(`user ${user.name} not found`); + * + */ +export default function createApplicationErrorClass(name) { + return class extends ApplicationError { + constructor(...params) { + super(...params); + + this.name = name; + } + }; +} diff --git a/server/utils/createId.js b/server/utils/createId.js new file mode 100644 index 00000000..65b8290f --- /dev/null +++ b/server/utils/createId.js @@ -0,0 +1,8 @@ +import objectID from 'bson-objectid'; + +/** + * Creates a mongo ID + */ +export default function createId() { + return objectID().toHexString(); +}