chore: request validation for pins (#5465)

This commit is contained in:
Apoorv Mishra
2023-06-22 15:57:00 +05:30
committed by GitHub
parent a094087342
commit d96bf5106d
6 changed files with 655 additions and 158 deletions

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from "./pins";

View File

@@ -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);
});
});

View File

@@ -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<T.PinsCreateReq>) => {
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<T.PinsCreateReq>) => {
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<T.PinsUpdateReq>) => {
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<T.PinsDeleteReq>) => {
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;

View File

@@ -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<typeof PinsCreateSchema>;
export const PinsListSchema = BaseSchema.extend({
body: z.object({
collectionId: z.string().uuid().nullish(),
}),
});
export type PinsListReq = z.infer<typeof PinsCreateSchema>;
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<typeof PinsUpdateSchema>;
export const PinsDeleteSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
}),
});
export type PinsDeleteReq = z.infer<typeof PinsDeleteSchema>;

View File

@@ -29,6 +29,7 @@ import {
Subscription, Subscription,
Notification, Notification,
SearchQuery, SearchQuery,
Pin,
} from "@server/models"; } from "@server/models";
let count = 1; let count = 1;
@@ -548,3 +549,26 @@ export async function buildSearchQuery(
return SearchQuery.create(overrides); return SearchQuery.create(overrides);
} }
export async function buildPin(overrides: Partial<Pin> = {}): Promise<Pin> {
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);
}