From 9000aa3aac074b0638237b4c47d94f1141705e20 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 12 May 2018 23:14:06 -0700 Subject: [PATCH] First pass at API --- server/api/__snapshots__/shares.test.js.snap | 19 ++++++ server/api/index.js | 2 + server/api/shares.js | 64 +++++++++++++++++++ server/api/shares.test.js | 61 ++++++++++++++++++ .../20180513041057-add-share-links.js | 44 +++++++++++++ server/models/Share.js | 27 ++++++++ server/models/index.js | 39 +++++------ server/policies/document.js | 2 +- server/presenters/index.js | 2 + server/presenters/share.js | 15 +++++ 10 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 server/api/__snapshots__/shares.test.js.snap create mode 100644 server/api/shares.js create mode 100644 server/api/shares.test.js create mode 100644 server/migrations/20180513041057-add-share-links.js create mode 100644 server/models/Share.js create mode 100644 server/presenters/share.js diff --git a/server/api/__snapshots__/shares.test.js.snap b/server/api/__snapshots__/shares.test.js.snap new file mode 100644 index 000000000..77cc5d4df --- /dev/null +++ b/server/api/__snapshots__/shares.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#shares.create should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#shares.list should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/api/index.js b/server/api/index.js index 45ae7c19a..26e9e1dc3 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -12,6 +12,7 @@ import documents from './documents'; import views from './views'; import hooks from './hooks'; import apiKeys from './apiKeys'; +import shares from './shares'; import team from './team'; import integrations from './integrations'; @@ -74,6 +75,7 @@ router.use('/', documents.routes()); router.use('/', views.routes()); router.use('/', hooks.routes()); router.use('/', apiKeys.routes()); +router.use('/', shares.routes()); router.use('/', team.routes()); router.use('/', integrations.routes()); diff --git a/server/api/shares.js b/server/api/shares.js new file mode 100644 index 000000000..ee4a46dbd --- /dev/null +++ b/server/api/shares.js @@ -0,0 +1,64 @@ +// @flow +import Router from 'koa-router'; +import auth from './middlewares/authentication'; +import pagination from './middlewares/pagination'; +import { presentShare } from '../presenters'; +import { Document, User, Share } from '../models'; +import policy from '../policies'; + +const { authorize } = policy; +const router = new Router(); + +router.post('shares.list', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const shares = await Share.findAll({ + where: { teamId: user.teamId }, + order: [[sort, direction]], + include: [ + { + model: Document, + required: true, + as: 'document', + }, + { + model: User, + required: true, + as: 'user', + }, + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + const data = await Promise.all(shares.map(share => presentShare(ctx, share))); + + ctx.body = { + data, + }; +}); + +router.post('shares.create', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const document = await Document.findById(id); + authorize(user, 'share', document); + + const share = await Share.create({ + documentId: document.id, + userId: user.id, + teamId: user.teamId, + }); + share.user = user; + share.document = document; + + ctx.body = { + data: presentShare(ctx, share), + }; +}); + +export default router; diff --git a/server/api/shares.test.js b/server/api/shares.test.js new file mode 100644 index 000000000..02e437f6a --- /dev/null +++ b/server/api/shares.test.js @@ -0,0 +1,61 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from 'fetch-test-server'; +import app from '..'; +import { flushdb, seed } from '../test/support'; +import { buildUser } from '../test/factories'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(server.close); + +describe('#shares.list', async () => { + it('should return a list of shares', async () => { + const { user } = await seed(); + const res = await server.post('/api/shares.list', { + body: { token: user.getJwtToken() }, + }); + expect(res.status).toEqual(200); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/shares.list'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#shares.create', async () => { + it('should allow creating a share record for document', async () => { + const { user, document } = await seed(); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), id: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.documentTitle).toBe(document.title); + }); + + it('should require authentication', async () => { + const { document } = await seed(); + const res = await server.post('/api/shares.create', { + body: { id: document.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const { document } = await seed(); + const user = await buildUser(); + const res = await server.post('/api/shares.create', { + body: { token: user.getJwtToken(), id: document.id }, + }); + expect(res.status).toEqual(403); + }); +}); diff --git a/server/migrations/20180513041057-add-share-links.js b/server/migrations/20180513041057-add-share-links.js new file mode 100644 index 000000000..5d67446f9 --- /dev/null +++ b/server/migrations/20180513041057-add-share-links.js @@ -0,0 +1,44 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('shares', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'users', + }, + }, + teamId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'teams', + }, + }, + documentId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'documents', + }, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('shares'); + }, +}; diff --git a/server/models/Share.js b/server/models/Share.js new file mode 100644 index 000000000..3965b8642 --- /dev/null +++ b/server/models/Share.js @@ -0,0 +1,27 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const Share = sequelize.define('share', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, +}); + +Share.associate = models => { + Share.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + }); + Share.belongsTo(models.Team, { + as: 'team', + foreignKey: 'teamId', + }); + Share.belongsTo(models.Document, { + as: 'document', + foreignKey: 'documentId', + }); +}; + +export default Share; diff --git a/server/models/index.js b/server/models/index.js index 8c50e5951..16d1606a5 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,28 +1,30 @@ // @flow +import ApiKey from './ApiKey'; import Authentication from './Authentication'; -import Integration from './Integration'; -import Event from './Event'; -import User from './User'; -import Team from './Team'; import Collection from './Collection'; import Document from './Document'; +import Event from './Event'; +import Integration from './Integration'; import Revision from './Revision'; -import ApiKey from './ApiKey'; -import View from './View'; +import Share from './Share'; import Star from './Star'; +import Team from './Team'; +import User from './User'; +import View from './View'; const models = { + ApiKey, Authentication, - Integration, - Event, - User, - Team, Collection, Document, + Event, + Integration, Revision, - ApiKey, - View, + Share, Star, + Team, + User, + View, }; // based on https://github.com/sequelize/express-example/blob/master/models/index.js @@ -33,15 +35,16 @@ Object.keys(models).forEach(modelName => { }); export { + ApiKey, Authentication, - Integration, - Event, - User, - Team, Collection, Document, + Event, + Integration, Revision, - ApiKey, - View, + Share, Star, + Team, + User, + View, }; diff --git a/server/policies/document.js b/server/policies/document.js index 60f6afbbf..cbe3be79a 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -8,7 +8,7 @@ allow(User, 'create', Document); allow( User, - ['read', 'update', 'delete'], + ['read', 'update', 'delete', 'share'], Document, (user, document) => user.teamId === document.teamId ); diff --git a/server/presenters/index.js b/server/presenters/index.js index 3ec608df6..3796c23fa 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -5,6 +5,7 @@ import presentDocument from './document'; import presentRevision from './revision'; import presentCollection from './collection'; import presentApiKey from './apiKey'; +import presentShare from './share'; import presentTeam from './team'; import presentIntegration from './integration'; import presentSlackAttachment from './slackAttachment'; @@ -16,6 +17,7 @@ export { presentRevision, presentCollection, presentApiKey, + presentShare, presentTeam, presentIntegration, presentSlackAttachment, diff --git a/server/presenters/share.js b/server/presenters/share.js new file mode 100644 index 000000000..c6d87d935 --- /dev/null +++ b/server/presenters/share.js @@ -0,0 +1,15 @@ +// @flow +import { Share } from '../models'; +import { presentUser } from '.'; + +function present(ctx: Object, share: Share) { + return { + id: share.id, + user: presentUser(ctx, share.user), + documentTitle: share.document.title, + createdAt: share.createdAt, + updatedAt: share.updatedAt, + }; +} + +export default present;