Introduce zod for server-side validations (#4397)

* chore(server): use zod for validations

* fix(server): use ctx.input for documents.list

* fix(server): schema for documents.archived

* fix(server): documents.deleted, documents.viewed & documents.drafts

* fix(server): documents.info

* fix(server): documents.export & documents.restore

* fix(server): documents.search_titles & documents.search

* fix(server): documents.templatize

* fix(server): replace nullish() with optional()

* fix(server): documents.update

* fix(server): documents.move

* fix(server): remaining

* fix(server): add validation for snippet min and max words

* fix(server): fix update types

* fix(server): remove DocumentSchema

* fix(server): collate duplicate schemas

* fix: typos

* fix: reviews

* chore: Fixed case of Metrics import

* fix: restructure /api

* fix: loosen validation for id as it can be a slug too

* Add test for query by slug
Simplify import

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2022-11-24 10:11:43 +05:30
committed by GitHub
parent 100d05035b
commit a6125be6f1
12 changed files with 1901 additions and 1313 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -64,8 +64,8 @@ Object {
exports[`#documents.update should require text while appending 1`] = `
Object {
"error": "param_required",
"message": "Text is required while appending",
"error": "validation_error",
"message": "ValidationError: text is required while appending",
"ok": false,
"status": 400,
}

View File

@@ -23,6 +23,17 @@ import { seed, getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#documents.info", () => {
it("should fail if both id and shareId are absent", async () => {
const res = await server.post("/api/documents.info", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual(
"ValidationError: one of id or shareId is required"
);
});
it("should return published document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.info", {
@@ -36,6 +47,19 @@ describe("#documents.info", () => {
expect(body.data.id).toEqual(document.id);
});
it("should return published document for urlId", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.info", {
body: {
token: user.getJwtToken(),
id: document.urlId,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
});
it("should return archived document", async () => {
const { user, document } = await seed();
await document.archive(user.id);
@@ -411,7 +435,7 @@ describe("#documents.info", () => {
const res = await server.post("/api/documents.info", {
body: {
token: user.getJwtToken(),
id: "test",
id: "9bcbf864-1090-4eb6-ba05-4da0c3a5c58e",
},
});
expect(res.status).toEqual(404);
@@ -665,6 +689,58 @@ describe("#documents.export", () => {
});
describe("#documents.list", () => {
it("should fail for invalid userId", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
userId: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("userId: Invalid uuid");
});
it("should fail for invalid collectionId", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
collectionId: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Invalid uuid");
});
it("should fail for invalid parentDocumentId", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
parentDocumentId: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("parentDocumentId: Invalid uuid");
});
it("should fail for invalid backlinkDocumentId", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.list", {
body: {
token: user.getJwtToken(),
backlinkDocumentId: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("backlinkDocumentId: Invalid uuid");
});
it("should return documents", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.list", {
@@ -853,6 +929,36 @@ describe("#documents.list", () => {
});
describe("#documents.drafts", () => {
it("should fail for invalid collectionId", async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
const res = await server.post("/api/documents.drafts", {
body: {
token: user.getJwtToken(),
collectionId: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Invalid uuid");
});
it("should fail for invalid dateFilter", async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
const res = await server.post("/api/documents.drafts", {
body: {
token: user.getJwtToken(),
dateFilter: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("dateFilter: Invalid input");
});
it("should return unpublished documents", async () => {
const { user, document } = await seed();
document.publishedAt = null;
@@ -909,6 +1015,18 @@ describe("#documents.drafts", () => {
});
describe("#documents.search_titles", () => {
it("should fail without query", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.search_titles", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("query: Required");
});
it("should return case insensitive results for partial query", async () => {
const user = await buildUser();
const document = await buildDocument({
@@ -960,6 +1078,20 @@ describe("#documents.search_titles", () => {
});
describe("#documents.search", () => {
it("should fail for invalid shareId", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.search", {
body: {
token: user.getJwtToken(),
query: "much",
shareId: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("shareId: Invalid uuid");
});
it("should return results", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.search", {
@@ -1451,6 +1583,20 @@ describe("#documents.search", () => {
});
});
describe("#documents.templatize", () => {
it("should require id", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.templatize", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.message).toBe("id: Required");
});
});
describe("#documents.archived", () => {
it("should return archived documents", async () => {
const { user } = await seed();
@@ -1675,6 +1821,64 @@ describe("#documents.viewed", () => {
});
describe("#documents.move", () => {
it("should fail if attempting to nest doc within itself", async () => {
const { user, document } = await seed();
const collection = await buildCollection();
const res = await server.post("/api/documents.move", {
body: {
id: document.id,
collectionId: collection.id,
parentDocumentId: document.id,
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual(
"ValidationError: infinite loop detected, cannot nest a document inside itself"
);
});
it("should require id", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should require collectionId", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Required");
});
it("should fail for invalid index", async () => {
const { user, document, collection } = await seed();
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
index: -1,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("index: Number must be greater than 0");
});
it("should move the document", async () => {
const { user, document } = await seed();
const collection = await buildCollection({
@@ -1726,6 +1930,34 @@ describe("#documents.move", () => {
});
describe("#documents.restore", () => {
it("should require id", async () => {
const { user, document } = await seed();
await document.destroy();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should fail for invalid collectionId", async () => {
const { user, document } = await seed();
await document.destroy();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: "invalid",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Invalid uuid");
});
it("should allow restore of trashed documents", async () => {
const { user, document } = await seed();
await document.destroy();
@@ -1905,7 +2137,7 @@ describe("#documents.restore", () => {
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: "test",
id: "76fe8ba4-4e6a-4a75-8a10-9bf57330b24c",
},
});
expect(res.status).toEqual(404);
@@ -1935,6 +2167,18 @@ describe("#documents.restore", () => {
});
describe("#documents.import", () => {
it("should require collectionId", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.import", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Required");
});
it("should error if no file is passed", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.import", {
@@ -1957,6 +2201,37 @@ describe("#documents.import", () => {
});
describe("#documents.create", () => {
it("should fail for invalid collectionId", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
collectionId: "invalid",
title: "new document",
text: "hello",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("collectionId: Invalid uuid");
});
it("should fail for invalid parentDocumentId", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
collectionId: collection.id,
parentDocumentId: "invalid",
title: "new document",
text: "hello",
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("parentDocumentId: Invalid uuid");
});
it("should create as a new document", async () => {
const { user, collection } = await seed();
const res = await server.post("/api/documents.create", {
@@ -2007,10 +2282,10 @@ describe("#documents.create", () => {
});
const body = await res.json();
expect(body.message).toBe(
"collectionId is required to create a nested doc or a template"
);
expect(res.status).toEqual(400);
expect(body.message).toBe(
"ValidationError: collectionId is required to create a template document"
);
});
it("should not allow publishing without specifying the collection", async () => {
@@ -2026,10 +2301,10 @@ describe("#documents.create", () => {
});
const body = await res.json();
expect(body.message).toBe(
"collectionId is required to publish a draft without collection"
);
expect(res.status).toEqual(400);
expect(body.message).toBe(
"ValidationError: collectionId is required to publish"
);
});
it("should not allow creating a nested doc without a collection", async () => {
@@ -2045,10 +2320,10 @@ describe("#documents.create", () => {
});
const body = await res.json();
expect(body.message).toBe(
"collectionId is required to create a nested doc or a template"
);
expect(res.status).toEqual(400);
expect(body.message).toBe(
"ValidationError: collectionId is required to create a nested document"
);
});
it("should not allow very long titles", async () => {
@@ -2494,9 +2769,50 @@ describe("#documents.update", () => {
});
expect(res.status).toEqual(403);
});
it("should fail for invalid collectionId", async () => {
const { document } = await seed();
const user = await buildUser();
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
id: document.id,
text: "Updated",
collectionId: "invalid",
},
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.message).toBe("collectionId: Invalid uuid");
});
it("should require id", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.update", {
body: {
token: user.getJwtToken(),
text: "Updated",
},
});
const body = await res.json();
expect(res.status).toBe(400);
expect(body.message).toBe("id: Required");
});
});
describe("#documents.archive", () => {
it("should require id", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.archive", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should allow archiving document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.archive", {
@@ -2523,6 +2839,18 @@ describe("#documents.archive", () => {
});
describe("#documents.delete", () => {
it("should require id", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.delete", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should allow deleting document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.delete", {
@@ -2616,6 +2944,18 @@ describe("#documents.delete", () => {
});
describe("#documents.unpublish", () => {
it("should require id", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.unpublish", {
body: {
token: user.getJwtToken(),
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("id: Required");
});
it("should unpublish a document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.unpublish", {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,279 @@
import { isEmpty } from "lodash";
import { z } from "zod";
const DocumentsSortParamsSchema = z.object({
/** Specifies the attributes by which documents will be sorted in the list */
sort: z
.string()
.refine((val) => ["createdAt", "updatedAt", "index"].includes(val))
.default("updatedAt"),
/** Specifies the sort order with respect to sort field */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
});
const DateFilterSchema = z.object({
/** Date filter */
dateFilter: z
.union([
z.literal("day"),
z.literal("week"),
z.literal("month"),
z.literal("year"),
])
.optional(),
});
const SearchQuerySchema = z.object({
/** Query for search */
query: z.string().refine((v) => v.trim() !== ""),
});
const BaseIdSchema = z.object({
/** Id of the entity */
id: z.string().uuid(),
});
export const DocumentsListSchema = DocumentsSortParamsSchema.extend({
/** Id of the user who created the doc */
userId: z.string().uuid().optional(),
/** Alias for userId - kept for backwards compatibility */
user: z.string().uuid().optional(),
/** Id of the collection to which the document belongs */
collectionId: z.string().uuid().optional(),
/** Alias for collectionId - kept for backwards compatibility */
collection: z.string().uuid().optional(),
/** Id of the backlinked document */
backlinkDocumentId: z.string().uuid().optional(),
/** Id of the parent document to which the document belongs */
parentDocumentId: z.string().uuid().nullish(),
/** Boolean which denotes whether the document is a template */
template: z.boolean().optional(),
})
// Maintains backwards compatibility
.transform((doc) => {
doc.collectionId = doc.collectionId || doc.collection;
doc.userId = doc.userId || doc.user;
delete doc.collection;
delete doc.user;
return doc;
});
export type DocumentsListReq = z.infer<typeof DocumentsListSchema>;
export const DocumentsArchivedSchema = DocumentsSortParamsSchema.extend({});
export type DocumentsArchivedReq = z.infer<typeof DocumentsArchivedSchema>;
export const DocumentsDeletedSchema = DocumentsSortParamsSchema.extend({});
export type DocumentsDeletedReq = z.infer<typeof DocumentsDeletedSchema>;
export const DocumentsViewedSchema = DocumentsSortParamsSchema.extend({});
export type DocumentsViewedReq = z.infer<typeof DocumentsViewedSchema>;
export const DocumentsDraftsSchema = DocumentsSortParamsSchema.merge(
DateFilterSchema
).extend({
/** Id of the collection to which the document belongs */
collectionId: z.string().uuid().optional(),
});
export type DocumentsDraftsReq = z.infer<typeof DocumentsDraftsSchema>;
export const DocumentsInfoSchema = z
.object({
/** Id of the document to be retrieved */
id: z.string().optional(),
/** Share Id, if available */
shareId: z.string().uuid().optional(),
/** Version of the API to be used */
apiVersion: z.number().optional(),
})
.refine((obj) => !(isEmpty(obj.id) && isEmpty(obj.shareId)), {
message: "one of id or shareId is required",
});
export type DocumentsInfoReq = z.infer<typeof DocumentsInfoSchema>;
export const DocumentsExportSchema = z
.object({
/** Id of the document to be exported */
id: z.string().uuid().optional(),
/** Share Id, if available */
shareId: z.string().uuid().optional(),
})
.refine((obj) => !(isEmpty(obj.id) && isEmpty(obj.shareId)), {
message: "one of id or shareId is required",
});
export type DocumentsExportReq = z.infer<typeof DocumentsExportSchema>;
export const DocumentsRestoreSchema = BaseIdSchema.extend({
/** Id of the collection to which the document belongs */
collectionId: z.string().uuid().optional(),
/** Id of document revision */
revisionId: z.string().uuid().optional(),
});
export type DocumentsRestoreReq = z.infer<typeof DocumentsRestoreSchema>;
export const DocumentsSearchTitlesSchema = SearchQuerySchema.extend({});
export type DocumentsSearchTitlesReq = z.infer<
typeof DocumentsSearchTitlesSchema
>;
export const DocumentsSearchSchema = SearchQuerySchema.merge(
DateFilterSchema
).extend({
/** Whether to include archived docs in results */
includeArchived: z.boolean().optional(),
/** Whether to include drafts in results */
includeDrafts: z.boolean().optional(),
/** Filter results for team based on the collection */
collectionId: z.string().uuid().optional(),
/** Filter results based on user */
userId: z.string().uuid().optional(),
/** Filter results for the team derived from shareId */
shareId: z.string().uuid().optional(),
/** Min words to be shown in the results snippets */
snippetMinWords: z.number().default(20),
/** Max words to be accomodated in the results snippets */
snippetMaxWords: z.number().default(30),
});
export type DocumentsSearchReq = z.infer<typeof DocumentsSearchSchema>;
export const DocumentsTemplatizeSchema = BaseIdSchema.extend({});
export type DocumentsTemplatizeReq = z.infer<typeof DocumentsTemplatizeSchema>;
export const DocumentsUpdateSchema = BaseIdSchema.extend({
/** Doc title to be updated */
title: z.string().optional(),
/** Doc text to be updated */
text: z.string().optional(),
/** Boolean to denote if the doc should occupy full width */
fullWidth: z.boolean().optional(),
/** Boolean to denote if the doc should be published */
publish: z.boolean().optional(),
/** Revision to compare against document revision count */
lastRevision: z.number().optional(),
/** Doc template Id */
templateId: z.string().uuid().nullish(),
/** Doc collection Id */
collectionId: z.string().uuid().optional(),
/** Boolean to denote if text should be appended */
append: z.boolean().optional(),
}).refine((obj) => !(obj.append && !obj.text), {
message: "text is required while appending",
});
export type DocumentsUpdateReq = z.infer<typeof DocumentsUpdateSchema>;
export const DocumentsMoveSchema = BaseIdSchema.extend({
/** Id of collection to which the doc is supposed to be moved */
collectionId: z.string().uuid(),
/** Parent Id, in case if the doc is moved to a new parent */
parentDocumentId: z.string().uuid().optional(),
/** Helps evaluate the new index in collection structure upon move */
index: z.number().positive().optional(),
}).refine((obj) => !(obj.parentDocumentId === obj.id), {
message: "infinite loop detected, cannot nest a document inside itself",
});
export type DocumentsMoveReq = z.infer<typeof DocumentsMoveSchema>;
export const DocumentsArchiveSchema = BaseIdSchema.extend({});
export type DocumentsArchiveReq = z.infer<typeof DocumentsArchiveSchema>;
export const DocumentsDeleteSchema = BaseIdSchema.extend({
/** Whether to permanently delete the doc as opposed to soft-delete */
permanent: z.boolean().optional(),
});
export type DocumentsDeleteReq = z.infer<typeof DocumentsDeleteSchema>;
export const DocumentsUnpublishSchema = BaseIdSchema.extend({});
export type DocumentsUnpublishReq = z.infer<typeof DocumentsUnpublishSchema>;
export const DocumentsImportSchema = z.object({
/** Whether to publish the imported docs */
publish: z.boolean().optional(),
/** Import docs to this collection */
collectionId: z.string().uuid(),
/** Import under this parent doc */
parentDocumentId: z.string().uuid().optional(),
});
export type DocumentsImportReq = z.infer<typeof DocumentsImportSchema>;
export const DocumentsCreateSchema = z
.object({
/** Doc title */
title: z.string().default(""),
/** Doc text */
text: z.string().default(""),
/** Boolean to denote if the doc should be published */
publish: z.boolean().optional(),
/** Create Doc under this collection */
collectionId: z.string().uuid().optional(),
/** Create Doc under this parent */
parentDocumentId: z.string().uuid().optional(),
/** Create doc with this template */
templateId: z.string().uuid().optional(),
/** Whether to create a template doc */
template: z.boolean().optional(),
})
.refine((obj) => !(obj.parentDocumentId && !obj.collectionId), {
message: "collectionId is required to create a nested document",
})
.refine((obj) => !(obj.template && !obj.collectionId), {
message: "collectionId is required to create a template document",
})
.refine((obj) => !(obj.publish && !obj.collectionId), {
message: "collectionId is required to publish",
});
export type DocumentsCreateReq = z.infer<typeof DocumentsCreateSchema>;