Request validation for /api/stars.* (#5475)

* chore: req validation for stars.create

* chore: req validation for stars.list

* chore: req validation for stars.update

* chore: req validation for stars.delete

* fix: DRY

* fix: group validation attributes and message
This commit is contained in:
Apoorv Mishra
2023-07-01 19:25:57 +05:30
committed by GitHub
parent f214db0ab7
commit 768fcbf6c4
6 changed files with 302 additions and 157 deletions

View File

@@ -1,157 +0,0 @@
import Router from "koa-router";
import { Sequelize } from "sequelize";
import starCreator from "@server/commands/starCreator";
import starDestroyer from "@server/commands/starDestroyer";
import starUpdater from "@server/commands/starUpdater";
import { sequelize } from "@server/database/sequelize";
import auth from "@server/middlewares/authentication";
import { Document, Star, Collection } from "@server/models";
import { authorize } from "@server/policies";
import {
presentStar,
presentDocument,
presentPolicies,
} from "@server/presenters";
import { APIContext } from "@server/types";
import { starIndexing } from "@server/utils/indexing";
import { assertUuid, assertIndexCharacters } from "@server/validation";
import pagination from "./middlewares/pagination";
const router = new Router();
router.post("stars.create", auth(), async (ctx: APIContext) => {
const { documentId, collectionId } = ctx.request.body;
const { index } = ctx.request.body;
const { user } = ctx.state.auth;
assertUuid(
documentId || collectionId,
"documentId or collectionId is required"
);
if (documentId) {
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "star", document);
}
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "star", collection);
}
if (index) {
assertIndexCharacters(index);
}
const star = await sequelize.transaction(async (transaction) =>
starCreator({
user,
documentId,
collectionId,
ip: ctx.request.ip,
index,
transaction,
})
);
ctx.body = {
data: presentStar(star),
policies: presentPolicies(user, [star]),
};
});
router.post("stars.list", auth(), pagination(), async (ctx: APIContext) => {
const { user } = ctx.state.auth;
const [stars, collectionIds] = await Promise.all([
Star.findAll({
where: {
userId: user.id,
},
order: [
Sequelize.literal('"star"."index" collate "C"'),
["updatedAt", "DESC"],
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
user.collectionIds(),
]);
const nullIndex = stars.findIndex((star) => star.index === null);
if (nullIndex !== -1) {
const indexedStars = await starIndexing(user.id);
stars.forEach((star) => {
star.index = indexedStars[star.id];
});
}
const documentIds = stars
.map((star) => star.documentId)
.filter(Boolean) as string[];
const documents = documentIds.length
? await Document.defaultScopeWithUser(user.id).findAll({
where: {
id: documentIds,
collectionId: collectionIds,
},
})
: [];
const policies = presentPolicies(user, [...documents, ...stars]);
ctx.body = {
pagination: ctx.state.pagination,
data: {
stars: stars.map(presentStar),
documents: await Promise.all(
documents.map((document: Document) => presentDocument(document))
),
},
policies,
};
});
router.post("stars.update", auth(), async (ctx: APIContext) => {
const { id, index } = ctx.request.body;
assertUuid(id, "id is required");
assertIndexCharacters(index);
const { user } = ctx.state.auth;
let star = await Star.findByPk(id);
authorize(user, "update", star);
star = await starUpdater({
user,
star,
ip: ctx.request.ip,
index,
});
ctx.body = {
data: presentStar(star),
policies: presentPolicies(user, [star]),
};
});
router.post("stars.delete", auth(), async (ctx: APIContext) => {
const { id } = ctx.request.body;
assertUuid(id, "id is required");
const { user } = ctx.state.auth;
const star = await Star.findByPk(id);
authorize(user, "delete", star);
await starDestroyer({ user, star, ip: ctx.request.ip });
ctx.body = {
success: true,
};
});
export default router;

View File

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

View File

@@ -0,0 +1,54 @@
import { isEmpty } from "lodash";
import { z } from "zod";
import { ValidateDocumentId, ValidateIndex } from "@server/validation";
import BaseSchema from "../BaseSchema";
export const StarsCreateSchema = BaseSchema.extend({
body: z
.object({
documentId: z
.string()
.refine(ValidateDocumentId.isValid, {
message: ValidateDocumentId.message,
})
.optional(),
collectionId: z.string().uuid().optional(),
index: z
.string()
.regex(ValidateIndex.regex, {
message: ValidateIndex.message,
})
.optional(),
})
.refine(
(body) => !(isEmpty(body.documentId) && isEmpty(body.collectionId)),
{
message: "One of documentId or collectionId is required",
}
),
});
export type StarsCreateReq = z.infer<typeof StarsCreateSchema>;
export const StarsListSchema = BaseSchema;
export type StarsListReq = z.infer<typeof StarsListSchema>;
export const StarsUpdateSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
index: z.string().regex(ValidateIndex.regex, {
message: ValidateIndex.message,
}),
}),
});
export type StarsUpdateReq = z.infer<typeof StarsUpdateSchema>;
export const StarsDeleteSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
}),
});
export type StarsDeleteReq = z.infer<typeof StarsDeleteSchema>;

View File

