diff --git a/server/routes/api/pins.ts b/server/routes/api/pins.ts deleted file mode 100644 index 7cc452b7b..000000000 --- a/server/routes/api/pins.ts +++ /dev/null @@ -1,158 +0,0 @@ -import Router from "koa-router"; -import { Sequelize, Op } from "sequelize"; -import pinCreator from "@server/commands/pinCreator"; -import pinDestroyer from "@server/commands/pinDestroyer"; -import pinUpdater from "@server/commands/pinUpdater"; -import auth from "@server/middlewares/authentication"; -import { Collection, Document, Pin } from "@server/models"; -import { authorize } from "@server/policies"; -import { - presentPin, - presentDocument, - presentPolicies, -} from "@server/presenters"; -import { APIContext } from "@server/types"; -import { assertUuid, assertIndexCharacters } from "@server/validation"; -import pagination from "./middlewares/pagination"; - -const router = new Router(); - -router.post("pins.create", auth(), async (ctx: APIContext) => { - const { documentId, collectionId } = ctx.request.body; - const { index } = ctx.request.body; - assertUuid(documentId, "documentId is required"); - - const { user } = ctx.state.auth; - const document = await Document.findByPk(documentId, { - userId: user.id, - }); - authorize(user, "read", document); - - if (collectionId) { - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "update", collection); - authorize(user, "pin", document); - } else { - authorize(user, "pinToHome", document); - } - - if (index) { - assertIndexCharacters(index); - } - - const pin = await pinCreator({ - user, - documentId, - collectionId, - ip: ctx.request.ip, - index, - }); - - ctx.body = { - data: presentPin(pin), - policies: presentPolicies(user, [pin]), - }; -}); - -router.post("pins.list", auth(), pagination(), async (ctx: APIContext) => { - const { collectionId } = ctx.request.body; - const { user } = ctx.state.auth; - - const [pins, collectionIds] = await Promise.all([ - Pin.findAll({ - where: { - ...(collectionId - ? { collectionId } - : { collectionId: { [Op.is]: null } }), - teamId: user.teamId, - }, - order: [ - Sequelize.literal('"pin"."index" collate "C"'), - ["updatedAt", "DESC"], - ], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }), - user.collectionIds(), - ]); - - const documents = await Document.defaultScopeWithUser(user.id).findAll({ - where: { - id: pins.map((pin) => pin.documentId), - collectionId: collectionIds, - }, - }); - - const policies = presentPolicies(user, [...documents, ...pins]); - - ctx.body = { - pagination: ctx.state.pagination, - data: { - pins: pins.map(presentPin), - documents: await Promise.all( - documents.map((document: Document) => presentDocument(document)) - ), - }, - policies, - }; -}); - -router.post("pins.update", auth(), async (ctx: APIContext) => { - const { id, index } = ctx.request.body; - assertUuid(id, "id is required"); - - assertIndexCharacters(index); - - const { user } = ctx.state.auth; - let pin = await Pin.findByPk(id, { rejectOnEmpty: true }); - - const document = await Document.findByPk(pin.documentId, { - userId: user.id, - }); - - if (pin.collectionId) { - authorize(user, "pin", document); - } else { - authorize(user, "update", pin); - } - - pin = await pinUpdater({ - user, - pin, - ip: ctx.request.ip, - index, - }); - - ctx.body = { - data: presentPin(pin), - policies: presentPolicies(user, [pin]), - }; -}); - -router.post("pins.delete", auth(), async (ctx: APIContext) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); - - const { user } = ctx.state.auth; - const pin = await Pin.findByPk(id, { rejectOnEmpty: true }); - - const document = await Document.findByPk(pin.documentId, { - userId: user.id, - }); - - if (pin.collectionId) { - authorize(user, "unpin", document); - } else { - authorize(user, "delete", pin); - } - - await pinDestroyer({ user, pin, ip: ctx.request.ip }); - - ctx.body = { - success: true, - }; -}); - -export default router; diff --git a/server/routes/api/pins/index.ts b/server/routes/api/pins/index.ts new file mode 100644 index 000000000..09411e0e4 --- /dev/null +++ b/server/routes/api/pins/index.ts @@ -0,0 +1 @@ +export { default } from "./pins"; diff --git a/server/routes/api/pins/pins.test.ts b/server/routes/api/pins/pins.test.ts new file mode 100644 index 000000000..f1936a0d3 --- /dev/null +++ b/server/routes/api/pins/pins.test.ts @@ -0,0 +1,410 @@ +import { Collection, Document, Pin, User } from "@server/models"; +import { + buildAdmin, + buildCollection, + buildDocument, + buildDraftDocument, + buildPin, + buildUser, +} from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("#pins.create", () => { + let admin: User; + let user: User; + let anotherUser: User; + let document: Document; + let collection: Collection; + + beforeEach(async () => { + admin = await buildAdmin(); + [user, anotherUser] = await Promise.all([ + buildUser({ teamId: admin.teamId }), + buildUser(), + ]); + collection = await buildCollection({ + createdById: admin.id, + teamId: admin.teamId, + }); + document = await buildDocument({ + createdById: admin.id, + teamId: admin.teamId, + collectionId: collection.id, + }); + }); + + it("should fail with status 401 unauthorized when user token is missing", async () => { + const res = await server.post("/api/pins.create", { + body: { + documentId: "foo", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body.message).toEqual("Authentication required"); + }); + + it("should fail with status 400 bad request when documentId is not suppled", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("documentId: required"); + }); + + it("should fail with status 400 bad request when documentId is invalid", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: user.getJwtToken(), + documentId: "foo", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("documentId: must be uuid or url slug"); + }); + + it("should fail with status 400 bad request when index is invalid", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: user.getJwtToken(), + documentId: "foo1234567", + index: "😀", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("index: must be between x20 to x7E ASCII"); + }); + + it("should fail with status 403 forbidden when user is disallowed to read the document", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: anotherUser.getJwtToken(), + documentId: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body.message).toEqual("Authorization error"); + }); + + it("should fail with status 403 forbidden when user is disallowed to update the collection", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + collectionId: collection.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body.message).toEqual("Authorization error"); + }); + + it("should fail with status 403 forbidden when user is disallowed to pin the document", async () => { + const draft = await buildDraftDocument({ + createdById: admin.id, + teamId: admin.teamId, + collectionId: collection.id, + }); + const res = await server.post("/api/pins.create", { + body: { + token: admin.getJwtToken(), + // A draft document cannot be pinned, neither by a member nor by an admin + documentId: draft.id, + collectionId: collection.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body.message).toEqual("Authorization error"); + }); + + it("should fail with status 403 forbidden when user is disallowed to pin the document to home page", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: user.getJwtToken(), + documentId: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body.message).toEqual("Authorization error"); + }); + + it("should succeed with status 200 ok when user is allowed to pin", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: admin.getJwtToken(), + documentId: document.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toBeTruthy(); + expect(body.data.documentId).toEqual(document.id); + expect(body.data.collectionId).toBeNull(); + }); + + it("should succeed with status 200 ok when valid collectionId is supplied", async () => { + const res = await server.post("/api/pins.create", { + body: { + token: admin.getJwtToken(), + documentId: document.id, + collectionId: collection.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toBeTruthy(); + expect(body.data.documentId).toEqual(document.id); + expect(body.data.collectionId).toEqual(collection.id); + }); +}); + +describe("#pins.list", () => { + let user: User; + let pins: Pin[]; + let docs: Document[]; + let collection: Collection; + + beforeEach(async () => { + user = await buildUser(); + collection = await buildCollection({ + teamId: user.teamId, + createdById: user.id, + }); + docs = await Promise.all([ + buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + }), + buildDocument({ + teamId: user.teamId, + collectionId: collection.id, + }), + ]); + pins = await Promise.all([ + buildPin({ + createdById: user.id, + documentId: docs[0].id, + teamId: user.teamId, + }), + buildPin({ + createdById: user.id, + documentId: docs[1].id, + teamId: user.teamId, + }), + ]); + }); + + it("should fail with status 401 unauthorized when user token is missing", async () => { + const res = await server.post("/api/pins.list", { + body: {}, + }); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body.message).toEqual("Authentication required"); + }); + + it("should succeed with status 200 ok returning pinned documents", async () => { + const res = await server.post("/api/pins.list", { + body: { + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toBeTruthy(); + expect(body.data.pins).toBeTruthy(); + expect(body.data.pins).toHaveLength(2); + const pinIds = body.data.pins.map((p: any) => p.id); + expect(pinIds).toContain(pins[0].id); + expect(pinIds).toContain(pins[1].id); + const docIds = body.data.documents.map((d: any) => d.id); + expect(docIds).toContain(docs[0].id); + expect(docIds).toContain(docs[1].id); + }); + + it("should succeed with status 200 ok returning pinned documents filtered by collectionId supplied", async () => { + const res = await server.post("/api/pins.list", { + body: { + token: user.getJwtToken(), + collectionId: collection.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toBeTruthy(); + expect(body.data.pins).toBeTruthy(); + expect(body.data.pins).toHaveLength(0); + }); +}); + +describe("#pins.update", () => { + let user: User; + let admin: User; + let pin: Pin; + + beforeEach(async () => { + user = await buildUser(); + admin = await buildAdmin(); + const collection = await buildCollection({ + createdById: admin.id, + teamId: admin.teamId, + }); + const doc = await buildDocument({ + createdById: admin.id, + teamId: admin.teamId, + collectionId: collection.id, + }); + pin = await buildPin({ + teamId: admin.teamId, + createdById: admin.id, + documentId: doc.id, + index: "a", + }); + }); + + it("should fail with status 401 unauthorized when user token is missing", async () => { + const res = await server.post("/api/pins.update", { + body: { + id: pin.id, + index: "i", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body.message).toEqual("Authentication required"); + }); + + it("should fail with status 400 bad request when id is missing", async () => { + const res = await server.post("/api/pins.update", { + body: { + token: admin.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + + it("should fail with status 400 bad request when index is missing", async () => { + const res = await server.post("/api/pins.update", { + body: { + token: admin.getJwtToken(), + id: pin.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("index: Required"); + }); + + it("should fail with status 400 bad request when an invalid index is sent", async () => { + const res = await server.post("/api/pins.update", { + body: { + token: admin.getJwtToken(), + id: pin.id, + index: "😀", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("index: must be between x20 to x7E ASCII"); + }); + + it("should fail with status 403 forbidden when user is disallowed to update the pin", async () => { + const res = await server.post("/api/pins.update", { + body: { + token: user.getJwtToken(), + id: pin.id, + index: "b", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body.message).toEqual("Authorization error"); + }); + + it("should succeed with status 200 ok and when user is allowed to update the pin", async () => { + const res = await server.post("/api/pins.update", { + body: { + token: admin.getJwtToken(), + id: pin.id, + index: "b", + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data).toBeTruthy(); + expect(body.data.id).toEqual(pin.id); + expect(body.data.index).toEqual("b"); + }); +}); + +describe("#pins.delete", () => { + let admin: User; + let pin: Pin; + + beforeEach(async () => { + admin = await buildAdmin(); + pin = await buildPin({ + teamId: admin.teamId, + createdById: admin.id, + }); + }); + + it("should fail with status 401 unauthorized when user token is missing", async () => { + const res = await server.post("/api/pins.delete", { + body: {}, + }); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body.message).toEqual("Authentication required"); + }); + + it("should fail with status 400 bad request when id is missing", async () => { + const res = await server.post("/api/pins.delete", { + body: { + token: admin.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body.message).toEqual("id: Required"); + }); + + it("should fail with status 403 forbidden when user is disallowed to delete the pin", async () => { + const user = await buildUser({ + teamId: admin.teamId, + }); + const res = await server.post("/api/pins.delete", { + body: { + token: user.getJwtToken(), + id: pin.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body.message).toEqual("Authorization error"); + }); + + it("should succeed with status 200 ok when user is allowed to delete the pin", async () => { + const res = await server.post("/api/pins.delete", { + body: { + token: admin.getJwtToken(), + id: pin.id, + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.success).toEqual(true); + }); +}); diff --git a/server/routes/api/pins/pins.ts b/server/routes/api/pins/pins.ts new file mode 100644 index 000000000..3f34af262 --- /dev/null +++ b/server/routes/api/pins/pins.ts @@ -0,0 +1,168 @@ +import Router from "koa-router"; +import { Sequelize, Op } from "sequelize"; +import pinCreator from "@server/commands/pinCreator"; +import pinDestroyer from "@server/commands/pinDestroyer"; +import pinUpdater from "@server/commands/pinUpdater"; +import auth from "@server/middlewares/authentication"; +import validate from "@server/middlewares/validate"; +import { Collection, Document, Pin } from "@server/models"; +import { authorize } from "@server/policies"; +import { + presentPin, + presentDocument, + presentPolicies, +} from "@server/presenters"; +import { APIContext } from "@server/types"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "pins.create", + auth(), + validate(T.PinsCreateSchema), + async (ctx: APIContext) => { + const { documentId, collectionId, index } = ctx.input.body; + const { user } = ctx.state.auth; + const document = await Document.findByPk(documentId, { + userId: user.id, + }); + authorize(user, "read", document); + + if (collectionId) { + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "update", collection); + authorize(user, "pin", document); + } else { + authorize(user, "pinToHome", document); + } + + const pin = await pinCreator({ + user, + documentId, + collectionId, + ip: ctx.request.ip, + index, + }); + + ctx.body = { + data: presentPin(pin), + policies: presentPolicies(user, [pin]), + }; + } +); + +router.post( + "pins.list", + auth(), + validate(T.PinsListSchema), + pagination(), + async (ctx: APIContext) => { + const { collectionId } = ctx.input.body; + const { user } = ctx.state.auth; + + const [pins, collectionIds] = await Promise.all([ + Pin.findAll({ + where: { + ...(collectionId + ? { collectionId } + : { collectionId: { [Op.is]: null } }), + teamId: user.teamId, + }, + order: [ + Sequelize.literal('"pin"."index" collate "C"'), + ["updatedAt", "DESC"], + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + user.collectionIds(), + ]); + + const documents = await Document.defaultScopeWithUser(user.id).findAll({ + where: { + id: pins.map((pin) => pin.documentId), + collectionId: collectionIds, + }, + }); + + const policies = presentPolicies(user, [...documents, ...pins]); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + pins: pins.map(presentPin), + documents: await Promise.all( + documents.map((document: Document) => presentDocument(document)) + ), + }, + policies, + }; + } +); + +router.post( + "pins.update", + auth(), + validate(T.PinsUpdateSchema), + async (ctx: APIContext) => { + const { id, index } = ctx.input.body; + const { user } = ctx.state.auth; + let pin = await Pin.findByPk(id, { rejectOnEmpty: true }); + + const document = await Document.findByPk(pin.documentId, { + userId: user.id, + }); + + if (pin.collectionId) { + authorize(user, "pin", document); + } else { + authorize(user, "update", pin); + } + + pin = await pinUpdater({ + user, + pin, + ip: ctx.request.ip, + index, + }); + + ctx.body = { + data: presentPin(pin), + policies: presentPolicies(user, [pin]), + }; + } +); + +router.post( + "pins.delete", + auth(), + validate(T.PinsDeleteSchema), + async (ctx: APIContext) => { + const { id } = ctx.input.body; + + const { user } = ctx.state.auth; + const pin = await Pin.findByPk(id, { rejectOnEmpty: true }); + + const document = await Document.findByPk(pin.documentId, { + userId: user.id, + }); + + if (pin.collectionId) { + authorize(user, "unpin", document); + } else { + authorize(user, "delete", pin); + } + + await pinDestroyer({ user, pin, ip: ctx.request.ip }); + + ctx.body = { + success: true, + }; + } +); + +export default router; diff --git a/server/routes/api/pins/schema.ts b/server/routes/api/pins/schema.ts new file mode 100644 index 000000000..9f6808773 --- /dev/null +++ b/server/routes/api/pins/schema.ts @@ -0,0 +1,52 @@ +import isUUID from "validator/lib/isUUID"; +import { z } from "zod"; +import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; +import BaseSchema from "../BaseSchema"; + +export const PinsCreateSchema = BaseSchema.extend({ + body: z.object({ + documentId: z + .string({ + required_error: "required", + }) + .refine((val) => isUUID(val) || SLUG_URL_REGEX.test(val), { + message: "must be uuid or url slug", + }), + collectionId: z.string().uuid().nullish(), + index: z + .string() + .regex(new RegExp("^[\x20-\x7E]+$"), { + message: "must be between x20 to x7E ASCII", + }) + .optional(), + }), +}); + +export type PinsCreateReq = z.infer; + +export const PinsListSchema = BaseSchema.extend({ + body: z.object({ + collectionId: z.string().uuid().nullish(), + }), +}); + +export type PinsListReq = z.infer; + +export const PinsUpdateSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + index: z.string().regex(new RegExp("^[\x20-\x7E]+$"), { + message: "must be between x20 to x7E ASCII", + }), + }), +}); + +export type PinsUpdateReq = z.infer; + +export const PinsDeleteSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + }), +}); + +export type PinsDeleteReq = z.infer; diff --git a/server/test/factories.ts b/server/test/factories.ts index c3ad2c93a..5cafc31ad 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -29,6 +29,7 @@ import { Subscription, Notification, SearchQuery, + Pin, } from "@server/models"; let count = 1; @@ -548,3 +549,26 @@ export async function buildSearchQuery( return SearchQuery.create(overrides); } + +export async function buildPin(overrides: Partial = {}): Promise { + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + + if (!overrides.createdById) { + const user = await buildUser({ + teamId: overrides.teamId, + }); + overrides.createdById = user.id; + } + + if (!overrides.documentId) { + const document = await buildDocument({ + teamId: overrides.teamId, + }); + overrides.documentId = document.id; + } + + return Pin.create(overrides); +}