fix: Unknown Slack users should be able to search team accessible docs (#1049)
* fix: Unknown Slack users should be able to search team accessible docs * test: fix flaky test * test: remove obsolete snapshot * lint * flow * fix: Spelling mistake
This commit is contained in:
@ -70,72 +70,6 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.list should require admin for detailed info 1`] = `
|
||||
Object {
|
||||
"data": Array [
|
||||
Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"createdAt": "2018-01-01T00:00:00.000Z",
|
||||
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
|
||||
"isAdmin": true,
|
||||
"isSuspended": false,
|
||||
"lastActiveAt": null,
|
||||
"name": "Admin User",
|
||||
},
|
||||
],
|
||||
"ok": true,
|
||||
"pagination": Object {
|
||||
"limit": 15,
|
||||
"nextPath": "/api/users.list?limit=15&offset=15",
|
||||
"offset": 0,
|
||||
},
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.list should return teams paginated user list 1`] = `
|
||||
Object {
|
||||
"data": Array [
|
||||
Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"createdAt": "2018-01-02T00:00:00.000Z",
|
||||
"email": "user1@example.com",
|
||||
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
|
||||
"isAdmin": false,
|
||||
"isSuspended": false,
|
||||
"lastActiveAt": null,
|
||||
"name": "User 1",
|
||||
},
|
||||
Object {
|
||||
"avatarUrl": "http://example.com/avatar.png",
|
||||
"createdAt": "2018-01-01T00:00:00.000Z",
|
||||
"email": "admin@example.com",
|
||||
"id": "fa952cff-fa64-4d42-a6ea-6955c9689046",
|
||||
"isAdmin": true,
|
||||
"isSuspended": false,
|
||||
"lastActiveAt": null,
|
||||
"name": "Admin User",
|
||||
},
|
||||
],
|
||||
"ok": true,
|
||||
"pagination": Object {
|
||||
"limit": 15,
|
||||
"nextPath": "/api/users.list?limit=15&offset=15",
|
||||
"offset": 0,
|
||||
},
|
||||
"status": 200,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`#users.promote should promote a new admin 1`] = `
|
||||
Object {
|
||||
"data": Object {
|
||||
|
@ -58,15 +58,18 @@ router.post('hooks.interactive', async ctx => {
|
||||
ctx.assertPresent(token, 'token is required');
|
||||
ctx.assertPresent(callback_id, 'callback_id is required');
|
||||
|
||||
if (token !== process.env.SLACK_VERIFICATION_TOKEN)
|
||||
if (token !== process.env.SLACK_VERIFICATION_TOKEN) {
|
||||
throw new AuthenticationError('Invalid verification token');
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
where: { service: 'slack', serviceId: data.user.id },
|
||||
const team = await Team.findOne({
|
||||
where: { slackId: data.team.id },
|
||||
});
|
||||
if (!user) {
|
||||
|
||||
if (!team) {
|
||||
ctx.body = {
|
||||
text: 'Sorry, we couldn’t find your user on this team in Outline.',
|
||||
text:
|
||||
'Sorry, we couldn’t find an integration for your team. Head to your Outline settings to set one up.',
|
||||
response_type: 'ephemeral',
|
||||
replace_original: false,
|
||||
};
|
||||
@ -75,12 +78,13 @@ router.post('hooks.interactive', async ctx => {
|
||||
|
||||
// we find the document based on the users teamId to ensure access
|
||||
const document = await Document.findOne({
|
||||
where: { id: data.callback_id, teamId: user.teamId },
|
||||
where: {
|
||||
id: data.callback_id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
if (!document) throw new InvalidRequestError('Invalid document');
|
||||
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
// respond with a public message that will be posted in the original channel
|
||||
ctx.body = {
|
||||
response_type: 'in_channel',
|
||||
@ -93,8 +97,9 @@ router.post('hooks.interactive', async ctx => {
|
||||
|
||||
// triggered by the /outline command in Slack
|
||||
router.post('hooks.slack', async ctx => {
|
||||
const { token, user_id, text } = ctx.body;
|
||||
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) {
|
||||
@ -116,25 +121,33 @@ router.post('hooks.slack', async ctx => {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
service: 'slack',
|
||||
serviceId: user_id,
|
||||
},
|
||||
const team = await Team.findOne({
|
||||
where: { slackId: team_id },
|
||||
});
|
||||
if (!user) {
|
||||
if (!team) {
|
||||
ctx.body = {
|
||||
response_type: 'ephemeral',
|
||||
text: 'Sorry, we couldn’t find your user – have you signed into Outline?',
|
||||
text:
|
||||
'Sorry, we couldn’t find an integration for your team. Head to your Outline settings to set one up.',
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
const results = await Document.searchForUser(user, text, {
|
||||
limit: 5,
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
service: 'slack',
|
||||
serviceId: user_id,
|
||||
},
|
||||
});
|
||||
|
||||
const options = {
|
||||
limit: 5,
|
||||
};
|
||||
const results = user
|
||||
? await Document.searchForUser(user, text, options)
|
||||
: await Document.searchForTeam(team, text, options);
|
||||
|
||||
if (results.length) {
|
||||
const attachments = [];
|
||||
for (const result of results) {
|
||||
|
@ -3,7 +3,7 @@ import TestServer from 'fetch-test-server';
|
||||
import app from '../app';
|
||||
import { Authentication } from '../models';
|
||||
import { flushdb, seed } from '../test/support';
|
||||
import { buildDocument, buildUser } from '../test/factories';
|
||||
import { buildDocument } from '../test/factories';
|
||||
import * as Slack from '../slack';
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
@ -51,12 +51,13 @@ describe('#hooks.unfurl', async () => {
|
||||
|
||||
describe('#hooks.slack', async () => {
|
||||
it('should return no matches', async () => {
|
||||
const { user } = await seed();
|
||||
const { user, team } = await seed();
|
||||
|
||||
const res = await server.post('/api/hooks.slack', {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.serviceId,
|
||||
team_id: team.slackId,
|
||||
text: 'dsfkndfskndsfkn',
|
||||
},
|
||||
});
|
||||
@ -66,7 +67,7 @@ describe('#hooks.slack', async () => {
|
||||
});
|
||||
|
||||
it('should return search results with summary if query is in title', async () => {
|
||||
const user = await buildUser();
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: 'This title contains a search term',
|
||||
userId: user.id,
|
||||
@ -76,6 +77,7 @@ describe('#hooks.slack', async () => {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.serviceId,
|
||||
team_id: team.slackId,
|
||||
text: 'contains',
|
||||
},
|
||||
});
|
||||
@ -87,7 +89,7 @@ describe('#hooks.slack', async () => {
|
||||
});
|
||||
|
||||
it('should return search results if query is regex-like', async () => {
|
||||
const user = await buildUser();
|
||||
const { user, team } = await seed();
|
||||
await buildDocument({
|
||||
title: 'This title contains a search term',
|
||||
userId: user.id,
|
||||
@ -97,6 +99,7 @@ describe('#hooks.slack', async () => {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.serviceId,
|
||||
team_id: team.slackId,
|
||||
text: '*contains',
|
||||
},
|
||||
});
|
||||
@ -106,7 +109,7 @@ describe('#hooks.slack', async () => {
|
||||
});
|
||||
|
||||
it('should return search results with snippet if query is in text', async () => {
|
||||
const user = await buildUser();
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
text: 'This title contains a search term',
|
||||
userId: user.id,
|
||||
@ -116,6 +119,7 @@ describe('#hooks.slack', async () => {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.serviceId,
|
||||
team_id: team.slackId,
|
||||
text: 'contains',
|
||||
},
|
||||
});
|
||||
@ -129,11 +133,12 @@ describe('#hooks.slack', async () => {
|
||||
});
|
||||
|
||||
it('should respond with help content for help keyword', async () => {
|
||||
const user = await buildUser();
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post('/api/hooks.slack', {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.serviceId,
|
||||
team_id: team.slackId,
|
||||
text: 'help',
|
||||
},
|
||||
});
|
||||
@ -143,11 +148,12 @@ describe('#hooks.slack', async () => {
|
||||
});
|
||||
|
||||
it('should respond with help content for no keyword', async () => {
|
||||
const user = await buildUser();
|
||||
const { user, team } = await seed();
|
||||
const res = await server.post('/api/hooks.slack', {
|
||||
body: {
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user_id: user.serviceId,
|
||||
team_id: team.slackId,
|
||||
text: '',
|
||||
},
|
||||
});
|
||||
@ -156,26 +162,46 @@ describe('#hooks.slack', async () => {
|
||||
expect(body.text.includes('How to use')).toEqual(true);
|
||||
});
|
||||
|
||||
it('should respond with error if unknown user', async () => {
|
||||
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: 'not-a-user-id',
|
||||
text: 'Welcome',
|
||||
user_id: 'unknown-slack-user-id',
|
||||
team_id: team.slackId,
|
||||
text: 'contains',
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.text.includes('Sorry')).toEqual(true);
|
||||
expect(body.attachments).toEqual(undefined);
|
||||
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 } = await seed();
|
||||
const { user, team } = await seed();
|
||||
|
||||
const res = await server.post('/api/hooks.slack', {
|
||||
body: {
|
||||
token: 'wrong-verification-token',
|
||||
team_id: team.slackId,
|
||||
user_id: user.serviceId,
|
||||
text: 'Welcome',
|
||||
},
|
||||
@ -186,7 +212,7 @@ describe('#hooks.slack', async () => {
|
||||
|
||||
describe('#hooks.interactive', async () => {
|
||||
it('should respond with replacement message', async () => {
|
||||
const user = await buildUser();
|
||||
const { user, team } = await seed();
|
||||
const document = await buildDocument({
|
||||
title: 'This title contains a search term',
|
||||
userId: user.id,
|
||||
@ -195,7 +221,8 @@ describe('#hooks.interactive', async () => {
|
||||
|
||||
const payload = JSON.stringify({
|
||||
token: process.env.SLACK_VERIFICATION_TOKEN,
|
||||
user: { id: user.serviceId, name: user.name },
|
||||
user: { id: user.serviceId },
|
||||
team: { id: team.slackId },
|
||||
callback_id: document.id,
|
||||
});
|
||||
const res = await server.post('/api/hooks.interactive', {
|
||||
@ -208,19 +235,28 @@ describe('#hooks.interactive', async () => {
|
||||
expect(body.attachments[0].title).toEqual(document.title);
|
||||
});
|
||||
|
||||
it('should respond with error if unknown user', async () => {
|
||||
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: 'not-a-user-id', name: 'unknown' },
|
||||
callback_id: 'doesnt-matter',
|
||||
user: { id: 'unknown-slack-user-id' },
|
||||
team: { id: team.slackId },
|
||||
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.text.includes('Sorry')).toEqual(true);
|
||||
expect(body.attachments).toEqual(undefined);
|
||||
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 () => {
|
||||
|
@ -12,7 +12,7 @@ afterAll(server.close);
|
||||
|
||||
describe('#users.list', async () => {
|
||||
it('should return teams paginated user list', async () => {
|
||||
const { admin } = await seed();
|
||||
const { admin, user } = await seed();
|
||||
|
||||
const res = await server.post('/api/users.list', {
|
||||
body: { token: admin.getJwtToken() },
|
||||
@ -20,18 +20,24 @@ describe('#users.list', async () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toMatchSnapshot();
|
||||
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 } = await seed();
|
||||
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).toMatchSnapshot();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -214,10 +214,80 @@ type SearchResult = {
|
||||
document: Document,
|
||||
};
|
||||
|
||||
type SearchOptions = {
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
collectionId?: string,
|
||||
dateFilter?: 'day' | 'week' | 'month' | 'year',
|
||||
collaboratorIds?: string[],
|
||||
includeArchived?: boolean,
|
||||
};
|
||||
|
||||
Document.searchForTeam = async (
|
||||
team,
|
||||
query,
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult[]> => {
|
||||
const limit = options.limit || 15;
|
||||
const offset = options.offset || 0;
|
||||
const wildcardQuery = `${sequelize.escape(query)}:*`;
|
||||
const collectionIds = await team.collectionIds();
|
||||
|
||||
// Build the SQL query to get documentIds, ranking, and search term context
|
||||
const sql = `
|
||||
SELECT
|
||||
id,
|
||||
ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking",
|
||||
ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext"
|
||||
FROM documents
|
||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
"collectionId" IN(:collectionIds) AND
|
||||
"deletedAt" IS NULL AND
|
||||
"publishedAt" IS NOT NULL
|
||||
ORDER BY
|
||||
"searchRanking" DESC,
|
||||
"updatedAt" DESC
|
||||
LIMIT :limit
|
||||
OFFSET :offset;
|
||||
`;
|
||||
|
||||
const results = await sequelize.query(sql, {
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
teamId: team.id,
|
||||
query: wildcardQuery,
|
||||
limit,
|
||||
offset,
|
||||
collectionIds,
|
||||
},
|
||||
});
|
||||
|
||||
// Final query to get associated document data
|
||||
const documents = await Document.findAll({
|
||||
where: {
|
||||
id: map(results, 'id'),
|
||||
},
|
||||
include: [
|
||||
{ model: Collection, as: 'collection' },
|
||||
{ model: User, as: 'createdBy', paranoid: false },
|
||||
{ model: User, as: 'updatedBy', paranoid: false },
|
||||
],
|
||||
});
|
||||
|
||||
return map(results, result => ({
|
||||
ranking: result.searchRanking,
|
||||
context: removeMarkdown(unescape(result.searchContext), {
|
||||
stripHTML: false,
|
||||
}),
|
||||
document: find(documents, { id: result.id }),
|
||||
}));
|
||||
};
|
||||
|
||||
Document.searchForUser = async (
|
||||
user,
|
||||
query,
|
||||
options = {}
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult[]> => {
|
||||
const limit = options.limit || 15;
|
||||
const offset = options.offset || 0;
|
||||
|
@ -190,6 +190,15 @@ Team.prototype.activateUser = async function(user: User, admin: User) {
|
||||
});
|
||||
};
|
||||
|
||||
Team.prototype.collectionIds = async function(paranoid: boolean = true) {
|
||||
let models = await Collection.findAll({
|
||||
attributes: ['id', 'private'],
|
||||
where: { teamId: this.id, private: false },
|
||||
paranoid,
|
||||
});
|
||||
return models.map(c => c.id);
|
||||
};
|
||||
|
||||
Team.beforeSave(uploadAvatar);
|
||||
|
||||
export default Team;
|
||||
|
Reference in New Issue
Block a user