@@ -4,6 +4,22 @@ import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#stars.create", () => {
it("should fail with status 400 bad request when both documentId and collectionId are missing", async () => {
const user = await buildUser();
const res = await server.post("/api/stars.create", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual(
"body: One of documentId or collectionId is required"
);
});
it("should create a star", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -57,7 +73,52 @@ describe("#stars.list", () => {
});
});
describe("#stars.update", () => {
it("should fail with status 400 bad request when id is missing", async () => {
const user = await buildUser();
const res = await server.post("/api/stars.update", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should succeed with status 200 ok", async () => {
const user = await buildUser();
const star = await buildStar({
userId: user.id,
});
const res = await server.post("/api/stars.update", {
body: {
token: user.getJwtToken(),
id: star.id,
index: "i",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data).toBeTruthy();
expect(body.data.id).toEqual(star.id);
expect(body.data.index).toEqual("i");
});
});
describe("#stars.delete", () => {
it("should fail with status 400 bad request when id is missing", async () => {
const user = await buildUser();
const res = await server.post("/api/stars.delete", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should delete users star", async () => {
const user = await buildUser();
const star = await buildStar({

View File

@@ -0,0 +1,165 @@
import Router from "koa-router";
import { Sequelize } from "sequelize";
import starCreator from "@server/commands/starCreator";
import starDestroyer from "@server/commands/starDestroyer";
import starUpdater from "@server/commands/starUpdater";
import { sequelize } from "@server/database/sequelize";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Document, Star, Collection } from "@server/models";
import { authorize } from "@server/policies";
import {
presentStar,
presentDocument,
presentPolicies,
} from "@server/presenters";
import { APIContext } from "@server/types";
import { starIndexing } from "@server/utils/indexing";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"stars.create",
auth(),
validate(T.StarsCreateSchema),
async (ctx: APIContext<T.StarsCreateReq>) => {
const { documentId, collectionId, index } = ctx.input.body;
const { user } = ctx.state.auth;
if (documentId) {
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "star", document);
}
if (collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "star", collection);
}
const star = await sequelize.transaction(async (transaction) =>
starCreator({
user,
documentId,
collectionId,
ip: ctx.request.ip,
index,
transaction,
})
);
ctx.body = {
data: presentStar(star),
policies: presentPolicies(user, [star]),
};
}
);
router.post(
"stars.list",
auth(),
pagination(),
validate(T.StarsListSchema),
async (ctx: APIContext<T.StarsListReq>) => {
const { user } = ctx.state.auth;
const [stars, collectionIds] = await Promise.all([
Star.findAll({
where: {
userId: user.id,
},
order: [
Sequelize.literal('"star"."index" collate "C"'),
["updatedAt", "DESC"],
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
user.collectionIds(),
]);
const nullIndex = stars.findIndex((star) => star.index === null);
if (nullIndex !== -1) {
const indexedStars = await starIndexing(user.id);
stars.forEach((star) => {
star.index = indexedStars[star.id];
});
}
const documentIds = stars
.map((star) => star.documentId)
.filter(Boolean) as string[];
const documents = documentIds.length
? await Document.defaultScopeWithUser(user.id).findAll({
where: {
id: documentIds,
collectionId: collectionIds,
},
})
: [];
const policies = presentPolicies(user, [...documents, ...stars]);
ctx.body = {
pagination: ctx.state.pagination,
data: {
stars: stars.map(presentStar),
documents: await Promise.all(
documents.map((document: Document) => presentDocument(document))
),
},
policies,
};
}
);
router.post(
"stars.update",
auth(),
validate(T.StarsUpdateSchema),
async (ctx: APIContext<T.StarsUpdateReq>) => {
const { id, index } = ctx.input.body;
const { user } = ctx.state.auth;
let star = await Star.findByPk(id);
authorize(user, "update", star);
star = await starUpdater({
user,
star,
ip: ctx.request.ip,
index,
});
ctx.body = {
data: presentStar(star),
policies: presentPolicies(user, [star]),
};
}
);
router.post(
"stars.delete",
auth(),
validate(T.StarsDeleteSchema),
async (ctx: APIContext<T.StarsDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const star = await Star.findByPk(id);
authorize(user, "delete", star);
await starDestroyer({ user, star, ip: ctx.request.ip });
ctx.body = {
success: true,
};
}
);
export default router;

View File

@@ -1,6 +1,8 @@
import { isArrayLike } from "lodash";
import { Primitive } from "utility-types";
import validator from "validator";
import isUUID from "validator/lib/isUUID";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { CollectionPermission } from "../shared/types";
import { validateColorHex } from "../shared/utils/color";
import { validateIndexCharacters } from "../shared/utils/indexCharacters";
@@ -165,3 +167,22 @@ export const assertCollectionPermission = (
) => {
assertIn(value, [...Object.values(CollectionPermission), null], message);
};
export class ValidateDocumentId {
/**
* Checks if documentId is valid. A valid documentId is either
* a UUID or a url slug matching a particular regex.
*
* @param documentId
* @returns true if documentId is valid, false otherwise
*/
public static isValid = (documentId: string) =>
isUUID(documentId) || SLUG_URL_REGEX.test(documentId);
public static message = "Must be uuid or url slug";
}
export class ValidateIndex {
public static regex = new RegExp("^[\x20-\x7E]+$");
public static message = "Must be between x20 to x7E ASCII";
}