141
server/routes/api/__snapshots__/collections.test.js.snap
Normal file
141
server/routes/api/__snapshots__/collections.test.js.snap
Normal file
@@ -0,0 +1,141 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`#collections.add_group should require group in team 1`] = `
|
||||
Object {
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.add_user should require user in team 1`] = `
|
||||
Object {
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.create should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.delete should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.export should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.export_all should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.group_memberships should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.import should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.info should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.list should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.memberships should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.move should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.remove_group should require group in team 1`] = `
|
||||
Object {
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.remove_user should require user in team 1`] = `
|
||||
Object {
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.update should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#collections.users should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
126
server/routes/api/__snapshots__/documents.test.js.snap
Normal file
126
server/routes/api/__snapshots__/documents.test.js.snap
Normal file
@@ -0,0 +1,126 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`#documents.create should error with invalid parentDocument 1`] = `
|
||||
Object {
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.delete should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.list should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.pin should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.restore should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.search should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.star should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.starred should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.unpin should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.unstar should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.update should fail if document lastRevision does not match 1`] = `
|
||||
Object {
|
||||
"error": "invalid_request",
|
||||
"message": "Document has changed since last revision",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.update should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.update should require text while appending 1`] = `
|
||||
Object {
|
||||
"error": "param_required",
|
||||
"message": "Text is required while appending",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#documents.viewed should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
10
server/routes/api/__snapshots__/events.test.js.snap
Normal file
10
server/routes/api/__snapshots__/events.test.js.snap
Normal file
@@ -0,0 +1,10 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`#events.list should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
88
server/routes/api/__snapshots__/groups.test.js.snap
Normal file
88
server/routes/api/__snapshots__/groups.test.js.snap
Normal file
@@ -0,0 +1,88 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`#groups.add_user should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
"message": "An admin role is required to access this resource",
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.add_user should require user in team 1`] = `
|
||||
Object {
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.delete should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.info should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.list should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.memberships should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.remove_user should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
"message": "An admin role is required to access this resource",
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.remove_user should require user in team 1`] = `
|
||||
Object {
|
||||
"error": "authorization_error",
|
||||
"message": "Authorization error",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.update should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#groups.update when user is admin fails with validation error when name already taken 1`] = `
|
||||
Object {
|
||||
"error": "",
|
||||
"message": "The name of this group is already in use (isUniqueNameInTeam)",
|
||||
"ok": false,
|
||||
}
|
||||
`;
|
||||
46
server/routes/api/__snapshots__/shares.test.js.snap
Normal file
46
server/routes/api/__snapshots__/shares.test.js.snap
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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.info 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,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#shares.revoke should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#shares.update should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
277
server/routes/api/__snapshots__/users.test.js.snap
Normal file
277
server/routes/api/__snapshots__/users.test.js.snap
Normal file
@@ -0,0 +1,277 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`#users.activate should activate a suspended user 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"isViewer": false,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
"ok": true,
|
||||
"policies": Array [
|
||||
Object {
|
||||
"abilities": Object {
|
||||
"activate": true,
|
||||
"delete": true,
|
||||
"demote": true,
|
||||
"promote": true,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
],
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.activate should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
"message": "An admin role is required to access this resource",
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.delete should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote should demote an admin 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"isViewer": false,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
"ok": true,
|
||||
"policies": Array [
|
||||
Object {
|
||||
"abilities": Object {
|
||||
"activate": true,
|
||||
"delete": true,
|
||||
"demote": true,
|
||||
"promote": true,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
],
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote should demote an admin to member 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"isViewer": false,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
"ok": true,
|
||||
"policies": Array [
|
||||
Object {
|
||||
"abilities": Object {
|
||||
"activate": true,
|
||||
"delete": true,
|
||||
"demote": true,
|
||||
"promote": true,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
],
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote should demote an admin to viewer 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"isViewer": true,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
"ok": true,
|
||||
"policies": Array [
|
||||
Object {
|
||||
"abilities": Object {
|
||||
"activate": true,
|
||||
"delete": true,
|
||||
"demote": true,
|
||||
"promote": true,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
],
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote should not demote admins if only one available 1`] = `
|
||||
Object {
|
||||
"error": "validation_error",
|
||||
"message": "At least one admin is required",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.demote should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
"message": "An admin role is required to access this resource",
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.promote should promote a new admin 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": true,
|
||||
"isSuspended": false,
|
||||
"isViewer": false,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
"ok": true,
|
||||
"policies": Array [
|
||||
Object {
|
||||
"abilities": Object {
|
||||
"activate": true,
|
||||
"delete": true,
|
||||
"demote": true,
|
||||
"promote": false,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
],
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.promote should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
"message": "An admin role is required to access this resource",
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.suspend should not allow suspending the user themselves 1`] = `
|
||||
Object {
|
||||
"error": "validation_error",
|
||||
"message": "Unable to suspend the current user",
|
||||
"ok": false,
|
||||
"status": 400,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.suspend should require admin 1`] = `
|
||||
Object {
|
||||
"error": "admin_required",
|
||||
"message": "An admin role is required to access this resource",
|
||||
"ok": false,
|
||||
"status": 403,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.suspend should suspend an user 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
"avatarUrl": "https://tiley.herokuapp.com/avatar/111d68d06e2d317b5a59c2c6c5bad808/U.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": true,
|
||||
"isViewer": false,
|
||||
"language": "en_US",
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
"ok": true,
|
||||
"policies": Array [
|
||||
Object {
|
||||
"abilities": Object {
|
||||
"activate": true,
|
||||
"delete": true,
|
||||
"demote": false,
|
||||
"promote": false,
|
||||
"read": true,
|
||||
"readDetails": true,
|
||||
"suspend": true,
|
||||
"update": false,
|
||||
},
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
},
|
||||
],
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.update should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
19
server/routes/api/__snapshots__/views.test.js.snap
Normal file
19
server/routes/api/__snapshots__/views.test.js.snap
Normal file
@@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`#views.create should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#views.list should require authentication 1`] = `
|
||||
Object {
|
||||
"error": "authentication_required",
|
||||
"message": "Authentication required",
|
||||
"ok": false,
|
||||
"status": 401,
|
||||
}
|
||||
`;
|
||||
80
server/routes/api/apiKeys.js
Normal file
80
server/routes/api/apiKeys.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { ApiKey, Event } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentApiKey } from "../../presenters";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("apiKeys.create", auth(), async (ctx) => {
|
||||
const { name } = ctx.body;
|
||||
ctx.assertPresent(name, "name is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "createApiKey", user.team);
|
||||
|
||||
const key = await ApiKey.create({
|
||||
name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await Event.create({
|
||||
name: "api_keys.create",
|
||||
modelId: key.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentApiKey(key),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("apiKeys.list", auth(), pagination(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
const keys = await ApiKey.findAll({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: keys.map(presentApiKey),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("apiKeys.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const key = await ApiKey.findByPk(id);
|
||||
authorize(user, "delete", key);
|
||||
|
||||
await key.destroy();
|
||||
|
||||
await Event.create({
|
||||
name: "api_keys.delete",
|
||||
modelId: key.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { name: key.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
156
server/routes/api/attachments.js
Normal file
156
server/routes/api/attachments.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// @flow
|
||||
import { format } from "date-fns";
|
||||
import Router from "koa-router";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { NotFoundError } from "../../errors";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Attachment, Document, Event } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import {
|
||||
makePolicy,
|
||||
getSignature,
|
||||
publicS3Endpoint,
|
||||
makeCredential,
|
||||
getSignedUrl,
|
||||
} from "../../utils/s3";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
const AWS_S3_ACL = process.env.AWS_S3_ACL || "private";
|
||||
|
||||
router.post("attachments.create", auth(), async (ctx) => {
|
||||
let { name, documentId, contentType, size } = ctx.body;
|
||||
|
||||
ctx.assertPresent(name, "name is required");
|
||||
ctx.assertPresent(contentType, "contentType is required");
|
||||
ctx.assertPresent(size, "size is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
authorize(user, "createAttachment", user.team);
|
||||
|
||||
const s3Key = uuidv4();
|
||||
const acl =
|
||||
ctx.body.public === undefined
|
||||
? AWS_S3_ACL
|
||||
: ctx.body.public
|
||||
? "public-read"
|
||||
: "private";
|
||||
|
||||
const bucket = acl === "public-read" ? "public" : "uploads";
|
||||
const key = `${bucket}/${user.id}/${s3Key}/${name}`;
|
||||
const credential = makeCredential();
|
||||
const longDate = format(new Date(), "yyyyMMdd'T'HHmmss'Z'");
|
||||
const policy = makePolicy(credential, longDate, acl, contentType);
|
||||
const endpoint = publicS3Endpoint();
|
||||
const url = `${endpoint}/${key}`;
|
||||
|
||||
if (documentId) {
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
authorize(user, "update", document);
|
||||
}
|
||||
|
||||
const attachment = await Attachment.create({
|
||||
key,
|
||||
acl,
|
||||
size,
|
||||
url,
|
||||
contentType,
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await Event.create({
|
||||
name: "attachments.create",
|
||||
data: { name },
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
maxUploadSize: process.env.AWS_S3_UPLOAD_MAX_SIZE,
|
||||
uploadUrl: endpoint,
|
||||
form: {
|
||||
"Cache-Control": "max-age=31557600",
|
||||
"Content-Type": contentType,
|
||||
acl,
|
||||
key,
|
||||
policy,
|
||||
"x-amz-algorithm": "AWS4-HMAC-SHA256",
|
||||
"x-amz-credential": credential,
|
||||
"x-amz-date": longDate,
|
||||
"x-amz-signature": getSignature(policy),
|
||||
},
|
||||
attachment: {
|
||||
documentId,
|
||||
contentType,
|
||||
name,
|
||||
id: attachment.id,
|
||||
url: attachment.redirectUrl,
|
||||
size,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("attachments.delete", auth(), async (ctx) => {
|
||||
let { id } = ctx.body;
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const attachment = await Attachment.findByPk(id);
|
||||
if (!attachment) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
if (attachment.documentId) {
|
||||
const document = await Document.findByPk(attachment.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "update", document);
|
||||
}
|
||||
|
||||
authorize(user, "delete", attachment);
|
||||
await attachment.destroy();
|
||||
|
||||
await Event.create({
|
||||
name: "attachments.delete",
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("attachments.redirect", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const attachment = await Attachment.findByPk(id);
|
||||
if (!attachment) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
if (attachment.isPrivate) {
|
||||
if (attachment.documentId) {
|
||||
const document = await Document.findByPk(attachment.documentId, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
}
|
||||
|
||||
const accessUrl = await getSignedUrl(attachment.key);
|
||||
ctx.redirect(accessUrl);
|
||||
} else {
|
||||
ctx.redirect(attachment.url);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
232
server/routes/api/attachments.test.js
Normal file
232
server/routes/api/attachments.test.js
Normal file
@@ -0,0 +1,232 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import { Attachment } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import {
|
||||
buildUser,
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
buildAttachment,
|
||||
buildDocument,
|
||||
} from "../../test/factories";
|
||||
import { flushdb } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#attachments.delete", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/attachments.delete");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should allow deleting an attachment belonging to a document user has access to", async () => {
|
||||
const user = await buildUser();
|
||||
const attachment = await buildAttachment({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/attachments.delete", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Attachment.count()).toEqual(0);
|
||||
});
|
||||
|
||||
it("should allow deleting an attachment without a document created by user", async () => {
|
||||
const user = await buildUser();
|
||||
const attachment = await buildAttachment({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
attachment.documentId = null;
|
||||
await attachment.save();
|
||||
|
||||
const res = await server.post("/api/attachments.delete", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Attachment.count()).toEqual(0);
|
||||
});
|
||||
|
||||
it("should allow deleting an attachment without a document if admin", async () => {
|
||||
const user = await buildAdmin();
|
||||
const attachment = await buildAttachment({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
attachment.documentId = null;
|
||||
await attachment.save();
|
||||
|
||||
const res = await server.post("/api/attachments.delete", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Attachment.count()).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not allow deleting an attachment in another team", async () => {
|
||||
const user = await buildAdmin();
|
||||
const attachment = await buildAttachment();
|
||||
|
||||
attachment.documentId = null;
|
||||
await attachment.save();
|
||||
|
||||
const res = await server.post("/api/attachments.delete", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should not allow deleting an attachment without a document", async () => {
|
||||
const user = await buildUser();
|
||||
const attachment = await buildAttachment({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
attachment.documentId = null;
|
||||
await attachment.save();
|
||||
|
||||
const res = await server.post("/api/attachments.delete", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should not allow deleting an attachment belonging to a document user does not have access to", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: collection.teamId,
|
||||
userId: collection.userId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document.teamId,
|
||||
userId: document.userId,
|
||||
documentId: document.id,
|
||||
acl: "private",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/attachments.delete", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#attachments.redirect", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/attachments.redirect");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should return a redirect for an attachment belonging to a document user has access to", async () => {
|
||||
const user = await buildUser();
|
||||
const attachment = await buildAttachment({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/attachments.redirect", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(302);
|
||||
});
|
||||
|
||||
it("should return a redirect for an attachment belonging to a trashed document user has access to", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const attachment = await buildAttachment({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/attachments.redirect", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(302);
|
||||
});
|
||||
|
||||
it("should always return a redirect for a public attachment", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const attachment = await buildAttachment({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/attachments.redirect", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(302);
|
||||
});
|
||||
|
||||
it("should not return a redirect for a private attachment belonging to a document user does not have access to", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: collection.teamId,
|
||||
userId: collection.userId,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document.teamId,
|
||||
userId: document.userId,
|
||||
documentId: document.id,
|
||||
acl: "private",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/attachments.redirect", {
|
||||
body: { token: user.getJwtToken(), id: attachment.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
117
server/routes/api/auth.js
Normal file
117
server/routes/api/auth.js
Normal file
@@ -0,0 +1,117 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import { find } from "lodash";
|
||||
import { parseDomain, isCustomSubdomain } from "../../../shared/utils/domains";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Team } from "../../models";
|
||||
import { presentUser, presentTeam, presentPolicies } from "../../presenters";
|
||||
import { isCustomDomain } from "../../utils/domains";
|
||||
import providers from "../auth/providers";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
function filterProviders(team) {
|
||||
return providers
|
||||
.sort((provider) => (provider.id === "email" ? 1 : -1))
|
||||
.filter((provider) => {
|
||||
// guest sign-in is an exception as it does not have an authentication
|
||||
// provider using passport, instead it exists as a boolean option on the team
|
||||
if (provider.id === "email") {
|
||||
return team && team.guestSignin;
|
||||
}
|
||||
|
||||
return (
|
||||
!team ||
|
||||
find(team.authenticationProviders, { name: provider.id, enabled: true })
|
||||
);
|
||||
})
|
||||
.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
authUrl: provider.authUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
router.post("auth.config", async (ctx) => {
|
||||
// If self hosted AND there is only one team then that team becomes the
|
||||
// brand for the knowledge base and it's guest signin option is used for the
|
||||
// root login page.
|
||||
if (process.env.DEPLOYMENT !== "hosted") {
|
||||
const teams = await Team.scope("withAuthenticationProviders").findAll();
|
||||
|
||||
if (teams.length === 1) {
|
||||
const team = teams[0];
|
||||
ctx.body = {
|
||||
data: {
|
||||
name: team.name,
|
||||
providers: filterProviders(team),
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCustomDomain(ctx.request.hostname)) {
|
||||
const team = await Team.scope("withAuthenticationProviders").findOne({
|
||||
where: { domain: ctx.request.hostname },
|
||||
});
|
||||
|
||||
if (team) {
|
||||
ctx.body = {
|
||||
data: {
|
||||
name: team.name,
|
||||
hostname: ctx.request.hostname,
|
||||
providers: filterProviders(team),
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If subdomain signin page then we return minimal team details to allow
|
||||
// for a custom screen showing only relevant signin options for that team.
|
||||
if (
|
||||
process.env.SUBDOMAINS_ENABLED === "true" &&
|
||||
isCustomSubdomain(ctx.request.hostname) &&
|
||||
!isCustomDomain(ctx.request.hostname)
|
||||
) {
|
||||
const domain = parseDomain(ctx.request.hostname);
|
||||
const subdomain = domain ? domain.subdomain : undefined;
|
||||
const team = await Team.scope("withAuthenticationProviders").findOne({
|
||||
where: { subdomain },
|
||||
});
|
||||
|
||||
if (team) {
|
||||
ctx.body = {
|
||||
data: {
|
||||
name: team.name,
|
||||
hostname: ctx.request.hostname,
|
||||
providers: filterProviders(team),
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, we're requesting from the standard root signin page
|
||||
ctx.body = {
|
||||
data: {
|
||||
providers: filterProviders(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("auth.info", auth(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
user: presentUser(user, { includeDetails: true }),
|
||||
team: presentTeam(team),
|
||||
},
|
||||
policies: presentPolicies(user, [team]),
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
189
server/routes/api/auth.test.js
Normal file
189
server/routes/api/auth.test.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import webService from "../../services/web";
|
||||
import { buildUser, buildTeam } from "../../test/factories";
|
||||
import { flushdb } from "../../test/support";
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#auth.info", () => {
|
||||
it("should return current authentication", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
|
||||
const res = await server.post("/api/auth.info", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.user.name).toBe(user.name);
|
||||
expect(body.data.team.name).toBe(team.name);
|
||||
});
|
||||
|
||||
it("should require the team to not be deleted", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
|
||||
await team.destroy();
|
||||
|
||||
const res = await server.post("/api/auth.info", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/auth.info");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#auth.config", () => {
|
||||
it("should return available SSO providers", async () => {
|
||||
const res = await server.post("/api/auth.config");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.providers.length).toBe(2);
|
||||
expect(body.data.providers[0].name).toBe("Slack");
|
||||
expect(body.data.providers[1].name).toBe("Google");
|
||||
});
|
||||
|
||||
it("should return available providers for team subdomain", async () => {
|
||||
process.env.URL = "http://localoutline.com";
|
||||
|
||||
await buildTeam({
|
||||
guestSignin: false,
|
||||
subdomain: "example",
|
||||
authenticationProviders: [
|
||||
{
|
||||
name: "slack",
|
||||
providerId: "123",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await server.post("/api/auth.config", {
|
||||
headers: { host: `example.localoutline.com` },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.providers.length).toBe(1);
|
||||
expect(body.data.providers[0].name).toBe("Slack");
|
||||
});
|
||||
|
||||
it("should return available providers for team custom domain", async () => {
|
||||
await buildTeam({
|
||||
guestSignin: false,
|
||||
domain: "docs.mycompany.com",
|
||||
authenticationProviders: [
|
||||
{
|
||||
name: "slack",
|
||||
providerId: "123",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await server.post("/api/auth.config", {
|
||||
headers: { host: "docs.mycompany.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.providers.length).toBe(1);
|
||||
expect(body.data.providers[0].name).toBe("Slack");
|
||||
});
|
||||
|
||||
it("should return email provider for team when guest signin enabled", async () => {
|
||||
process.env.URL = "http://localoutline.com";
|
||||
|
||||
await buildTeam({
|
||||
guestSignin: true,
|
||||
subdomain: "example",
|
||||
authenticationProviders: [
|
||||
{
|
||||
name: "slack",
|
||||
providerId: "123",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await server.post("/api/auth.config", {
|
||||
headers: { host: "example.localoutline.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.providers.length).toBe(2);
|
||||
expect(body.data.providers[0].name).toBe("Slack");
|
||||
expect(body.data.providers[1].name).toBe("Email");
|
||||
});
|
||||
|
||||
it("should not return provider when disabled", async () => {
|
||||
process.env.URL = "http://localoutline.com";
|
||||
|
||||
await buildTeam({
|
||||
guestSignin: false,
|
||||
subdomain: "example",
|
||||
authenticationProviders: [
|
||||
{
|
||||
name: "slack",
|
||||
providerId: "123",
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await server.post("/api/auth.config", {
|
||||
headers: { host: "example.localoutline.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.providers.length).toBe(0);
|
||||
});
|
||||
|
||||
describe("self hosted", () => {
|
||||
it("should return available providers for team", async () => {
|
||||
process.env.DEPLOYMENT = "";
|
||||
|
||||
await buildTeam({
|
||||
guestSignin: false,
|
||||
authenticationProviders: [
|
||||
{
|
||||
name: "slack",
|
||||
providerId: "123",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await server.post("/api/auth.config");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.providers.length).toBe(1);
|
||||
expect(body.data.providers[0].name).toBe("Slack");
|
||||
});
|
||||
|
||||
it("should return email provider for team when guest signin enabled", async () => {
|
||||
process.env.DEPLOYMENT = "";
|
||||
|
||||
await buildTeam({
|
||||
guestSignin: true,
|
||||
authenticationProviders: [
|
||||
{
|
||||
name: "slack",
|
||||
providerId: "123",
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await server.post("/api/auth.config");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.providers.length).toBe(2);
|
||||
expect(body.data.providers[0].name).toBe("Slack");
|
||||
expect(body.data.providers[1].name).toBe("Email");
|
||||
});
|
||||
});
|
||||
});
|
||||
88
server/routes/api/authenticationProviders.js
Normal file
88
server/routes/api/authenticationProviders.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { AuthenticationProvider, Event } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import {
|
||||
presentAuthenticationProvider,
|
||||
presentPolicies,
|
||||
} from "../../presenters";
|
||||
import allAuthenticationProviders from "../auth/providers";
|
||||
|
||||
const router = new Router();
|
||||
const { authorize } = policy;
|
||||
|
||||
router.post("authenticationProviders.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const authenticationProvider = await AuthenticationProvider.findByPk(id);
|
||||
authorize(user, "read", authenticationProvider);
|
||||
|
||||
ctx.body = {
|
||||
data: presentAuthenticationProvider(authenticationProvider),
|
||||
policies: presentPolicies(user, [authenticationProvider]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("authenticationProviders.update", auth(), async (ctx) => {
|
||||
const { id, isEnabled } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertPresent(isEnabled, "isEnabled is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const authenticationProvider = await AuthenticationProvider.findByPk(id);
|
||||
authorize(user, "update", authenticationProvider);
|
||||
|
||||
const enabled = !!isEnabled;
|
||||
if (enabled) {
|
||||
await authenticationProvider.enable();
|
||||
} else {
|
||||
await authenticationProvider.disable();
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "authenticationProviders.update",
|
||||
data: { enabled },
|
||||
modelId: id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentAuthenticationProvider(authenticationProvider),
|
||||
policies: presentPolicies(user, [authenticationProvider]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("authenticationProviders.list", auth(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "read", user.team);
|
||||
|
||||
const teamAuthenticationProviders = await user.team.getAuthenticationProviders();
|
||||
const otherAuthenticationProviders = allAuthenticationProviders.filter(
|
||||
(p) =>
|
||||
!teamAuthenticationProviders.find((t) => t.name === p.id) &&
|
||||
p.enabled &&
|
||||
// email auth is dealt with separetly right now, although it definitely
|
||||
// wants to be here in the future – we'll need to migrate more data though
|
||||
p.id !== "email"
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
authenticationProviders: [
|
||||
...teamAuthenticationProviders.map(presentAuthenticationProvider),
|
||||
...otherAuthenticationProviders.map((p) => ({
|
||||
name: p.id,
|
||||
isEnabled: false,
|
||||
isConnected: false,
|
||||
})),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
157
server/routes/api/authenticationProviders.test.js
Normal file
157
server/routes/api/authenticationProviders.test.js
Normal file
@@ -0,0 +1,157 @@
|
||||
// @flow
|
||||
import TestServer from "fetch-test-server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import webService from "../../services/web";
|
||||
import { buildUser, buildAdmin, buildTeam } from "../../test/factories";
|
||||
import { flushdb } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#authenticationProviders.info", () => {
|
||||
it("should return auth provider", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.info", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe("slack");
|
||||
expect(body.data.isEnabled).toBe(true);
|
||||
expect(body.data.isConnected).toBe(true);
|
||||
expect(body.policies[0].abilities.read).toBe(true);
|
||||
expect(body.policies[0].abilities.update).toBe(false);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.info", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const team = await buildTeam();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.info", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#authenticationProviders.update", () => {
|
||||
it("should not allow admins to disable when last authentication provider", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow admins to disable", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({ teamId: team.id });
|
||||
await team.createAuthenticationProvider({
|
||||
name: "google",
|
||||
providerId: uuidv4(),
|
||||
});
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe("slack");
|
||||
expect(body.data.isEnabled).toBe(false);
|
||||
expect(body.data.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const team = await buildTeam();
|
||||
const authenticationProviders = await team.getAuthenticationProviders();
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.update", {
|
||||
body: {
|
||||
id: authenticationProviders[0].id,
|
||||
isEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#authenticationProviders.list", () => {
|
||||
it("should return enabled and available auth providers", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
|
||||
const res = await server.post("/api/authenticationProviders.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.authenticationProviders.length).toBe(2);
|
||||
expect(body.data.authenticationProviders[0].name).toBe("slack");
|
||||
expect(body.data.authenticationProviders[0].isEnabled).toBe(true);
|
||||
expect(body.data.authenticationProviders[0].isConnected).toBe(true);
|
||||
expect(body.data.authenticationProviders[1].name).toBe("google");
|
||||
expect(body.data.authenticationProviders[1].isEnabled).toBe(false);
|
||||
expect(body.data.authenticationProviders[1].isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/authenticationProviders.list");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
706
server/routes/api/collections.js
Normal file
706
server/routes/api/collections.js
Normal file
@@ -0,0 +1,706 @@
|
||||
// @flow
|
||||
import fractionalIndex from "fractional-index";
|
||||
import Router from "koa-router";
|
||||
import { ValidationError } from "../../errors";
|
||||
import { exportCollections } from "../../exporter";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import {
|
||||
Collection,
|
||||
CollectionUser,
|
||||
CollectionGroup,
|
||||
Team,
|
||||
Event,
|
||||
User,
|
||||
Group,
|
||||
Attachment,
|
||||
FileOperation,
|
||||
} from "../../models";
|
||||
import policy from "../../policies";
|
||||
import {
|
||||
presentCollection,
|
||||
presentUser,
|
||||
presentPolicies,
|
||||
presentMembership,
|
||||
presentGroup,
|
||||
presentCollectionGroupMembership,
|
||||
presentFileOperation,
|
||||
} from "../../presenters";
|
||||
import { Op, sequelize } from "../../sequelize";
|
||||
|
||||
import collectionIndexing from "../../utils/collectionIndexing";
|
||||
import removeIndexCollision from "../../utils/removeIndexCollision";
|
||||
import { getAWSKeyForFileOp } from "../../utils/s3";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("collections.create", auth(), async (ctx) => {
|
||||
const {
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
permission,
|
||||
sharing,
|
||||
icon,
|
||||
sort = Collection.DEFAULT_SORT,
|
||||
} = ctx.body;
|
||||
|
||||
let { index } = ctx.body;
|
||||
ctx.assertPresent(name, "name is required");
|
||||
|
||||
if (color) {
|
||||
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
|
||||
}
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "createCollection", user.team);
|
||||
|
||||
const collections = await Collection.findAll({
|
||||
where: { teamId: user.teamId, deletedAt: null },
|
||||
attributes: ["id", "index", "updatedAt"],
|
||||
limit: 1,
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
});
|
||||
|
||||
if (index) {
|
||||
ctx.assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
);
|
||||
} else {
|
||||
index = fractionalIndex(
|
||||
null,
|
||||
collections.length ? collections[0].index : null
|
||||
);
|
||||
}
|
||||
|
||||
index = await removeIndexCollision(user.teamId, index);
|
||||
|
||||
let collection = await Collection.create({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
permission: permission ? permission : null,
|
||||
sharing,
|
||||
sort,
|
||||
index,
|
||||
});
|
||||
|
||||
await Event.create({
|
||||
name: "collections.create",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: user.id,
|
||||
data: { name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
// we must reload the collection to get memberships for policy presenter
|
||||
collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collection.id);
|
||||
|
||||
ctx.body = {
|
||||
data: presentCollection(collection),
|
||||
policies: presentPolicies(user, [collection]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
authorize(user, "read", collection);
|
||||
|
||||
ctx.body = {
|
||||
data: presentCollection(collection),
|
||||
policies: presentPolicies(user, [collection]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.import", auth(), async (ctx) => {
|
||||
const { type, attachmentId } = ctx.body;
|
||||
ctx.assertIn(type, ["outline"], "type must be one of 'outline'");
|
||||
ctx.assertUuid(attachmentId, "attachmentId is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "importCollection", user.team);
|
||||
|
||||
const attachment = await Attachment.findByPk(attachmentId);
|
||||
authorize(user, "read", attachment);
|
||||
|
||||
await Event.create({
|
||||
name: "collections.import",
|
||||
modelId: attachmentId,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { type },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.add_group", auth(), async (ctx) => {
|
||||
const { id, groupId, permission = "read_write" } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(groupId, "groupId is required");
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", ctx.state.user.id],
|
||||
}).findByPk(id);
|
||||
authorize(ctx.state.user, "update", collection);
|
||||
|
||||
const group = await Group.findByPk(groupId);
|
||||
authorize(ctx.state.user, "read", group);
|
||||
|
||||
let membership = await CollectionGroup.findOne({
|
||||
where: {
|
||||
collectionId: id,
|
||||
groupId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
membership = await CollectionGroup.create({
|
||||
collectionId: id,
|
||||
groupId,
|
||||
permission,
|
||||
createdById: ctx.state.user.id,
|
||||
});
|
||||
} else if (permission) {
|
||||
membership.permission = permission;
|
||||
await membership.save();
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "collections.add_group",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
data: { name: group.name, groupId },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
collectionGroupMemberships: [
|
||||
presentCollectionGroupMembership(membership),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.remove_group", auth(), async (ctx) => {
|
||||
const { id, groupId } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(groupId, "groupId is required");
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", ctx.state.user.id],
|
||||
}).findByPk(id);
|
||||
authorize(ctx.state.user, "update", collection);
|
||||
|
||||
const group = await Group.findByPk(groupId);
|
||||
authorize(ctx.state.user, "read", group);
|
||||
|
||||
await collection.removeGroup(group);
|
||||
|
||||
await Event.create({
|
||||
name: "collections.remove_group",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
data: { name: group.name, groupId },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post(
|
||||
"collections.group_memberships",
|
||||
auth(),
|
||||
pagination(),
|
||||
async (ctx) => {
|
||||
const { id, query, permission } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
authorize(user, "read", collection);
|
||||
|
||||
let where = {
|
||||
collectionId: id,
|
||||
};
|
||||
|
||||
let groupWhere;
|
||||
|
||||
if (query) {
|
||||
groupWhere = {
|
||||
name: {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (permission) {
|
||||
where = {
|
||||
...where,
|
||||
permission,
|
||||
};
|
||||
}
|
||||
|
||||
const memberships = await CollectionGroup.findAll({
|
||||
where,
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
include: [
|
||||
{
|
||||
model: Group,
|
||||
as: "group",
|
||||
where: groupWhere,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: {
|
||||
collectionGroupMemberships: memberships.map(
|
||||
presentCollectionGroupMembership
|
||||
),
|
||||
groups: memberships.map((membership) => presentGroup(membership.group)),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post("collections.add_user", auth(), async (ctx) => {
|
||||
const { id, userId, permission = "read_write" } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(userId, "userId is required");
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", ctx.state.user.id],
|
||||
}).findByPk(id);
|
||||
authorize(ctx.state.user, "update", collection);
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(ctx.state.user, "read", user);
|
||||
|
||||
let membership = await CollectionUser.findOne({
|
||||
where: {
|
||||
collectionId: id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
membership = await CollectionUser.create({
|
||||
collectionId: id,
|
||||
userId,
|
||||
permission,
|
||||
createdById: ctx.state.user.id,
|
||||
});
|
||||
} else if (permission) {
|
||||
membership.permission = permission;
|
||||
await membership.save();
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "collections.add_user",
|
||||
userId,
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
users: [presentUser(user)],
|
||||
memberships: [presentMembership(membership)],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.remove_user", auth(), async (ctx) => {
|
||||
const { id, userId } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(userId, "userId is required");
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", ctx.state.user.id],
|
||||
}).findByPk(id);
|
||||
authorize(ctx.state.user, "update", collection);
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(ctx.state.user, "read", user);
|
||||
|
||||
await collection.removeUser(user);
|
||||
|
||||
await Event.create({
|
||||
name: "collections.remove_user",
|
||||
userId,
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
// DEPRECATED: Use collection.memberships which has pagination, filtering and permissions
|
||||
router.post("collections.users", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
authorize(user, "read", collection);
|
||||
|
||||
const users = await collection.getUsers();
|
||||
|
||||
ctx.body = {
|
||||
data: users.map(presentUser),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.memberships", auth(), pagination(), async (ctx) => {
|
||||
const { id, query, permission } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
authorize(user, "read", collection);
|
||||
|
||||
let where = {
|
||||
collectionId: id,
|
||||
};
|
||||
|
||||
let userWhere;
|
||||
|
||||
if (query) {
|
||||
userWhere = {
|
||||
name: {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (permission) {
|
||||
where = {
|
||||
...where,
|
||||
permission,
|
||||
};
|
||||
}
|
||||
|
||||
const memberships = await CollectionUser.findAll({
|
||||
where,
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "user",
|
||||
where: userWhere,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: {
|
||||
memberships: memberships.map(presentMembership),
|
||||
users: memberships.map((membership) => presentUser(membership.user)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.export", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "export", team);
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
ctx.assertPresent(collection, "Collection should be present");
|
||||
authorize(user, "read", collection);
|
||||
|
||||
const key = getAWSKeyForFileOp(team.id, collection.name);
|
||||
|
||||
let exportData;
|
||||
exportData = await FileOperation.create({
|
||||
type: "export",
|
||||
state: "creating",
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId: id,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
exportCollections(user.teamId, user.id, user.email, exportData.id, id);
|
||||
|
||||
exportData.user = user;
|
||||
exportData.collection = collection;
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
data: { fileOperation: presentFileOperation(exportData) },
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.export_all", auth(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "export", team);
|
||||
|
||||
const key = getAWSKeyForFileOp(team.id, team.name);
|
||||
|
||||
let exportData;
|
||||
exportData = await FileOperation.create({
|
||||
type: "export",
|
||||
state: "creating",
|
||||
key,
|
||||
url: null,
|
||||
size: 0,
|
||||
collectionId: null,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// async operation to upload zip archive to cloud and email user with link
|
||||
exportCollections(user.teamId, user.id, user.email, exportData.id);
|
||||
|
||||
exportData.user = user;
|
||||
exportData.collection = null;
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
data: { fileOperation: presentFileOperation(exportData) },
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.update", auth(), async (ctx) => {
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
permission,
|
||||
color,
|
||||
sort,
|
||||
sharing,
|
||||
} = ctx.body;
|
||||
|
||||
if (color) {
|
||||
ctx.assertHexColor(color, "Invalid hex value (please use format #FFFFFF)");
|
||||
}
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
authorize(user, "update", collection);
|
||||
|
||||
// we're making this collection have no default access, ensure that the current
|
||||
// user has a read-write membership so that at least they can edit it
|
||||
if (permission !== "read_write" && collection.permission === "read_write") {
|
||||
await CollectionUser.findOrCreate({
|
||||
where: {
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
},
|
||||
defaults: {
|
||||
permission: "read_write",
|
||||
createdById: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const permissionChanged = permission !== collection.permission;
|
||||
|
||||
if (name !== undefined) {
|
||||
collection.name = name;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
collection.description = description;
|
||||
}
|
||||
if (icon !== undefined) {
|
||||
collection.icon = icon;
|
||||
}
|
||||
if (color !== undefined) {
|
||||
collection.color = color;
|
||||
}
|
||||
if (permission !== undefined) {
|
||||
collection.permission = permission ? permission : null;
|
||||
}
|
||||
if (sharing !== undefined) {
|
||||
collection.sharing = sharing;
|
||||
}
|
||||
if (sort !== undefined) {
|
||||
collection.sort = sort;
|
||||
}
|
||||
|
||||
await collection.save();
|
||||
|
||||
await Event.create({
|
||||
name: "collections.update",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: user.id,
|
||||
data: { name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
// must reload to update collection membership for correct policy calculation
|
||||
// if the privacy level has changed. Otherwise skip this query for speed.
|
||||
if (permissionChanged) {
|
||||
await collection.reload();
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: presentCollection(collection),
|
||||
policies: presentPolicies(user, [collection]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.list", auth(), pagination(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
const collectionIds = await user.collectionIds();
|
||||
let collections = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
id: collectionIds,
|
||||
},
|
||||
order: [["updatedAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const nullIndexCollection = collections.findIndex(
|
||||
(collection) => collection.index === null
|
||||
);
|
||||
|
||||
if (nullIndexCollection !== -1) {
|
||||
const indexedCollections = await collectionIndexing(ctx.state.user.teamId);
|
||||
collections.forEach((collection) => {
|
||||
collection.index = indexedCollections[collection.id];
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: collections.map(presentCollection),
|
||||
policies: presentPolicies(user, collections),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
const user = ctx.state.user;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
authorize(user, "delete", collection);
|
||||
|
||||
const total = await Collection.count();
|
||||
if (total === 1) throw new ValidationError("Cannot delete last collection");
|
||||
|
||||
await collection.destroy();
|
||||
|
||||
await Event.create({
|
||||
name: "collections.delete",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: user.id,
|
||||
data: { name: collection.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("collections.move", auth(), async (ctx) => {
|
||||
const id = ctx.body.id;
|
||||
let index = ctx.body.index;
|
||||
|
||||
ctx.assertPresent(index, "index is required");
|
||||
ctx.assertIndexCharacters(
|
||||
index,
|
||||
"Index characters must be between x20 to x7E ASCII"
|
||||
);
|
||||
ctx.assertUuid(id, "id must be a uuid");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.findByPk(id);
|
||||
|
||||
authorize(user, "move", collection);
|
||||
|
||||
index = await removeIndexCollision(user.teamId, index);
|
||||
|
||||
await collection.update({ index });
|
||||
|
||||
await Event.create({
|
||||
name: "collections.move",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: user.id,
|
||||
data: { index },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
data: { index },
|
||||
};
|
||||
});
|
||||
export default router;
|
||||
1438
server/routes/api/collections.test.js
Normal file
1438
server/routes/api/collections.test.js
Normal file
File diff suppressed because it is too large
Load Diff
1420
server/routes/api/documents.js
Normal file
1420
server/routes/api/documents.js
Normal file
File diff suppressed because it is too large
Load Diff
2439
server/routes/api/documents.test.js
Normal file
2439
server/routes/api/documents.test.js
Normal file
File diff suppressed because it is too large
Load Diff
95
server/routes/api/events.js
Normal file
95
server/routes/api/events.js
Normal file
@@ -0,0 +1,95 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import Sequelize from "sequelize";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Event, User, Collection } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentEvent } from "../../presenters";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
let {
|
||||
sort = "createdAt",
|
||||
actorId,
|
||||
documentId,
|
||||
collectionId,
|
||||
direction,
|
||||
name,
|
||||
auditLog = false,
|
||||
} = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, Event);
|
||||
|
||||
let where = {
|
||||
name: Event.ACTIVITY_EVENTS,
|
||||
teamId: user.teamId,
|
||||
};
|
||||
|
||||
if (actorId) {
|
||||
ctx.assertUuid(actorId, "actorId must be a UUID");
|
||||
where = { ...where, actorId };
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
ctx.assertUuid(documentId, "documentId must be a UUID");
|
||||
where = { ...where, documentId };
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
ctx.assertUuid(collectionId, "collection must be a UUID");
|
||||
|
||||
where = { ...where, collectionId };
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "read", collection);
|
||||
} else {
|
||||
const collectionIds = await user.collectionIds({ paranoid: false });
|
||||
where = {
|
||||
...where,
|
||||
[Op.or]: [
|
||||
{ collectionId: collectionIds },
|
||||
{
|
||||
collectionId: {
|
||||
[Op.eq]: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
authorize(user, "manage", user.team);
|
||||
where.name = Event.AUDIT_EVENTS;
|
||||
}
|
||||
|
||||
if (name && where.name.includes(name)) {
|
||||
where.name = name;
|
||||
}
|
||||
|
||||
const events = await Event.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "actor",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: events.map((event) => presentEvent(event, auditLog)),
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
226
server/routes/api/events.test.js
Normal file
226
server/routes/api/events.test.js
Normal file
@@ -0,0 +1,226 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import webService from "../../services/web";
|
||||
import { buildEvent, buildUser } from "../../test/factories";
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#events.list", () => {
|
||||
it("should only return activity events", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// event viewable in activity stream
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should return audit events", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
const auditEvent = await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// event viewable in activity stream
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: admin.getJwtToken(), auditLog: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
expect(body.data[1].id).toEqual(auditEvent.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by actorId", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
const auditEvent = await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// event viewable in activity stream
|
||||
await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: admin.getJwtToken(), auditLog: true, actorId: admin.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(auditEvent.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by documentId", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should not return events for documentId without authorization", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const actor = await buildUser();
|
||||
|
||||
await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: actor.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should allow filtering by event name", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// audit event
|
||||
await buildEvent({
|
||||
name: "users.promote",
|
||||
teamId: user.teamId,
|
||||
actorId: admin.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// event viewable in activity stream
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
name: "documents.publish",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should return events with deleted actors", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
// event viewable in activity stream
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
await user.destroy();
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: admin.getJwtToken() },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should require authorization for audit events", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: { token: user.getJwtToken(), auditLog: true },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/events.list");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
99
server/routes/api/fileOperations.js
Normal file
99
server/routes/api/fileOperations.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import { NotFoundError, ValidationError } from "../../errors";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { FileOperation, Team } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentFileOperation } from "../../presenters";
|
||||
import { getSignedUrl } from "../../utils/s3";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("fileOperations.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
const fileOperation = await FileOperation.findByPk(id);
|
||||
|
||||
authorize(user, fileOperation.type, team);
|
||||
|
||||
if (!fileOperation) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: presentFileOperation(fileOperation),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("fileOperations.list", auth(), pagination(), async (ctx) => {
|
||||
let { sort = "createdAt", direction, type } = ctx.body;
|
||||
|
||||
ctx.assertPresent(type, "type is required");
|
||||
ctx.assertIn(
|
||||
type,
|
||||
["import", "export"],
|
||||
"type must be one of 'import' or 'export'"
|
||||
);
|
||||
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
|
||||
const user = ctx.state.user;
|
||||
|
||||
const where = {
|
||||
teamId: user.teamId,
|
||||
type,
|
||||
};
|
||||
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, type, team);
|
||||
|
||||
const [exports, total] = await Promise.all([
|
||||
await FileOperation.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
await FileOperation.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
pagination: {
|
||||
...ctx.state.pagination,
|
||||
total,
|
||||
},
|
||||
data: exports.map(presentFileOperation),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("fileOperations.redirect", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
const fileOp = await FileOperation.unscoped().findByPk(id);
|
||||
|
||||
if (!fileOp) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
authorize(user, fileOp.type, team);
|
||||
|
||||
if (fileOp.state !== "complete") {
|
||||
throw new ValidationError("file operation is not complete yet");
|
||||
}
|
||||
|
||||
const accessUrl = await getSignedUrl(fileOp.key);
|
||||
|
||||
ctx.redirect(accessUrl);
|
||||
});
|
||||
|
||||
export default router;
|
||||
283
server/routes/api/fileOperations.test.js
Normal file
283
server/routes/api/fileOperations.test.js
Normal file
@@ -0,0 +1,283 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
|
||||
import { Collection, User } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import {
|
||||
buildAdmin,
|
||||
buildCollection,
|
||||
buildFileOperation,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "../../test/factories";
|
||||
import { flushdb } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#fileOperations.info", () => {
|
||||
it("should return fileOperation", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/fileOperations.info", {
|
||||
body: {
|
||||
id: exportData.id,
|
||||
token: admin.getJwtToken(),
|
||||
type: "export",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(exportData.id);
|
||||
expect(body.data.state).toBe(exportData.state);
|
||||
});
|
||||
|
||||
it("should require user to be an admin", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/fileOperations.info", {
|
||||
body: {
|
||||
id: exportData.id,
|
||||
token: user.getJwtToken(),
|
||||
type: "export",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#fileOperations.list", () => {
|
||||
it("should return fileOperations list", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/fileOperations.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: "export",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
const data = body.data[0];
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toBe(1);
|
||||
expect(data.id).toBe(exportData.id);
|
||||
expect(data.key).toBe(undefined);
|
||||
expect(data.state).toBe(exportData.state);
|
||||
});
|
||||
|
||||
it("should return exports with collection data", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: admin.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/fileOperations.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: "export",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
const data = body.data[0];
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toBe(1);
|
||||
expect(data.id).toBe(exportData.id);
|
||||
expect(data.key).toBe(undefined);
|
||||
expect(data.state).toBe(exportData.state);
|
||||
expect(data.collection.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
it("should return exports with collection data even if collection is deleted", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: admin.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
await collection.destroy();
|
||||
|
||||
const isCollectionPresent = await Collection.findByPk(collection.id);
|
||||
|
||||
expect(isCollectionPresent).toBe(null);
|
||||
|
||||
const res = await server.post("/api/fileOperations.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
type: "export",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
const data = body.data[0];
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toBe(1);
|
||||
expect(data.id).toBe(exportData.id);
|
||||
expect(data.key).toBe(undefined);
|
||||
expect(data.state).toBe(exportData.state);
|
||||
expect(data.collection.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
it("should return exports with user data even if user is deleted", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin2 = await buildAdmin({ teamId: team.id });
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: admin.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
await admin.destroy();
|
||||
|
||||
const isAdminPresent = await User.findByPk(admin.id);
|
||||
|
||||
expect(isAdminPresent).toBe(null);
|
||||
|
||||
const res = await server.post("/api/fileOperations.list", {
|
||||
body: {
|
||||
token: admin2.getJwtToken(),
|
||||
type: "export",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
const data = body.data[0];
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toBe(1);
|
||||
expect(data.id).toBe(exportData.id);
|
||||
expect(data.key).toBe(undefined);
|
||||
expect(data.state).toBe(exportData.state);
|
||||
expect(data.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/api/fileOperations.list", {
|
||||
body: { token: user.getJwtToken(), type: "export" },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#fileOperations.redirect", () => {
|
||||
it("should not redirect when file operation is not complete", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/fileOperations.redirect", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: exportData.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toEqual("file operation is not complete yet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#fileOperations.info", () => {
|
||||
it("should return file operation", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/fileOperations.info", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: exportData.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.data.id).toBe(exportData.id);
|
||||
expect(body.data.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const exportData = await buildFileOperation({
|
||||
type: "export",
|
||||
teamId: team.id,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/fileOperations.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: exportData.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
287
server/routes/api/groups.js
Normal file
287
server/routes/api/groups.js
Normal file
@@ -0,0 +1,287 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import { MAX_AVATAR_DISPLAY } from "../../../shared/constants";
|
||||
import auth from "../../middlewares/authentication";
|
||||
|
||||
import { User, Event, Group, GroupUser } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import {
|
||||
presentGroup,
|
||||
presentPolicies,
|
||||
presentUser,
|
||||
presentGroupMembership,
|
||||
} from "../../presenters";
|
||||
import { Op } from "../../sequelize";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("groups.list", auth(), pagination(), async (ctx) => {
|
||||
let { sort = "updatedAt", direction } = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, Group);
|
||||
|
||||
const user = ctx.state.user;
|
||||
|
||||
let groups = await Group.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
},
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
if (!user.isAdmin) {
|
||||
groups = groups.filter(
|
||||
(group) =>
|
||||
group.groupMemberships.filter((gm) => gm.userId === user.id).length
|
||||
);
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: {
|
||||
groups: groups.map(presentGroup),
|
||||
groupMemberships: groups
|
||||
.map((g) =>
|
||||
g.groupMemberships
|
||||
.filter((membership) => !!membership.user)
|
||||
.slice(0, MAX_AVATAR_DISPLAY)
|
||||
)
|
||||
.flat()
|
||||
.map(presentGroupMembership),
|
||||
},
|
||||
policies: presentPolicies(user, groups),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("groups.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const group = await Group.findByPk(id);
|
||||
authorize(user, "read", group);
|
||||
|
||||
ctx.body = {
|
||||
data: presentGroup(group),
|
||||
policies: presentPolicies(user, [group]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("groups.create", auth(), async (ctx) => {
|
||||
const { name } = ctx.body;
|
||||
ctx.assertPresent(name, "name is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
|
||||
authorize(user, "createGroup", user.team);
|
||||
let group = await Group.create({
|
||||
name,
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
// reload to get default scope
|
||||
group = await Group.findByPk(group.id);
|
||||
|
||||
await Event.create({
|
||||
name: "groups.create",
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
modelId: group.id,
|
||||
data: { name: group.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentGroup(group),
|
||||
policies: presentPolicies(user, [group]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("groups.update", auth(), async (ctx) => {
|
||||
const { id, name } = ctx.body;
|
||||
ctx.assertPresent(name, "name is required");
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const group = await Group.findByPk(id);
|
||||
|
||||
authorize(user, "update", group);
|
||||
|
||||
group.name = name;
|
||||
|
||||
if (group.changed()) {
|
||||
await group.save();
|
||||
await Event.create({
|
||||
name: "groups.update",
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
modelId: group.id,
|
||||
data: { name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: presentGroup(group),
|
||||
policies: presentPolicies(user, [group]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("groups.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const group = await Group.findByPk(id);
|
||||
|
||||
authorize(user, "delete", group);
|
||||
await group.destroy();
|
||||
|
||||
await Event.create({
|
||||
name: "groups.delete",
|
||||
actorId: user.id,
|
||||
modelId: group.id,
|
||||
teamId: group.teamId,
|
||||
data: { name: group.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("groups.memberships", auth(), pagination(), async (ctx) => {
|
||||
const { id, query } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const group = await Group.findByPk(id);
|
||||
authorize(user, "read", group);
|
||||
|
||||
let userWhere;
|
||||
if (query) {
|
||||
userWhere = {
|
||||
name: {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const memberships = await GroupUser.findAll({
|
||||
where: { groupId: id },
|
||||
order: [["createdAt", "DESC"]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "user",
|
||||
where: userWhere,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: {
|
||||
groupMemberships: memberships.map(presentGroupMembership),
|
||||
users: memberships.map((membership) => presentUser(membership.user)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("groups.add_user", auth(), async (ctx) => {
|
||||
const { id, userId } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(userId, "userId is required");
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(ctx.state.user, "read", user);
|
||||
|
||||
let group = await Group.findByPk(id);
|
||||
authorize(ctx.state.user, "update", group);
|
||||
|
||||
let membership = await GroupUser.findOne({
|
||||
where: {
|
||||
groupId: id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
await group.addUser(user, {
|
||||
through: { createdById: ctx.state.user.id },
|
||||
});
|
||||
|
||||
// reload to get default scope
|
||||
membership = await GroupUser.findOne({
|
||||
where: {
|
||||
groupId: id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// reload to get default scope
|
||||
group = await Group.findByPk(id);
|
||||
|
||||
await Event.create({
|
||||
name: "groups.add_user",
|
||||
userId,
|
||||
teamId: user.teamId,
|
||||
modelId: group.id,
|
||||
actorId: ctx.state.user.id,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
users: [presentUser(user)],
|
||||
groupMemberships: [presentGroupMembership(membership)],
|
||||
groups: [presentGroup(group)],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("groups.remove_user", auth(), async (ctx) => {
|
||||
const { id, userId } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(userId, "userId is required");
|
||||
|
||||
let group = await Group.findByPk(id);
|
||||
authorize(ctx.state.user, "update", group);
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(ctx.state.user, "read", user);
|
||||
|
||||
await group.removeUser(user);
|
||||
|
||||
await Event.create({
|
||||
name: "groups.remove_user",
|
||||
userId,
|
||||
modelId: group.id,
|
||||
teamId: user.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
// reload to get default scope
|
||||
group = await Group.findByPk(id);
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
groups: [presentGroup(group)],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
491
server/routes/api/groups.test.js
Normal file
491
server/routes/api/groups.test.js
Normal file
@@ -0,0 +1,491 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import { Event } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import { buildUser, buildAdmin, buildGroup } from "../../test/factories";
|
||||
import { flushdb } from "../../test/support";
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#groups.create", () => {
|
||||
it("should create a group", async () => {
|
||||
const name = "hello I am a group";
|
||||
const user = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/groups.create", {
|
||||
body: { token: user.getJwtToken(), name },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual(name);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.update", () => {
|
||||
it("should require authentication", async () => {
|
||||
const group = await buildGroup();
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: { id: group.id, name: "Test" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const group = await buildGroup();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: { token: user.getJwtToken(), id: group.id, name: "Test" },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const group = await buildGroup();
|
||||
const user = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: { token: user.getJwtToken(), id: group.id, name: "Test" },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
describe("when user is admin", () => {
|
||||
let user, group;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await buildAdmin();
|
||||
group = await buildGroup({ teamId: user.teamId });
|
||||
});
|
||||
|
||||
it("allows admin to edit a group", async () => {
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: { token: user.getJwtToken(), id: group.id, name: "Test" },
|
||||
});
|
||||
|
||||
const events = await Event.findAll();
|
||||
expect(events.length).toEqual(1);
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe("Test");
|
||||
});
|
||||
|
||||
it("does not create an event if the update is a noop", async () => {
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: { token: user.getJwtToken(), id: group.id, name: group.name },
|
||||
});
|
||||
|
||||
const events = await Event.findAll();
|
||||
expect(events.length).toEqual(0);
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe(group.name);
|
||||
});
|
||||
|
||||
it("fails with validation error when name already taken", async () => {
|
||||
await buildGroup({
|
||||
teamId: user.teamId,
|
||||
name: "test",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
name: "TEST",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.list", () => {
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/groups.list");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should return groups with memberships preloaded", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
await group.addUser(user, { through: { createdById: user.id } });
|
||||
|
||||
const res = await server.post("/api/groups.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(body.data["groups"].length).toEqual(1);
|
||||
expect(body.data["groups"][0].id).toEqual(group.id);
|
||||
|
||||
expect(body.data["groupMemberships"].length).toEqual(1);
|
||||
expect(body.data["groupMemberships"][0].groupId).toEqual(group.id);
|
||||
expect(body.data["groupMemberships"][0].user.id).toEqual(user.id);
|
||||
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return groups when membership user is deleted", async () => {
|
||||
const me = await buildUser();
|
||||
const user = await buildUser({ teamId: me.teamId });
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
await group.addUser(user, { through: { createdById: me.id } });
|
||||
await group.addUser(me, { through: { createdById: me.id } });
|
||||
await user.destroy();
|
||||
|
||||
const res = await server.post("/api/groups.list", {
|
||||
body: { token: me.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(body.data["groups"].length).toEqual(1);
|
||||
expect(body.data["groups"][0].id).toEqual(group.id);
|
||||
|
||||
expect(body.data["groupMemberships"].length).toEqual(1);
|
||||
expect(body.data["groupMemberships"][0].groupId).toEqual(group.id);
|
||||
expect(body.data["groupMemberships"][0].user.id).toEqual(me.id);
|
||||
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.info", () => {
|
||||
it("should return group if admin", async () => {
|
||||
const user = await buildAdmin();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
const res = await server.post("/api/groups.info", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(group.id);
|
||||
});
|
||||
|
||||
it("should return group if member", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
await group.addUser(user, { through: { createdById: user.id } });
|
||||
|
||||
const res = await server.post("/api/groups.info", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(group.id);
|
||||
});
|
||||
|
||||
it("should not return group if non-member, non-admin", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
const res = await server.post("/api/groups.info", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/groups.info");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup();
|
||||
const res = await server.post("/api/groups.info", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.delete", () => {
|
||||
it("should require authentication", async () => {
|
||||
const group = await buildGroup();
|
||||
const res = await server.post("/api/groups.delete", {
|
||||
body: { id: group.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const group = await buildGroup();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/groups.delete", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const group = await buildGroup();
|
||||
const user = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/groups.delete", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("allows admin to delete a group", async () => {
|
||||
const user = await buildAdmin();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
const res = await server.post("/api/groups.delete", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.memberships", () => {
|
||||
it("should return members in a group", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
await group.addUser(user, { through: { createdById: user.id } });
|
||||
|
||||
const res = await server.post("/api/groups.memberships", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.users.length).toEqual(1);
|
||||
expect(body.data.users[0].id).toEqual(user.id);
|
||||
expect(body.data.groupMemberships.length).toEqual(1);
|
||||
expect(body.data.groupMemberships[0].user.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it("should allow filtering members in group by name", async () => {
|
||||
const user = await buildUser();
|
||||
const user2 = await buildUser({ name: "Won't find" });
|
||||
const user3 = await buildUser({ teamId: user.teamId, name: "Deleted" });
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
|
||||
await group.addUser(user, { through: { createdById: user.id } });
|
||||
await group.addUser(user2, { through: { createdById: user.id } });
|
||||
await group.addUser(user3, { through: { createdById: user.id } });
|
||||
|
||||
await user3.destroy();
|
||||
|
||||
const res = await server.post("/api/groups.memberships", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
query: user.name.slice(0, 3),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.users.length).toEqual(1);
|
||||
expect(body.data.users[0].id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/groups.memberships");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup();
|
||||
|
||||
const res = await server.post("/api/groups.memberships", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.add_user", () => {
|
||||
it("should add user to group", async () => {
|
||||
const user = await buildAdmin();
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const users = await group.getUsers();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(users.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/groups.add_user");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should require user in team", async () => {
|
||||
const user = await buildAdmin();
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const anotherUser = await buildUser();
|
||||
|
||||
const res = await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const anotherUser = await buildUser({ teamId: user.teamId });
|
||||
|
||||
const res = await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#groups.remove_user", () => {
|
||||
it("should remove user from group", async () => {
|
||||
const user = await buildAdmin();
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
await server.post("/api/groups.add_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const users = await group.getUsers();
|
||||
expect(users.length).toEqual(1);
|
||||
|
||||
const res = await server.post("/api/groups.remove_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const users1 = await group.getUsers();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(users1.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/groups.remove_user");
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
|
||||
it("should require user in team", async () => {
|
||||
const user = await buildAdmin();
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const anotherUser = await buildUser();
|
||||
|
||||
const res = await server.post("/api/groups.remove_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const anotherUser = await buildUser({
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/groups.remove_user", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: group.id,
|
||||
userId: anotherUser.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
265
server/routes/api/hooks.js
Normal file
265
server/routes/api/hooks.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import { escapeRegExp } from "lodash";
|
||||
import { AuthenticationError, InvalidRequestError } from "../../errors";
|
||||
import {
|
||||
UserAuthentication,
|
||||
AuthenticationProvider,
|
||||
Document,
|
||||
User,
|
||||
Team,
|
||||
SearchQuery,
|
||||
Integration,
|
||||
IntegrationAuthentication,
|
||||
} from "../../models";
|
||||
import { presentSlackAttachment } from "../../presenters";
|
||||
import * as Slack from "../../slack";
|
||||
const router = new Router();
|
||||
|
||||
// triggered by a user posting a getoutline.com link in Slack
|
||||
router.post("hooks.unfurl", async (ctx) => {
|
||||
const { challenge, token, event } = ctx.body;
|
||||
if (challenge) return (ctx.body = ctx.body.challenge);
|
||||
|
||||
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
|
||||
throw new AuthenticationError("Invalid token");
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
include: [
|
||||
{
|
||||
where: { providerId: event.user },
|
||||
model: UserAuthentication,
|
||||
as: "authentications",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!user) return;
|
||||
|
||||
const auth = await IntegrationAuthentication.findOne({
|
||||
where: { service: "slack", teamId: user.teamId },
|
||||
});
|
||||
if (!auth) return;
|
||||
|
||||
// get content for unfurled links
|
||||
let unfurls = {};
|
||||
for (let link of event.links) {
|
||||
const id = link.url.substr(link.url.lastIndexOf("/") + 1);
|
||||
const doc = await Document.findByPk(id);
|
||||
if (!doc || doc.teamId !== user.teamId) continue;
|
||||
|
||||
unfurls[link.url] = {
|
||||
title: doc.title,
|
||||
text: doc.getSummary(),
|
||||
color: doc.collection.color,
|
||||
};
|
||||
}
|
||||
|
||||
await Slack.post("chat.unfurl", {
|
||||
token: auth.token,
|
||||
channel: event.channel,
|
||||
ts: event.message_ts,
|
||||
unfurls,
|
||||
});
|
||||
});
|
||||
|
||||
// triggered by interactions with actions, dialogs, message buttons in Slack
|
||||
router.post("hooks.interactive", async (ctx) => {
|
||||
const { payload } = ctx.body;
|
||||
ctx.assertPresent(payload, "payload is required");
|
||||
|
||||
const data = JSON.parse(payload);
|
||||
const { callback_id, token } = data;
|
||||
ctx.assertPresent(token, "token is required");
|
||||
ctx.assertPresent(callback_id, "callback_id is required");
|
||||
|
||||
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
|
||||
throw new AuthenticationError("Invalid verification token");
|
||||
}
|
||||
|
||||
// we find the document based on the users teamId to ensure access
|
||||
const document = await Document.scope("withCollection").findByPk(
|
||||
data.callback_id
|
||||
);
|
||||
if (!document) {
|
||||
throw new InvalidRequestError("Invalid callback_id");
|
||||
}
|
||||
|
||||
const team = await Team.findByPk(document.teamId);
|
||||
|
||||
// respond with a public message that will be posted in the original channel
|
||||
ctx.body = {
|
||||
response_type: "in_channel",
|
||||
replace_original: false,
|
||||
attachments: [
|
||||
presentSlackAttachment(
|
||||
document,
|
||||
document.collection,
|
||||
team,
|
||||
document.getSummary()
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// triggered by the /outline command in Slack
|
||||
router.post("hooks.slack", async (ctx) => {
|
||||
const { token, team_id, user_id, text = "" } = ctx.body;
|
||||
ctx.assertPresent(token, "token is required");
|
||||
ctx.assertPresent(team_id, "team_id is required");
|
||||
ctx.assertPresent(user_id, "user_id is required");
|
||||
|
||||
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
|
||||
throw new AuthenticationError("Invalid verification token");
|
||||
}
|
||||
|
||||
// Handle "help" command or no input
|
||||
if (text.trim() === "help" || !text.trim()) {
|
||||
ctx.body = {
|
||||
response_type: "ephemeral",
|
||||
text: "How to use /outline",
|
||||
attachments: [
|
||||
{
|
||||
text:
|
||||
"To search your knowledge base use `/outline keyword`. \nYou’ve already learned how to get help with `/outline help`.",
|
||||
},
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let user, team;
|
||||
|
||||
// attempt to find the corresponding team for this request based on the team_id
|
||||
team = await Team.findOne({
|
||||
include: [
|
||||
{
|
||||
where: {
|
||||
name: "slack",
|
||||
providerId: team_id,
|
||||
},
|
||||
as: "authenticationProviders",
|
||||
model: AuthenticationProvider,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (team) {
|
||||
const authentication = await UserAuthentication.findOne({
|
||||
where: {
|
||||
providerId: user_id,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
where: { teamId: team.id },
|
||||
model: User,
|
||||
as: "user",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (authentication) {
|
||||
user = authentication.user;
|
||||
}
|
||||
} else {
|
||||
// If we couldn't find a team it's still possible that the request is from
|
||||
// a team that authenticated with a different service, but connected Slack
|
||||
// via integration
|
||||
const integration = await Integration.findOne({
|
||||
where: {
|
||||
settings: {
|
||||
serviceTeamId: team_id,
|
||||
},
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (integration) {
|
||||
team = integration.team;
|
||||
}
|
||||
}
|
||||
|
||||
// This should be super rare, how does someone end up being able to make a valid
|
||||
// request from Slack that connects to no teams in Outline.
|
||||
if (!team) {
|
||||
ctx.body = {
|
||||
response_type: "ephemeral",
|
||||
text:
|
||||
"Sorry, we couldn’t find an integration for your team. Head to your Outline settings to set one up.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
limit: 5,
|
||||
};
|
||||
|
||||
// If we were able to map the request to a user then we can use their permissions
|
||||
// to load more documents based on the collections they have access to. Otherwise
|
||||
// just a generic search against team-visible documents is allowed.
|
||||
const { results, totalCount } = user
|
||||
? await Document.searchForUser(user, text, options)
|
||||
: await Document.searchForTeam(team, text, options);
|
||||
|
||||
SearchQuery.create({
|
||||
userId: user ? user.id : null,
|
||||
teamId: team.id,
|
||||
source: "slack",
|
||||
query: text,
|
||||
results: totalCount,
|
||||
});
|
||||
|
||||
const haventSignedIn = `(It looks like you haven’t signed in to Outline yet, so results may be limited)`;
|
||||
|
||||
// Map search results to the format expected by the Slack API
|
||||
if (results.length) {
|
||||
const attachments = [];
|
||||
for (const result of results) {
|
||||
const queryIsInTitle = !!result.document.title
|
||||
.toLowerCase()
|
||||
.match(escapeRegExp(text.toLowerCase()));
|
||||
|
||||
attachments.push(
|
||||
presentSlackAttachment(
|
||||
result.document,
|
||||
result.document.collection,
|
||||
team,
|
||||
queryIsInTitle ? undefined : result.context,
|
||||
process.env.SLACK_MESSAGE_ACTIONS
|
||||
? [
|
||||
{
|
||||
name: "post",
|
||||
text: "Post to Channel",
|
||||
type: "button",
|
||||
value: result.document.id,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
text: user
|
||||
? `This is what we found for "${text}"…`
|
||||
: `This is what we found for "${text}" ${haventSignedIn}…`,
|
||||
attachments,
|
||||
};
|
||||
} else {
|
||||
ctx.body = {
|
||||
text: user
|
||||
? `No results for "${text}"`
|
||||
: `No results for "${text}" ${haventSignedIn}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
334
server/routes/api/hooks.test.js
Normal file
334
server/routes/api/hooks.test.js
Normal file
@@ -0,0 +1,334 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import { IntegrationAuthentication, SearchQuery } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import * as Slack from "../../slack";
|
||||
import { buildDocument, buildIntegration } from "../../test/factories";
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
jest.mock("../../slack", () => ({
|
||||
post: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("#hooks.unfurl", () => {
|
||||
it("should return documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
await IntegrationAuthentication.create({
|
||||
service: "slack",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
token: "",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/hooks.unfurl", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
team_id: "TXXXXXXXX",
|
||||
api_app_id: "AXXXXXXXXX",
|
||||
event: {
|
||||
type: "link_shared",
|
||||
channel: "Cxxxxxx",
|
||||
user: user.authentications[0].providerId,
|
||||
message_ts: "123456789.9875",
|
||||
links: [
|
||||
{
|
||||
domain: "getoutline.com",
|
||||
url: document.url,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(Slack.post).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#hooks.slack", () => {
|
||||
it("should return no matches", async () => {
|
||||
const { user, team } = await seed();
|
||||
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "dsfkndfskndsfkn",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("should return search results with summary if query is in title", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(document.getSummary());
|
||||
});
|
||||
|
||||
it("should return search results if query is regex-like", async () => {
|
||||
const { user, team } = await seed();
|
||||
await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "*contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return search results with snippet if query is in text", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(
|
||||
"This title *contains* a search term"
|
||||
);
|
||||
});
|
||||
|
||||
it("should save search term, hits and source", async (done) => {
|
||||
const { user, team } = await seed();
|
||||
await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
|
||||
// setTimeout is needed here because SearchQuery is saved asynchronously
|
||||
// in order to not slow down the response time.
|
||||
setTimeout(async () => {
|
||||
const searchQuery = await SearchQuery.findAll({
|
||||
where: { query: "contains" },
|
||||
});
|
||||
expect(searchQuery.length).toBe(1);
|
||||
expect(searchQuery[0].results).toBe(0);
|
||||
expect(searchQuery[0].source).toBe("slack");
|
||||
done();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
it("should respond with help content for help keyword", async () => {
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "help",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text.includes("How to use")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should respond with help content for no keyword", async () => {
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text.includes("How to use")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return search results with snippet for unknown user", async () => {
|
||||
const { user, team } = await seed();
|
||||
|
||||
// unpublished document will not be returned
|
||||
await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
publishedAt: null,
|
||||
});
|
||||
|
||||
const document = await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: "unknown-slack-user-id",
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text).toContain("you haven’t signed in to Outline yet");
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(
|
||||
"This title *contains* a search term"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return search results with snippet for user through integration mapping", async () => {
|
||||
const { user } = await seed();
|
||||
const serviceTeamId = "slack_team_id";
|
||||
|
||||
await buildIntegration({
|
||||
teamId: user.teamId,
|
||||
settings: {
|
||||
serviceTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await buildDocument({
|
||||
text: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: "unknown-slack-user-id",
|
||||
team_id: serviceTeamId,
|
||||
text: "contains",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text).toContain("you haven’t signed in to Outline yet");
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
expect(body.attachments[0].text).toEqual(
|
||||
"This title *contains* a search term"
|
||||
);
|
||||
});
|
||||
|
||||
it("should error if incorrect verification token", async () => {
|
||||
const { user, team } = await seed();
|
||||
|
||||
const res = await server.post("/api/hooks.slack", {
|
||||
body: {
|
||||
token: "wrong-verification-token",
|
||||
user_id: user.authentications[0].providerId,
|
||||
team_id: team.authenticationProviders[0].providerId,
|
||||
text: "Welcome",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#hooks.interactive", () => {
|
||||
it("should respond with replacement message", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user: { id: user.authentications[0].providerId },
|
||||
team: { id: team.authenticationProviders[0].providerId },
|
||||
callback_id: document.id,
|
||||
});
|
||||
const res = await server.post("/api/hooks.interactive", {
|
||||
body: { payload },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.response_type).toEqual("in_channel");
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
});
|
||||
|
||||
it("should respond with replacement message if unknown user", async () => {
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: "This title contains a search term",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user: { id: "unknown-slack-user-id" },
|
||||
team: { id: team.authenticationProviders[0].providerId },
|
||||
callback_id: document.id,
|
||||
});
|
||||
const res = await server.post("/api/hooks.interactive", {
|
||||
body: { payload },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.response_type).toEqual("in_channel");
|
||||
expect(body.attachments.length).toEqual(1);
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
});
|
||||
|
||||
it("should error if incorrect verification token", async () => {
|
||||
const { user } = await seed();
|
||||
const payload = JSON.stringify({
|
||||
token: "wrong-verification-token",
|
||||
user: { id: user.authentications[0].providerId, name: user.name },
|
||||
callback_id: "doesnt-matter",
|
||||
});
|
||||
const res = await server.post("/api/hooks.interactive", {
|
||||
body: { payload },
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
76
server/routes/api/index.js
Normal file
76
server/routes/api/index.js
Normal file
@@ -0,0 +1,76 @@
|
||||
// @flow
|
||||
import Koa from "koa";
|
||||
import bodyParser from "koa-body";
|
||||
import Router from "koa-router";
|
||||
|
||||
import { NotFoundError } from "../../errors";
|
||||
import errorHandling from "../../middlewares/errorHandling";
|
||||
import methodOverride from "../../middlewares/methodOverride";
|
||||
import validation from "../../middlewares/validation";
|
||||
import apiKeys from "./apiKeys";
|
||||
import attachments from "./attachments";
|
||||
import auth from "./auth";
|
||||
import authenticationProviders from "./authenticationProviders";
|
||||
import collections from "./collections";
|
||||
import documents from "./documents";
|
||||
import events from "./events";
|
||||
import fileOperationsRoute from "./fileOperations";
|
||||
import groups from "./groups";
|
||||
import hooks from "./hooks";
|
||||
import integrations from "./integrations";
|
||||
|
||||
import apiWrapper from "./middlewares/apiWrapper";
|
||||
import editor from "./middlewares/editor";
|
||||
import notificationSettings from "./notificationSettings";
|
||||
import revisions from "./revisions";
|
||||
import shares from "./shares";
|
||||
import team from "./team";
|
||||
import users from "./users";
|
||||
import utils from "./utils";
|
||||
import views from "./views";
|
||||
|
||||
const api = new Koa();
|
||||
const router = new Router();
|
||||
|
||||
// middlewares
|
||||
api.use(errorHandling());
|
||||
api.use(
|
||||
bodyParser({
|
||||
multipart: true,
|
||||
formidable: { maxFieldsSize: 10 * 1024 * 1024 },
|
||||
})
|
||||
);
|
||||
api.use(methodOverride());
|
||||
api.use(validation());
|
||||
api.use(apiWrapper());
|
||||
api.use(editor());
|
||||
|
||||
// routes
|
||||
router.use("/", auth.routes());
|
||||
router.use("/", authenticationProviders.routes());
|
||||
router.use("/", events.routes());
|
||||
router.use("/", users.routes());
|
||||
router.use("/", collections.routes());
|
||||
router.use("/", documents.routes());
|
||||
router.use("/", revisions.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());
|
||||
router.use("/", notificationSettings.routes());
|
||||
router.use("/", attachments.routes());
|
||||
router.use("/", utils.routes());
|
||||
router.use("/", groups.routes());
|
||||
router.use("/", fileOperationsRoute.routes());
|
||||
|
||||
router.post("*", (ctx) => {
|
||||
ctx.throw(new NotFoundError("Endpoint not found"));
|
||||
});
|
||||
|
||||
// Router is embedded in a Koa application wrapper, because koa-router does not
|
||||
// allow middleware to catch any routes which were not explicitly defined.
|
||||
api.use(router.routes());
|
||||
|
||||
export default api;
|
||||
23
server/routes/api/index.test.js
Normal file
23
server/routes/api/index.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import webService from "../../services/web";
|
||||
import { flushdb } from "../../test/support";
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("POST unknown endpoint", () => {
|
||||
it("should be not found", async () => {
|
||||
const res = await server.post("/api/blah");
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET unknown endpoint", () => {
|
||||
it("should be not found", async () => {
|
||||
const res = await server.get("/api/blah");
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
});
|
||||
55
server/routes/api/integrations.js
Normal file
55
server/routes/api/integrations.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Event } from "../../models";
|
||||
import Integration from "../../models/Integration";
|
||||
import policy from "../../policies";
|
||||
import { presentIntegration } from "../../presenters";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("integrations.list", auth(), pagination(), async (ctx) => {
|
||||
let { sort = "updatedAt", direction } = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, Integration);
|
||||
|
||||
const user = ctx.state.user;
|
||||
const integrations = await Integration.findAll({
|
||||
where: { teamId: user.teamId },
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: integrations.map(presentIntegration),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("integrations.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const integration = await Integration.findByPk(id);
|
||||
authorize(user, "delete", integration);
|
||||
|
||||
await integration.destroy();
|
||||
|
||||
await Event.create({
|
||||
name: "integrations.delete",
|
||||
modelId: integration.id,
|
||||
teamId: integration.teamId,
|
||||
actorId: user.id,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
27
server/routes/api/middlewares/apiWrapper.js
Normal file
27
server/routes/api/middlewares/apiWrapper.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// @flow
|
||||
import stream from "stream";
|
||||
import { type Context } from "koa";
|
||||
|
||||
export default function apiWrapper() {
|
||||
return async function apiWrapperMiddleware(
|
||||
ctx: Context,
|
||||
next: () => Promise<*>
|
||||
) {
|
||||
await next();
|
||||
|
||||
const ok = ctx.status < 400;
|
||||
|
||||
if (
|
||||
typeof ctx.body !== "string" &&
|
||||
!(ctx.body instanceof stream.Readable)
|
||||
) {
|
||||
// $FlowFixMe
|
||||
ctx.body = {
|
||||
// $FlowFixMe
|
||||
...ctx.body,
|
||||
status: ctx.status,
|
||||
ok,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
27
server/routes/api/middlewares/editor.js
Normal file
27
server/routes/api/middlewares/editor.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// @flow
|
||||
import { type Context } from "koa";
|
||||
import pkg from "rich-markdown-editor/package.json";
|
||||
import semver from "semver";
|
||||
import { EditorUpdateError } from "../../../errors";
|
||||
|
||||
export default function editor() {
|
||||
return async function editorMiddleware(ctx: Context, next: () => Promise<*>) {
|
||||
const clientVersion = ctx.headers["x-editor-version"];
|
||||
|
||||
// If the editor version on the client is behind the current version being
|
||||
// served in production by either a minor (new features), or major (breaking
|
||||
// changes) then force a client reload.
|
||||
if (clientVersion) {
|
||||
const parsedClientVersion = semver.parse(clientVersion);
|
||||
const parsedCurrentVersion = semver.parse(pkg.version);
|
||||
|
||||
if (
|
||||
parsedClientVersion.major < parsedCurrentVersion.major ||
|
||||
parsedClientVersion.minor < parsedCurrentVersion.minor
|
||||
) {
|
||||
throw new EditorUpdateError();
|
||||
}
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
70
server/routes/api/middlewares/pagination.js
Normal file
70
server/routes/api/middlewares/pagination.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// @flow
|
||||
import querystring from "querystring";
|
||||
import { type Context } from "koa";
|
||||
import { InvalidRequestError } from "../../../errors";
|
||||
|
||||
export default function pagination(options?: Object) {
|
||||
return async function paginationMiddleware(
|
||||
ctx: Context,
|
||||
next: () => Promise<*>
|
||||
) {
|
||||
const opts = {
|
||||
defaultLimit: 15,
|
||||
defaultOffset: 0,
|
||||
maxLimit: 100,
|
||||
...options,
|
||||
};
|
||||
|
||||
let query = ctx.request.query;
|
||||
let body = ctx.request.body;
|
||||
|
||||
// $FlowFixMe
|
||||
let limit = query.limit || body.limit;
|
||||
// $FlowFixMe
|
||||
let offset = query.offset || body.offset;
|
||||
|
||||
if (limit && isNaN(limit)) {
|
||||
throw new InvalidRequestError(`Pagination limit must be a valid number`);
|
||||
}
|
||||
if (offset && isNaN(offset)) {
|
||||
throw new InvalidRequestError(`Pagination offset must be a valid number`);
|
||||
}
|
||||
|
||||
limit = parseInt(limit || opts.defaultLimit, 10);
|
||||
offset = parseInt(offset || opts.defaultOffset, 10);
|
||||
|
||||
if (limit > opts.maxLimit) {
|
||||
throw new InvalidRequestError(
|
||||
`Pagination limit is too large (max ${opts.maxLimit})`
|
||||
);
|
||||
}
|
||||
if (limit <= 0) {
|
||||
throw new InvalidRequestError(`Pagination limit must be greater than 0`);
|
||||
}
|
||||
if (offset < 0) {
|
||||
throw new InvalidRequestError(
|
||||
`Pagination offset must be greater than or equal to 0`
|
||||
);
|
||||
}
|
||||
|
||||
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
|
||||
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
|
||||
ctx.state.pagination = {
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
|
||||
// $FlowFixMe
|
||||
query.limit = ctx.state.pagination.limit;
|
||||
// $FlowFixMe
|
||||
query.offset = ctx.state.pagination.offset + query.limit;
|
||||
|
||||
/* $FlowFixMeNowPlease This comment suppresses an error found when upgrading
|
||||
* flow-bin@0.104.0. To view the error, delete this comment and run Flow. */
|
||||
ctx.state.pagination.nextPath = `/api${
|
||||
ctx.request.path
|
||||
}?${querystring.stringify(query)}`;
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
||||
56
server/routes/api/middlewares/pagination.test.js
Normal file
56
server/routes/api/middlewares/pagination.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import webService from "../../../services/web";
|
||||
import { flushdb, seed } from "../../../test/support";
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#pagination", () => {
|
||||
it("should allow offset and limit", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: { token: user.getJwtToken(), limit: 1, offset: 1 },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it("should not allow negative limit", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: { token: user.getJwtToken(), limit: -1 },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should not allow non-integer limit", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: { token: user.getJwtToken(), limit: "blah" },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should not allow negative offset", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: { token: user.getJwtToken(), offset: -1 },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should not allow non-integer offset", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: { token: user.getJwtToken(), offset: "blah" },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
84
server/routes/api/notificationSettings.js
Normal file
84
server/routes/api/notificationSettings.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Team, NotificationSetting } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentNotificationSetting } from "../../presenters";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("notificationSettings.create", auth(), async (ctx) => {
|
||||
const { event } = ctx.body;
|
||||
ctx.assertPresent(event, "event is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
authorize(user, "createNotificationSetting", user.team);
|
||||
|
||||
const [setting] = await NotificationSetting.findOrCreate({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
event,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentNotificationSetting(setting),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("notificationSettings.list", auth(), async (ctx) => {
|
||||
const user = ctx.state.user;
|
||||
const settings = await NotificationSetting.findAll({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: settings.map(presentNotificationSetting),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("notificationSettings.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const setting = await NotificationSetting.findByPk(id);
|
||||
authorize(user, "delete", setting);
|
||||
|
||||
await setting.destroy();
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
router.post("notificationSettings.unsubscribe", async (ctx) => {
|
||||
const { id, token } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertPresent(token, "token is required");
|
||||
|
||||
const setting = await NotificationSetting.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
required: true,
|
||||
as: "team",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (setting && setting.unsubscribeToken === token) {
|
||||
await setting.destroy();
|
||||
ctx.redirect(`${setting.team.url}/settings/notifications?success`);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.redirect(`${process.env.URL}?notice=invalid-auth`);
|
||||
});
|
||||
|
||||
export default router;
|
||||
61
server/routes/api/revisions.js
Normal file
61
server/routes/api/revisions.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import { NotFoundError } from "../../errors";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Document, Revision } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentRevision } from "../../presenters";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("revisions.info", auth(), async (ctx) => {
|
||||
let { id } = ctx.body;
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const revision = await Revision.findByPk(id);
|
||||
if (!revision) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
const document = await Document.findByPk(revision.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: await presentRevision(revision),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("revisions.list", auth(), pagination(), async (ctx) => {
|
||||
let { documentId, sort = "updatedAt", direction } = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, Revision);
|
||||
ctx.assertPresent(documentId, "documentId is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
|
||||
const revisions = await Revision.findAll({
|
||||
where: { documentId: document.id },
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
revisions.map((revision) => presentRevision(revision))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
91
server/routes/api/revisions.test.js
Normal file
91
server/routes/api/revisions.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import { Revision } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import { buildDocument, buildUser } from "../../test/factories";
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#revisions.info", () => {
|
||||
it("should return a document revision", async () => {
|
||||
const { user, document } = await seed();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).not.toEqual(document.id);
|
||||
expect(body.data.title).toEqual(document.title);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const document = await buildDocument();
|
||||
const revision = await Revision.createFromDocument(document);
|
||||
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/revisions.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
id: revision.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#revisions.list", () => {
|
||||
it("should return a document's revisions", async () => {
|
||||
const { user, document } = await seed();
|
||||
await Revision.createFromDocument(document);
|
||||
|
||||
const res = await server.post("/api/revisions.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).not.toEqual(document.id);
|
||||
expect(body.data[0].title).toEqual(document.title);
|
||||
});
|
||||
|
||||
it("should not return revisions for document in collection not a member of", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
await Revision.createFromDocument(document);
|
||||
|
||||
collection.permission = null;
|
||||
await collection.save();
|
||||
|
||||
const res = await server.post("/api/revisions.list", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const document = await buildDocument();
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/revisions.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
278
server/routes/api/shares.js
Normal file
278
server/routes/api/shares.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import Sequelize from "sequelize";
|
||||
import { NotFoundError } from "../../errors";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Document, User, Event, Share, Team, Collection } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentShare, presentPolicies } from "../../presenters";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("shares.info", auth(), async (ctx) => {
|
||||
const { id, documentId, apiVersion } = ctx.body;
|
||||
ctx.assertUuid(id || documentId, "id or documentId is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
let shares = [];
|
||||
let share = await Share.scope({
|
||||
method: ["withCollection", user.id],
|
||||
}).findOne({
|
||||
where: id
|
||||
? {
|
||||
id,
|
||||
revokedAt: { [Op.eq]: null },
|
||||
}
|
||||
: {
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
revokedAt: { [Op.eq]: null },
|
||||
},
|
||||
});
|
||||
|
||||
// Deprecated API response returns just the share for the current documentId
|
||||
if (apiVersion !== 2) {
|
||||
if (!share || !share.document) {
|
||||
return (ctx.response.status = 204);
|
||||
}
|
||||
|
||||
authorize(user, "read", share);
|
||||
|
||||
ctx.body = {
|
||||
data: presentShare(share, user.isAdmin),
|
||||
policies: presentPolicies(user, [share]),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// API version 2 returns the response for the current documentId and any
|
||||
// parent documents that are publicly shared and accessible to the user
|
||||
if (share && share.document) {
|
||||
authorize(user, "read", share);
|
||||
shares.push(share);
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
const document = await Document.scope("withCollection").findByPk(
|
||||
documentId
|
||||
);
|
||||
const parentIds = document?.collection?.getDocumentParents(documentId);
|
||||
|
||||
const parentShare = parentIds
|
||||
? await Share.findOne({
|
||||
where: {
|
||||
documentId: parentIds,
|
||||
teamId: user.teamId,
|
||||
revokedAt: { [Op.eq]: null },
|
||||
includeChildDocuments: true,
|
||||
published: true,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (parentShare && parentShare.document) {
|
||||
authorize(user, "read", parentShare);
|
||||
shares.push(parentShare);
|
||||
}
|
||||
}
|
||||
|
||||
if (!shares.length) {
|
||||
return (ctx.response.status = 204);
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
shares: shares.map((share) => presentShare(share, user.isAdmin)),
|
||||
},
|
||||
policies: presentPolicies(user, shares),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("shares.list", auth(), pagination(), async (ctx) => {
|
||||
let { sort = "updatedAt", direction } = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, Share);
|
||||
|
||||
const user = ctx.state.user;
|
||||
const where = {
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
published: true,
|
||||
revokedAt: { [Op.eq]: null },
|
||||
};
|
||||
|
||||
if (user.isAdmin) {
|
||||
delete where.userId;
|
||||
}
|
||||
|
||||
const collectionIds = await user.collectionIds();
|
||||
const shares = await Share.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
required: true,
|
||||
paranoid: true,
|
||||
as: "document",
|
||||
where: {
|
||||
collectionId: collectionIds,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}),
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
required: true,
|
||||
as: "user",
|
||||
},
|
||||
{
|
||||
model: Team,
|
||||
required: true,
|
||||
as: "team",
|
||||
},
|
||||
],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: shares.map((share) => presentShare(share, user.isAdmin)),
|
||||
policies: presentPolicies(user, shares),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("shares.update", auth(), async (ctx) => {
|
||||
const { id, includeChildDocuments, published } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "share", team);
|
||||
|
||||
// fetch the share with document and collection.
|
||||
const share = await Share.scope({
|
||||
method: ["withCollection", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
authorize(user, "update", share);
|
||||
|
||||
if (published !== undefined) {
|
||||
share.published = published;
|
||||
|
||||
// Reset nested document sharing when unpublishing a share link. So that
|
||||
// If it's ever re-published this doesn't immediately share nested docs
|
||||
// without forewarning the user
|
||||
if (!published) {
|
||||
share.includeChildDocuments = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (includeChildDocuments !== undefined) {
|
||||
share.includeChildDocuments = includeChildDocuments;
|
||||
}
|
||||
|
||||
await share.save();
|
||||
|
||||
await Event.create({
|
||||
name: "shares.update",
|
||||
documentId: share.documentId,
|
||||
modelId: share.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { published },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentShare(share, user.isAdmin),
|
||||
policies: presentPolicies(user, [share]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("shares.create", auth(), async (ctx) => {
|
||||
const { documentId } = ctx.body;
|
||||
ctx.assertPresent(documentId, "documentId is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
// user could be creating the share link to share with team members
|
||||
authorize(user, "read", document);
|
||||
|
||||
const [share, isCreated] = await Share.findOrCreate({
|
||||
where: {
|
||||
documentId,
|
||||
teamId: user.teamId,
|
||||
revokedAt: null,
|
||||
},
|
||||
defaults: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (isCreated) {
|
||||
await Event.create({
|
||||
name: "shares.create",
|
||||
documentId,
|
||||
collectionId: document.collectionId,
|
||||
modelId: share.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { name: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
share.team = team;
|
||||
share.user = user;
|
||||
share.document = document;
|
||||
|
||||
ctx.body = {
|
||||
data: presentShare(share),
|
||||
policies: presentPolicies(user, [share]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("shares.revoke", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const share = await Share.findByPk(id);
|
||||
authorize(user, "revoke", share);
|
||||
|
||||
const document = await Document.findByPk(share.documentId);
|
||||
if (!document) {
|
||||
throw new NotFoundError();
|
||||
}
|
||||
|
||||
await share.revoke(user.id);
|
||||
|
||||
await Event.create({
|
||||
name: "shares.revoke",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
modelId: share.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
data: { name: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
691
server/routes/api/shares.test.js
Normal file
691
server/routes/api/shares.test.js
Normal file
@@ -0,0 +1,691 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import { CollectionUser } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import { buildUser, buildDocument, buildShare } from "../../test/factories";
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#shares.list", () => {
|
||||
it("should only return shares created by user", async () => {
|
||||
const { user, admin, document } = await seed();
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(share.id);
|
||||
expect(body.data[0].documentTitle).toBe(document.title);
|
||||
});
|
||||
|
||||
it("should not return revoked shares", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
await share.revoke(user.id);
|
||||
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not return unpublished shares", async () => {
|
||||
const { user, document } = await seed();
|
||||
await buildShare({
|
||||
published: false,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not return shares to deleted documents", async () => {
|
||||
const { user, document } = await seed();
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await document.delete(user.id);
|
||||
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("admins should return shares created by all users", async () => {
|
||||
const { user, admin, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: { token: admin.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(share.id);
|
||||
expect(body.data[0].documentTitle).toBe(document.title);
|
||||
});
|
||||
|
||||
it("admins should not return shares in collection not a member of", async () => {
|
||||
const { admin, document, collection } = await seed();
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
|
||||
collection.permission = null;
|
||||
await collection.save();
|
||||
|
||||
const res = await server.post("/api/shares.list", {
|
||||
body: { token: admin.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.published).toBe(false);
|
||||
expect(body.data.documentTitle).toBe(document.title);
|
||||
});
|
||||
|
||||
it("should allow creating a share record with read-only permissions but no publishing", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
collection.permission = null;
|
||||
|
||||
await collection.save();
|
||||
|
||||
await CollectionUser.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: "read",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
const response = await server.post("/api/shares.update", {
|
||||
body: { token: user.getJwtToken(), id: body.data.id, published: true },
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should allow creating a share record if link previously revoked", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
await share.revoke();
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).not.toEqual(share.id);
|
||||
expect(body.data.documentTitle).toBe(document.title);
|
||||
});
|
||||
|
||||
it("should return existing share link for document and user", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
});
|
||||
|
||||
it("should allow creating a share record if team sharing disabled but not publishing", async () => {
|
||||
const { user, document, team } = await seed();
|
||||
await team.update({ sharing: false });
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
const response = await server.post("/api/shares.update", {
|
||||
body: { token: user.getJwtToken(), id: body.data.id, published: true },
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should allow creating a share record if collection sharing disabled but not publishing", async () => {
|
||||
const { user, collection, document } = await seed();
|
||||
await collection.update({ sharing: false });
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
const response = await server.post("/api/shares.update", {
|
||||
body: { token: user.getJwtToken(), id: body.data.id, published: true },
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const { document } = await seed();
|
||||
const res = await server.post("/api/shares.create", {
|
||||
body: { documentId: 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(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.info", () => {
|
||||
it("should allow reading share by id", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.createdBy.id).toBe(user.id);
|
||||
});
|
||||
|
||||
it("should allow reading share created by deleted user", async () => {
|
||||
const { user, document } = await seed();
|
||||
const author = await buildUser({ teamId: user.teamId });
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: author.teamId,
|
||||
userId: author.id,
|
||||
});
|
||||
|
||||
await author.destroy();
|
||||
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.createdBy.id).toBe(author.id);
|
||||
});
|
||||
|
||||
it("should allow reading share by documentId", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should find share created by another user", async () => {
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser({
|
||||
teamId: admin.teamId,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should not find revoked share", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
await share.revoke();
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(204);
|
||||
});
|
||||
|
||||
it("should not find share for deleted document", async () => {
|
||||
const { user, document } = await seed();
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
await document.delete(user.id);
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(204);
|
||||
});
|
||||
|
||||
describe("apiVersion=2", () => {
|
||||
it("should allow reading share by documentId", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: document.id,
|
||||
apiVersion: 2,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.shares.length).toBe(1);
|
||||
expect(body.data.shares[0].id).toBe(share.id);
|
||||
expect(body.data.shares[0].published).toBe(true);
|
||||
});
|
||||
|
||||
it("should return share for parent document with includeChildDocuments=true", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const childDocument = await buildDocument({
|
||||
teamId: document.teamId,
|
||||
parentDocumentId: document.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
userId: user.id,
|
||||
includeChildDocuments: true,
|
||||
});
|
||||
|
||||
await collection.addDocumentToStructure(childDocument, 0);
|
||||
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: childDocument.id,
|
||||
apiVersion: 2,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.shares.length).toBe(1);
|
||||
expect(body.data.shares[0].id).toBe(share.id);
|
||||
expect(body.data.shares[0].documentId).toBe(document.id);
|
||||
expect(body.data.shares[0].published).toBe(true);
|
||||
expect(body.data.shares[0].includeChildDocuments).toBe(true);
|
||||
});
|
||||
|
||||
it("should not return share for parent document with includeChildDocuments=false", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const childDocument = await buildDocument({
|
||||
teamId: document.teamId,
|
||||
parentDocumentId: document.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
userId: user.id,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
|
||||
await collection.addDocumentToStructure(childDocument, 0);
|
||||
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: childDocument.id,
|
||||
apiVersion: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(204);
|
||||
});
|
||||
|
||||
it("should return shares for parent document and current document", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const childDocument = await buildDocument({
|
||||
teamId: document.teamId,
|
||||
parentDocumentId: document.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
const share = await buildShare({
|
||||
documentId: childDocument.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
includeChildDocuments: false,
|
||||
});
|
||||
const share2 = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
userId: user.id,
|
||||
includeChildDocuments: true,
|
||||
});
|
||||
|
||||
await collection.addDocumentToStructure(childDocument, 0);
|
||||
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
documentId: childDocument.id,
|
||||
apiVersion: 2,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.shares.length).toBe(2);
|
||||
expect(body.data.shares[0].id).toBe(share.id);
|
||||
expect(body.data.shares[0].includeChildDocuments).toBe(false);
|
||||
expect(body.data.shares[0].documentId).toBe(childDocument.id);
|
||||
expect(body.data.shares[0].published).toBe(true);
|
||||
|
||||
expect(body.data.shares[1].id).toBe(share2.id);
|
||||
expect(body.data.shares[1].documentId).toBe(document.id);
|
||||
expect(body.data.shares[1].published).toBe(true);
|
||||
expect(body.data.shares[1].includeChildDocuments).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { id: share.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.info", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.update", () => {
|
||||
it("should allow user to update a share", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: { token: user.getJwtToken(), id: share.id, published: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow author to update a share", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: { token: user.getJwtToken(), id: share.id, published: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow admin to update a share", async () => {
|
||||
const { user, admin, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: { token: admin.getJwtToken(), id: share.id, published: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toBe(share.id);
|
||||
expect(body.data.published).toBe(true);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: { id: share.id, published: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.update", {
|
||||
body: { token: user.getJwtToken(), id: share.id, published: true },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#shares.revoke", () => {
|
||||
it("should allow author to revoke a share", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.revoke", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it("should 404 if shares document is deleted", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
await document.delete(user.id);
|
||||
|
||||
const res = await server.post("/api/shares.revoke", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(404);
|
||||
});
|
||||
|
||||
it("should allow admin to revoke a share", async () => {
|
||||
const { user, admin, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/shares.revoke", {
|
||||
body: { token: admin.getJwtToken(), id: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.revoke", {
|
||||
body: { id: share.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const { admin, document } = await seed();
|
||||
const user = await buildUser();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: admin.teamId,
|
||||
userId: admin.id,
|
||||
});
|
||||
const res = await server.post("/api/shares.revoke", {
|
||||
body: { token: user.getJwtToken(), id: share.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
60
server/routes/api/team.js
Normal file
60
server/routes/api/team.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Event, Team } from "../../models";
|
||||
|
||||
import policy from "../../policies";
|
||||
import { presentTeam, presentPolicies } from "../../presenters";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("team.update", auth(), async (ctx) => {
|
||||
const {
|
||||
name,
|
||||
avatarUrl,
|
||||
subdomain,
|
||||
sharing,
|
||||
guestSignin,
|
||||
documentEmbeds,
|
||||
} = ctx.body;
|
||||
const user = ctx.state.user;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "update", team);
|
||||
|
||||
if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
|
||||
team.subdomain = subdomain === "" ? null : subdomain;
|
||||
}
|
||||
|
||||
if (name) team.name = name;
|
||||
if (sharing !== undefined) team.sharing = sharing;
|
||||
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
|
||||
if (guestSignin !== undefined) team.guestSignin = guestSignin;
|
||||
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
|
||||
|
||||
const changes = team.changed();
|
||||
const data = {};
|
||||
|
||||
await team.save();
|
||||
|
||||
if (changes) {
|
||||
for (const change of changes) {
|
||||
data[change] = team[change];
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "teams.update",
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
data,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: presentTeam(team),
|
||||
policies: presentPolicies(user, [team]),
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
48
server/routes/api/team.test.js
Normal file
48
server/routes/api/team.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import webService from "../../services/web";
|
||||
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#team.update", () => {
|
||||
it("should update team details", async () => {
|
||||
const { admin } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: { token: admin.getJwtToken(), name: "New name" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("New name");
|
||||
});
|
||||
|
||||
it("should allow identical team details", async () => {
|
||||
const { admin, team } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: { token: admin.getJwtToken(), name: team.name },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual(team.name);
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/team.update", {
|
||||
body: { token: user.getJwtToken(), name: "New name" },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
await seed();
|
||||
const res = await server.post("/api/team.update");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
300
server/routes/api/users.js
Normal file
300
server/routes/api/users.js
Normal file
@@ -0,0 +1,300 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import userDestroyer from "../../commands/userDestroyer";
|
||||
import userInviter from "../../commands/userInviter";
|
||||
import userSuspender from "../../commands/userSuspender";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { Event, User, Team } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentUser, presentPolicies } from "../../presenters";
|
||||
import { Op } from "../../sequelize";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const { can, authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("users.list", auth(), pagination(), async (ctx) => {
|
||||
let { sort = "createdAt", query, direction, filter } = ctx.body;
|
||||
if (direction !== "ASC") direction = "DESC";
|
||||
ctx.assertSort(sort, User);
|
||||
|
||||
if (filter) {
|
||||
ctx.assertIn(filter, [
|
||||
"invited",
|
||||
"viewers",
|
||||
"admins",
|
||||
"active",
|
||||
"all",
|
||||
"suspended",
|
||||
]);
|
||||
}
|
||||
|
||||
const actor = ctx.state.user;
|
||||
|
||||
let where = {
|
||||
teamId: actor.teamId,
|
||||
};
|
||||
|
||||
switch (filter) {
|
||||
case "invited": {
|
||||
where = { ...where, lastActiveAt: null };
|
||||
break;
|
||||
}
|
||||
case "viewers": {
|
||||
where = { ...where, isViewer: true };
|
||||
break;
|
||||
}
|
||||
case "admins": {
|
||||
where = { ...where, isAdmin: true };
|
||||
break;
|
||||
}
|
||||
case "suspended": {
|
||||
where = { ...where, suspendedAt: { [Op.ne]: null } };
|
||||
break;
|
||||
}
|
||||
case "all": {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
where = { ...where, suspendedAt: { [Op.eq]: null } };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (query) {
|
||||
where = {
|
||||
...where,
|
||||
name: {
|
||||
[Op.iLike]: `%${query}%`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
User.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
}),
|
||||
User.count({
|
||||
where,
|
||||
}),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
pagination: {
|
||||
...ctx.state.pagination,
|
||||
total,
|
||||
},
|
||||
data: users.map((user) =>
|
||||
presentUser(user, { includeDetails: can(actor, "readDetails", user) })
|
||||
),
|
||||
policies: presentPolicies(actor, users),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.count", auth(), async (ctx) => {
|
||||
const { user } = ctx.state;
|
||||
const counts = await User.getCounts(user.teamId);
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
counts,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
const actor = ctx.state.user;
|
||||
|
||||
const user = id ? await User.findByPk(id) : actor;
|
||||
authorize(actor, "read", user);
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.update", auth(), async (ctx) => {
|
||||
const { user } = ctx.state;
|
||||
const { name, avatarUrl, language } = ctx.body;
|
||||
|
||||
if (name) user.name = name;
|
||||
if (avatarUrl) user.avatarUrl = avatarUrl;
|
||||
if (language) user.language = language;
|
||||
|
||||
await user.save();
|
||||
|
||||
await Event.create({
|
||||
name: "users.update",
|
||||
actorId: user.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
};
|
||||
});
|
||||
|
||||
// Admin specific
|
||||
|
||||
router.post("users.promote", auth(), async (ctx) => {
|
||||
const userId = ctx.body.id;
|
||||
const teamId = ctx.state.user.teamId;
|
||||
const actor = ctx.state.user;
|
||||
ctx.assertPresent(userId, "id is required");
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(actor, "promote", user);
|
||||
|
||||
await user.promote();
|
||||
|
||||
await Event.create({
|
||||
name: "users.promote",
|
||||
actorId: actor.id,
|
||||
userId,
|
||||
teamId,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.demote", auth(), async (ctx) => {
|
||||
const userId = ctx.body.id;
|
||||
const teamId = ctx.state.user.teamId;
|
||||
let { to } = ctx.body;
|
||||
|
||||
const actor = ctx.state.user;
|
||||
ctx.assertPresent(userId, "id is required");
|
||||
|
||||
to = to === "viewer" ? "viewer" : "member";
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
|
||||
authorize(actor, "demote", user);
|
||||
|
||||
await user.demote(teamId, to);
|
||||
|
||||
await Event.create({
|
||||
name: "users.demote",
|
||||
actorId: actor.id,
|
||||
userId,
|
||||
teamId,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.suspend", auth(), async (ctx) => {
|
||||
const userId = ctx.body.id;
|
||||
const actor = ctx.state.user;
|
||||
ctx.assertPresent(userId, "id is required");
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(actor, "suspend", user);
|
||||
|
||||
await userSuspender({
|
||||
user,
|
||||
actorId: actor.id,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.activate", auth(), async (ctx) => {
|
||||
const userId = ctx.body.id;
|
||||
const teamId = ctx.state.user.teamId;
|
||||
const actor = ctx.state.user;
|
||||
ctx.assertPresent(userId, "id is required");
|
||||
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(actor, "activate", user);
|
||||
|
||||
await user.activate();
|
||||
|
||||
await Event.create({
|
||||
name: "users.activate",
|
||||
actorId: actor.id,
|
||||
userId,
|
||||
teamId,
|
||||
data: { name: user.name },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
const includeDetails = can(actor, "readDetails", user);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(user, { includeDetails }),
|
||||
policies: presentPolicies(actor, [user]),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.invite", auth(), async (ctx) => {
|
||||
const { invites } = ctx.body;
|
||||
ctx.assertArray(invites, "invites must be an array");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
authorize(user, "inviteUser", team);
|
||||
|
||||
const response = await userInviter({ user, invites, ip: ctx.request.ip });
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
sent: response.sent,
|
||||
users: response.users.map((user) => presentUser(user)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.post("users.delete", auth(), async (ctx) => {
|
||||
const { confirmation, id } = ctx.body;
|
||||
ctx.assertPresent(confirmation, "confirmation is required");
|
||||
|
||||
const actor = ctx.state.user;
|
||||
let user = actor;
|
||||
if (id) {
|
||||
user = await User.findByPk(id);
|
||||
}
|
||||
|
||||
authorize(actor, "delete", user);
|
||||
|
||||
await userDestroyer({
|
||||
user,
|
||||
actor,
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
573
server/routes/api/users.test.js
Normal file
573
server/routes/api/users.test.js
Normal file
@@ -0,0 +1,573 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import webService from "../../services/web";
|
||||
|
||||
import { buildTeam, buildAdmin, buildUser } from "../../test/factories";
|
||||
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#users.list", () => {
|
||||
it("should allow filtering by user name", async () => {
|
||||
const user = await buildUser({ name: "Tester" });
|
||||
|
||||
// suspended user should not be returned
|
||||
await buildUser({
|
||||
name: "Tester",
|
||||
teamId: user.teamId,
|
||||
suspendedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: {
|
||||
query: "test",
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it("should allow filtering to suspended users", async () => {
|
||||
const user = await buildUser({ name: "Tester" });
|
||||
await buildUser({
|
||||
name: "Tester",
|
||||
teamId: user.teamId,
|
||||
suspendedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: {
|
||||
query: "test",
|
||||
filter: "suspended",
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should allow filtering to invited", async () => {
|
||||
const user = await buildUser({ name: "Tester" });
|
||||
await buildUser({
|
||||
name: "Tester",
|
||||
teamId: user.teamId,
|
||||
lastActiveAt: null,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: {
|
||||
query: "test",
|
||||
filter: "invited",
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should return teams paginated user list", async () => {
|
||||
const { admin, user } = await seed();
|
||||
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: { token: admin.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(body.data[0].id).toEqual(user.id);
|
||||
expect(body.data[1].id).toEqual(admin.id);
|
||||
});
|
||||
|
||||
it("should require admin for detailed info", async () => {
|
||||
const { user, admin } = await seed();
|
||||
const res = await server.post("/api/users.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(2);
|
||||
expect(body.data[0].email).toEqual(undefined);
|
||||
expect(body.data[1].email).toEqual(undefined);
|
||||
expect(body.data[0].id).toEqual(user.id);
|
||||
expect(body.data[1].id).toEqual(admin.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.info", () => {
|
||||
it("should return current user with no id", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.info", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(user.id);
|
||||
expect(body.data.name).toEqual(user.name);
|
||||
expect(body.data.email).toEqual(user.email);
|
||||
});
|
||||
|
||||
it("should return user with permission", async () => {
|
||||
const user = await buildUser();
|
||||
const another = await buildUser({ teamId: user.teamId });
|
||||
const res = await server.post("/api/users.info", {
|
||||
body: { token: user.getJwtToken(), id: another.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(another.id);
|
||||
expect(body.data.name).toEqual(another.name);
|
||||
|
||||
// no emails of other users
|
||||
expect(body.data.email).toEqual(undefined);
|
||||
});
|
||||
|
||||
it("should now return user without permission", async () => {
|
||||
const user = await buildUser();
|
||||
const another = await buildUser();
|
||||
const res = await server.post("/api/users.info", {
|
||||
body: { token: user.getJwtToken(), id: another.id },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.info");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.invite", () => {
|
||||
it("should return sent invites", async () => {
|
||||
const user = await buildAdmin();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
invites: [{ email: "test@example.com", name: "Test", role: "member" }],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.sent.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should require invites to be an array", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
invites: { email: "test@example.com", name: "Test", role: "member" },
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const admin = await buildUser();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
invites: [{ email: "test@example.com", name: "Test", role: "member" }],
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should invite user as an admin", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
invites: [{ email: "test@example.com", name: "Test", role: "admin" }],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.sent.length).toEqual(1);
|
||||
expect(body.data.users[0].isAdmin).toBeTruthy();
|
||||
expect(body.data.users[0].isViewer).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should invite user as a viewer", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
invites: [{ email: "test@example.com", name: "Test", role: "viewer" }],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.sent.length).toEqual(1);
|
||||
expect(body.data.users[0].isViewer).toBeTruthy();
|
||||
expect(body.data.users[0].isAdmin).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should invite user as a member if role is any arbitary value", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
invites: [
|
||||
{ email: "test@example.com", name: "Test", role: "arbitary" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.sent.length).toEqual(1);
|
||||
expect(body.data.users[0].isViewer).toBeFalsy();
|
||||
expect(body.data.users[0].isAdmin).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.invite");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.delete", () => {
|
||||
it("should not allow deleting without confirmation", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.delete", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should not allow deleting last admin if many users", async () => {
|
||||
const user = await buildAdmin();
|
||||
await buildUser({ teamId: user.teamId, isAdmin: false });
|
||||
|
||||
const res = await server.post("/api/users.delete", {
|
||||
body: { token: user.getJwtToken(), confirmation: true },
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow deleting user account with confirmation", async () => {
|
||||
const user = await buildUser();
|
||||
await buildUser({ teamId: user.teamId });
|
||||
|
||||
const res = await server.post("/api/users.delete", {
|
||||
body: { token: user.getJwtToken(), confirmation: true },
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it("should allow deleting pending user account with admin", async () => {
|
||||
const user = await buildAdmin();
|
||||
const pending = await buildUser({
|
||||
teamId: user.teamId,
|
||||
lastActiveAt: null,
|
||||
});
|
||||
const res = await server.post("/api/users.delete", {
|
||||
body: { token: user.getJwtToken(), id: pending.id, confirmation: true },
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it("should not allow deleting another user account", async () => {
|
||||
const user = await buildAdmin();
|
||||
const user2 = await buildUser({ teamId: user.teamId });
|
||||
const res = await server.post("/api/users.delete", {
|
||||
body: { token: user.getJwtToken(), id: user2.id, confirmation: true },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.delete");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.update", () => {
|
||||
it("should update user profile information", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/users.update", {
|
||||
body: { token: user.getJwtToken(), name: "New name" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual("New name");
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.update");
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.promote", () => {
|
||||
it("should promote a new admin", async () => {
|
||||
const { admin, user } = await seed();
|
||||
|
||||
const res = await server.post("/api/users.promote", {
|
||||
body: { token: admin.getJwtToken(), id: user.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.promote", {
|
||||
body: { token: user.getJwtToken(), id: user.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.demote", () => {
|
||||
it("should demote an admin", async () => {
|
||||
const { admin, user } = await seed();
|
||||
await user.update({ isAdmin: true }); // Make another admin
|
||||
|
||||
const res = await server.post("/api/users.demote", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should demote an admin to viewer", async () => {
|
||||
const { admin, user } = await seed();
|
||||
await user.update({ isAdmin: true }); // Make another admin
|
||||
|
||||
const res = await server.post("/api/users.demote", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: user.id,
|
||||
to: "viewer",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should demote an admin to member", async () => {
|
||||
const { admin, user } = await seed();
|
||||
await user.update({ isAdmin: true }); // Make another admin
|
||||
|
||||
const res = await server.post("/api/users.demote", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: user.id,
|
||||
to: "member",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should not demote admins if only one available", async () => {
|
||||
const admin = await buildAdmin();
|
||||
|
||||
const res = await server.post("/api/users.demote", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: admin.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.promote", {
|
||||
body: { token: user.getJwtToken(), id: user.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.suspend", () => {
|
||||
it("should suspend an user", async () => {
|
||||
const { admin, user } = await seed();
|
||||
|
||||
const res = await server.post("/api/users.suspend", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should not allow suspending the user themselves", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const res = await server.post("/api/users.suspend", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: admin.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.suspend", {
|
||||
body: { token: user.getJwtToken(), id: user.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.activate", () => {
|
||||
it("should activate a suspended user", async () => {
|
||||
const { admin, user } = await seed();
|
||||
await user.update({
|
||||
suspendedById: admin.id,
|
||||
suspendedAt: new Date(),
|
||||
});
|
||||
|
||||
expect(user.isSuspended).toBe(true);
|
||||
const res = await server.post("/api/users.activate", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.activate", {
|
||||
body: { token: user.getJwtToken(), id: user.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(403);
|
||||
expect(body).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe("#users.count", () => {
|
||||
it("should count active users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(1);
|
||||
expect(body.data.counts.admins).toEqual(0);
|
||||
expect(body.data.counts.invited).toEqual(0);
|
||||
expect(body.data.counts.suspended).toEqual(0);
|
||||
expect(body.data.counts.active).toEqual(1);
|
||||
});
|
||||
|
||||
it("should count admin users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id, isAdmin: true });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(1);
|
||||
expect(body.data.counts.admins).toEqual(1);
|
||||
expect(body.data.counts.invited).toEqual(0);
|
||||
expect(body.data.counts.suspended).toEqual(0);
|
||||
expect(body.data.counts.active).toEqual(1);
|
||||
});
|
||||
|
||||
it("should count suspended users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
await buildUser({ teamId: team.id, suspendedAt: new Date() });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(2);
|
||||
expect(body.data.counts.admins).toEqual(0);
|
||||
expect(body.data.counts.invited).toEqual(0);
|
||||
expect(body.data.counts.suspended).toEqual(1);
|
||||
expect(body.data.counts.active).toEqual(1);
|
||||
});
|
||||
|
||||
it("should count invited users", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
|
||||
const res = await server.post("/api/users.count", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.counts.all).toEqual(1);
|
||||
expect(body.data.counts.admins).toEqual(0);
|
||||
expect(body.data.counts.invited).toEqual(1);
|
||||
expect(body.data.counts.suspended).toEqual(0);
|
||||
expect(body.data.counts.active).toEqual(0);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/users.count");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
62
server/routes/api/utils.js
Normal file
62
server/routes/api/utils.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// @flow
|
||||
import { subDays } from "date-fns";
|
||||
import debug from "debug";
|
||||
import Router from "koa-router";
|
||||
import { documentPermanentDeleter } from "../../commands/documentPermanentDeleter";
|
||||
import { AuthenticationError } from "../../errors";
|
||||
import { Document, FileOperation } from "../../models";
|
||||
import { Op } from "../../sequelize";
|
||||
|
||||
const router = new Router();
|
||||
const log = debug("utils");
|
||||
|
||||
router.post("utils.gc", async (ctx) => {
|
||||
const { token, limit = 500 } = ctx.body;
|
||||
|
||||
if (process.env.UTILS_SECRET !== token) {
|
||||
throw new AuthenticationError("Invalid secret token");
|
||||
}
|
||||
|
||||
log(`Permanently destroying upto ${limit} documents older than 30 days…`);
|
||||
|
||||
const documents = await Document.scope("withUnpublished").findAll({
|
||||
attributes: ["id", "teamId", "text", "deletedAt"],
|
||||
where: {
|
||||
deletedAt: {
|
||||
[Op.lt]: subDays(new Date(), 30),
|
||||
},
|
||||
},
|
||||
paranoid: false,
|
||||
limit,
|
||||
});
|
||||
|
||||
const countDeletedDocument = await documentPermanentDeleter(documents);
|
||||
|
||||
log(`Destroyed ${countDeletedDocument} documents`);
|
||||
|
||||
log(`Expiring all the collection export older than 30 days…`);
|
||||
|
||||
const exports = await FileOperation.unscoped().findAll({
|
||||
where: {
|
||||
type: "export",
|
||||
createdAt: {
|
||||
[Op.lt]: subDays(new Date(), 30),
|
||||
},
|
||||
state: {
|
||||
[Op.ne]: "expired",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
exports.map(async (e) => {
|
||||
await e.expire();
|
||||
})
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
153
server/routes/api/utils.test.js
Normal file
153
server/routes/api/utils.test.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import { subDays } from "date-fns";
|
||||
import TestServer from "fetch-test-server";
|
||||
import { Document, FileOperation } from "../../models";
|
||||
import { Op } from "../../sequelize";
|
||||
import webService from "../../services/web";
|
||||
import { buildDocument, buildFileOperation } from "../../test/factories";
|
||||
import { flushdb } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#utils.gc", () => {
|
||||
it("should not destroy documents not deleted", async () => {
|
||||
await buildDocument({
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
|
||||
});
|
||||
|
||||
it("should not destroy documents deleted less than 30 days ago", async () => {
|
||||
await buildDocument({
|
||||
publishedAt: new Date(),
|
||||
deletedAt: subDays(new Date(), 25),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
|
||||
});
|
||||
|
||||
it("should destroy documents deleted more than 30 days ago", async () => {
|
||||
await buildDocument({
|
||||
publishedAt: new Date(),
|
||||
deletedAt: subDays(new Date(), 60),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should destroy draft documents deleted more than 30 days ago", async () => {
|
||||
await buildDocument({
|
||||
publishedAt: undefined,
|
||||
deletedAt: subDays(new Date(), 60),
|
||||
});
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should expire exports older than 30 days ago", async () => {
|
||||
await buildFileOperation({
|
||||
type: "export",
|
||||
state: "complete",
|
||||
createdAt: subDays(new Date(), 30),
|
||||
});
|
||||
|
||||
await buildFileOperation({
|
||||
type: "export",
|
||||
state: "complete",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await FileOperation.count({
|
||||
where: {
|
||||
type: "export",
|
||||
state: {
|
||||
[Op.eq]: "expired",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(data).toEqual(1);
|
||||
});
|
||||
|
||||
it("should not expire exports made less than 30 days ago", async () => {
|
||||
await buildFileOperation({
|
||||
type: "export",
|
||||
state: "complete",
|
||||
createdAt: subDays(new Date(), 29),
|
||||
});
|
||||
|
||||
await buildFileOperation({
|
||||
type: "export",
|
||||
state: "complete",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await FileOperation.count({
|
||||
where: {
|
||||
type: "export",
|
||||
state: {
|
||||
[Op.eq]: "expired",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(data).toEqual(0);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const res = await server.post("/api/utils.gc");
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
52
server/routes/api/views.js
Normal file
52
server/routes/api/views.js
Normal file
@@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
import Router from "koa-router";
|
||||
import auth from "../../middlewares/authentication";
|
||||
import { View, Document, Event } from "../../models";
|
||||
import policy from "../../policies";
|
||||
import { presentView } from "../../presenters";
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("views.list", auth(), async (ctx) => {
|
||||
const { documentId } = ctx.body;
|
||||
ctx.assertUuid(documentId, "documentId is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
|
||||
const views = await View.findByDocument(documentId);
|
||||
|
||||
ctx.body = {
|
||||
data: views.map(presentView),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("views.create", auth(), async (ctx) => {
|
||||
const { documentId } = ctx.body;
|
||||
ctx.assertUuid(documentId, "documentId is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
|
||||
const view = await View.increment({ documentId, userId: user.id });
|
||||
|
||||
await Event.create({
|
||||
name: "views.create",
|
||||
actorId: user.id,
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: user.teamId,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
view.user = user;
|
||||
ctx.body = {
|
||||
data: presentView(view),
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
126
server/routes/api/views.test.js
Normal file
126
server/routes/api/views.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import { View, CollectionUser } from "../../models";
|
||||
import webService from "../../services/web";
|
||||
import { buildUser } from "../../test/factories";
|
||||
import { flushdb, seed } from "../../test/support";
|
||||
|
||||
const app = webService();
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("#views.list", () => {
|
||||
it("should return views for a document", async () => {
|
||||
const { user, document } = await seed();
|
||||
await View.increment({ documentId: document.id, userId: user.id });
|
||||
|
||||
const res = await server.post("/api/views.list", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data[0].count).toBe(1);
|
||||
expect(body.data[0].user.name).toBe(user.name);
|
||||
});
|
||||
|
||||
it("should return views for a document in read-only collection", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
collection.permission = null;
|
||||
await collection.save();
|
||||
|
||||
await CollectionUser.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: "read",
|
||||
});
|
||||
|
||||
await View.increment({ documentId: document.id, userId: user.id });
|
||||
|
||||
const res = await server.post("/api/views.list", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data[0].count).toBe(1);
|
||||
expect(body.data[0].user.name).toBe(user.name);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const { document } = await seed();
|
||||
const res = await server.post("/api/views.list", {
|
||||
body: { documentId: 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/views.list", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#views.create", () => {
|
||||
it("should allow creating a view record for document", async () => {
|
||||
const { user, document } = await seed();
|
||||
const res = await server.post("/api/views.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.count).toBe(1);
|
||||
});
|
||||
|
||||
it("should allow creating a view record for document in read-only collection", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
collection.permission = null;
|
||||
await collection.save();
|
||||
|
||||
await CollectionUser.create({
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: "read",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/views.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.count).toBe(1);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const { document } = await seed();
|
||||
const res = await server.post("/api/views.create", {
|
||||
body: { documentId: 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/views.create", {
|
||||
body: { token: user.getJwtToken(), documentId: document.id },
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user