Merge main

This commit is contained in:
Tom Moor
2021-02-07 12:58:17 -08:00
233 changed files with 7243 additions and 4147 deletions

View File

@@ -9,7 +9,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
@@ -45,7 +45,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": false,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
@@ -81,7 +81,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": true,
"isSuspended": false,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},
@@ -126,7 +126,7 @@ Object {
"id": "46fde1d4-0050-428f-9f0b-0bf77f4bdf61",
"isAdmin": false,
"isSuspended": true,
"language": null,
"language": "en_US",
"lastActiveAt": null,
"name": "User 1",
},

43
server/api/auth.test.js Normal file
View File

@@ -0,0 +1,43 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { buildUser, buildTeam } from "../test/factories";
import { flushdb } from "../test/support";
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);
});
});

View File

@@ -31,7 +31,13 @@ const { authorize } = policy;
const router = new Router();
router.post("collections.create", auth(), async (ctx) => {
const { name, color, description, icon } = ctx.body;
const {
name,
color,
description,
icon,
sort = Collection.DEFAULT_SORT,
} = ctx.body;
const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required");
@@ -48,8 +54,9 @@ router.post("collections.create", auth(), async (ctx) => {
icon,
color,
teamId: user.teamId,
creatorId: user.id,
createdById: user.id,
private: isPrivate,
sort,
});
await Event.create({
@@ -484,16 +491,14 @@ router.post("collections.export_all", auth(), async (ctx) => {
});
router.post("collections.update", auth(), async (ctx) => {
const { id, name, description, icon, color } = ctx.body;
let { id, name, description, icon, color, sort } = ctx.body;
const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required");
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);
@@ -517,11 +522,24 @@ router.post("collections.update", auth(), async (ctx) => {
const isPrivacyChanged = isPrivate !== collection.private;
collection.name = name;
collection.description = description;
collection.icon = icon;
collection.color = color;
collection.private = isPrivate;
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 (isPrivate !== undefined) {
collection.private = isPrivate;
}
if (sort !== undefined) {
collection.sort = sort;
}
await collection.save();

View File

@@ -1,8 +1,13 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { Collection, CollectionUser, CollectionGroup } from "../models";
import { buildUser, buildGroup, buildCollection } from "../test/factories";
import { Document, CollectionUser, CollectionGroup } from "../models";
import {
buildUser,
buildGroup,
buildCollection,
buildDocument,
} from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -885,6 +890,8 @@ describe("#collections.create", () => {
expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy();
expect(body.data.name).toBe("Test");
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("asc");
expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy();
expect(body.policies[0].abilities.export).toBeTruthy();
@@ -937,6 +944,29 @@ describe("#collections.update", () => {
expect(body.policies.length).toBe(1);
});
it("allows editing sort", async () => {
const { user, collection } = await seed();
const sort = { field: "index", direction: "desc" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.sort.field).toBe("index");
expect(body.data.sort.direction).toBe("desc");
});
it("allows editing individual fields", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, private: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.private).toBe(true);
expect(body.data.name).toBe(collection.name);
});
it("allows editing from non-private to private collection", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/collections.update", {
@@ -1048,6 +1078,24 @@ describe("#collections.update", () => {
});
expect(res.status).toEqual(403);
});
it("does not allow setting unknown sort fields", async () => {
const { user, collection } = await seed();
const sort = { field: "blah", direction: "desc" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
expect(res.status).toEqual(400);
});
it("does not allow setting unknown sort directions", async () => {
const { user, collection } = await seed();
const sort = { field: "title", direction: "blah" };
const res = await server.post("/api/collections.update", {
body: { token: user.getJwtToken(), id: collection.id, sort },
});
expect(res.status).toEqual(400);
});
});
describe("#collections.delete", () => {
@@ -1078,11 +1126,11 @@ describe("#collections.delete", () => {
it("should delete collection", async () => {
const { user, collection } = await seed();
await Collection.create({
name: "Blah",
urlId: "blah",
// to ensure it isn't the last collection
await buildCollection({
teamId: user.teamId,
creatorId: user.id,
createdById: user.id,
});
const res = await server.post("/api/collections.delete", {
@@ -1094,6 +1142,37 @@ describe("#collections.delete", () => {
expect(body.success).toBe(true);
});
it("should delete published documents", async () => {
const { user, collection } = await seed();
// to ensure it isn't the last collection
await buildCollection({
teamId: user.teamId,
createdById: user.id,
});
// archived document should not be deleted
await buildDocument({
collectionId: collection.id,
archivedAt: new Date(),
});
const res = await server.post("/api/collections.delete", {
body: { token: user.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toBe(true);
expect(
await Document.count({
where: {
collectionId: collection.id,
},
})
).toEqual(1);
});
it("allows deleting by read-write collection group user", async () => {
const user = await buildUser();
const collection = await buildCollection({

View File

@@ -38,7 +38,7 @@ const { authorize, cannot } = policy;
const router = new Router();
router.post("documents.list", auth(), pagination(), async (ctx) => {
const {
let {
sort = "updatedAt",
template,
backlinkDocumentId,
@@ -71,6 +71,7 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
where = { ...where, createdById };
}
let documentIds = [];
// if a specific collection is passed then we need to check auth to view it
if (collectionId) {
ctx.assertUuid(collectionId, "collection must be a UUID");
@@ -81,6 +82,15 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
}).findByPk(collectionId);
authorize(user, "read", collection);
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (sort === "index") {
documentIds = collection.documentStructure
.map((node) => node.id)
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
where = { ...where, id: documentIds };
}
// otherwise, filter by all collections the user has access to
} else {
const collectionIds = await user.collectionIds();
@@ -92,6 +102,12 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
where = { ...where, parentDocumentId };
}
// Explicitly passing 'null' as the parentDocumentId allows listing documents
// that have no parent document (aka they are at the root of the collection)
if (parentDocumentId === null) {
where = { ...where, parentDocumentId: { [Op.eq]: null } };
}
if (backlinkDocumentId) {
ctx.assertUuid(backlinkDocumentId, "backlinkDocumentId must be a UUID");
@@ -108,6 +124,10 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
};
}
if (sort === "index") {
sort = "updatedAt";
}
// add the users starred state to the response by default
const starredScope = { method: ["withStarred", user.id] };
const collectionScope = { method: ["withCollection", user.id] };
@@ -124,6 +144,14 @@ router.post("documents.list", auth(), pagination(), async (ctx) => {
limit: ctx.state.pagination.limit,
});
// index sort is special because it uses the order of the documents in the
// collection.documentStructure rather than a database column
if (documentIds.length) {
documents.sort(
(a, b) => documentIds.indexOf(a.id) - documentIds.indexOf(b.id)
);
}
const data = await Promise.all(
documents.map((document) => presentDocument(document))
);
@@ -523,18 +551,27 @@ router.post("documents.restore", auth(), async (ctx) => {
throw new NotFoundError();
}
// Passing collectionId allows restoring to a different collection than the
// document was originally within
if (collectionId) {
ctx.assertUuid(collectionId, "collectionId must be a uuid");
authorize(user, "restore", document);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "update", collection);
document.collectionId = collectionId;
}
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(document.collectionId);
// if the collectionId was provided in the request and isn't valid then it will
// be caught as a 403 on the authorize call below. Otherwise we're checking here
// that the original collection still exists and advising to pass collectionId
// if not.
if (!collectionId) {
ctx.assertPresent(collection, "collectionId is required");
}
authorize(user, "update", collection);
if (document.deletedAt) {
authorize(user, "restore", document);
@@ -910,7 +947,7 @@ router.post("documents.update", auth(), async (ctx) => {
transaction = await sequelize.transaction();
if (publish) {
await document.publish({ transaction });
await document.publish(user.id, { transaction });
} else {
await document.save({ autosave, transaction });
}
@@ -1087,7 +1124,7 @@ router.post("documents.unpublish", auth(), async (ctx) => {
authorize(user, "unpublish", document);
await document.unpublish();
await document.unpublish(user.id);
await Event.create({
name: "documents.unpublish",

View File

@@ -433,7 +433,27 @@ describe("#documents.list", () => {
expect(body.data[0].id).toEqual(document.id);
});
it("should not return unpublished documents", async () => {
it("should allow filtering documents with no parent", async () => {
const { user, document } = await seed();
await buildDocument({
title: "child document",
text: "random text",
parentDocumentId: document.id,
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/documents.list", {
body: { token: user.getJwtToken(), parentDocumentId: null },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(document.id);
});
it("should not return draft documents", async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
@@ -493,6 +513,32 @@ describe("#documents.list", () => {
expect(body.data[1].id).toEqual(anotherDoc.id);
});
it("should allow sorting by collection index", async () => {
const { user, document, collection } = await seed();
const anotherDoc = await buildDocument({
title: "another document",
text: "random text",
userId: user.id,
teamId: user.teamId,
collectionId: collection.id,
});
await collection.addDocumentToStructure(anotherDoc, 0);
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
sort: "index",
direction: "ASC",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data[0].id).toEqual(anotherDoc.id);
expect(body.data[1].id).toEqual(document.id);
});
it("should allow filtering by collection", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.list", {
@@ -1334,7 +1380,22 @@ describe("#documents.restore", () => {
expect(body.data.collectionId).toEqual(collection.id);
});
it("should now allow restore of trashed documents to collection user cannot access", async () => {
it("should not allow restore of documents in deleted collection", async () => {
const { user, document, collection } = await seed();
await document.destroy(user.id);
await collection.destroy();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
expect(res.status).toEqual(400);
});
it("should not allow restore of trashed documents to collection user cannot access", async () => {
const { user, document } = await seed();
const collection = await buildCollection();
@@ -1955,7 +2016,7 @@ describe("#documents.delete", () => {
describe("#documents.unpublish", () => {
it("should unpublish a document", async () => {
const { user, document } = await seed();
let { user, document } = await seed();
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
@@ -1964,6 +2025,28 @@ describe("#documents.unpublish", () => {
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
expect(body.data.publishedAt).toBeNull();
document = await Document.unscoped().findByPk(document.id);
expect(document.userId).toEqual(user.id);
});
it("should unpublish another users document", async () => {
const { user, collection } = await seed();
let document = await buildDocument({
teamId: user.teamId,
collectionId: collection.id,
});
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
expect(body.data.publishedAt).toBeNull();
document = await Document.unscoped().findByPk(document.id);
expect(document.userId).toEqual(user.id);
});
it("should fail to unpublish a draft document", async () => {
@@ -1989,7 +2072,7 @@ describe("#documents.unpublish", () => {
expect(res.status).toEqual(403);
});
it("should fail to unpublish a archived document", async () => {
it("should fail to unpublish an archived document", async () => {
const { user, document } = await seed();
await document.archive();

View File

@@ -2,7 +2,7 @@
import Router from "koa-router";
import Sequelize from "sequelize";
import auth from "../middlewares/authentication";
import { Event, Team, User } from "../models";
import { Event, Team, User, Collection } from "../models";
import policy from "../policies";
import { presentEvent } from "../presenters";
import pagination from "./middlewares/pagination";
@@ -12,30 +12,62 @@ const { authorize } = policy;
const router = new Router();
router.post("events.list", auth(), pagination(), async (ctx) => {
let { sort = "createdAt", direction, auditLog = false } = ctx.body;
if (direction !== "ASC") direction = "DESC";
const user = ctx.state.user;
const collectionIds = await user.collectionIds({ paranoid: false });
let {
sort = "createdAt",
actorId,
collectionId,
direction,
name,
auditLog = false,
} = ctx.body;
if (direction !== "ASC") direction = "DESC";
let where = {
name: Event.ACTIVITY_EVENTS,
teamId: user.teamId,
[Op.or]: [
{ collectionId: collectionIds },
{
collectionId: {
[Op.eq]: null,
},
},
],
};
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
where = {
...where,
actorId,
};
}
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, "auditLog", Team);
where.name = Event.AUDIT_EVENTS;
}
if (name && where.name.includes(name)) {
where.name = name;
}
const events = await Event.findAll({
where,
order: [[sort, direction]],

View File

@@ -13,7 +13,7 @@ describe("#events.list", () => {
it("should only return activity events", async () => {
const { user, admin, document, collection } = await seed();
// private event
// audit event
await buildEvent({
name: "users.promote",
teamId: user.teamId,
@@ -29,6 +29,7 @@ describe("#events.list", () => {
teamId: user.teamId,
actorId: admin.id,
});
const res = await server.post("/api/events.list", {
body: { token: user.getJwtToken() },
});
@@ -39,6 +40,100 @@ describe("#events.list", () => {
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 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();
@@ -64,6 +159,15 @@ describe("#events.list", () => {
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();