refactor: add server-side validation schema for events (#4622)

* refactor: move files to subfolder

* refactor: schema for events.list

* refactor: update nullable fields in Event model

* fix: event actor not nullable

* fix: team not nullable
This commit is contained in:
Mohamed ELIDRISSI
2022-12-31 22:56:37 +01:00
committed by GitHub
parent 05a4f050bb
commit dc795604a4
7 changed files with 143 additions and 109 deletions

View File

@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`#events.list should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;

View File

@@ -0,0 +1,205 @@
import { buildEvent, buildUser } from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support";
const server = getTestServer();
describe("#events.list", () => {
it("should only return activity events", 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: admin.id,
});
const res = await server.post("/api/events.list", {
body: {
token: user.getJwtToken(),
},
});
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 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 documentId", async () => {
const { user, admin, document, collection } = await seed();
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: admin.getJwtToken(),
documentId: document.id,
},
});
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 not return events for documentId without authorization", async () => {
const { user, document, collection } = await seed();
const actor = await buildUser();
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: actor.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
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();
// event viewable in activity stream
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
await user.destroy();
const res = await server.post("/api/events.list", {
body: {
token: admin.getJwtToken(),
},
});
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 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();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,102 @@
import Router from "koa-router";
import { Op, WhereOptions } from "sequelize";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { Event, User, Collection } from "@server/models";
import { authorize } from "@server/policies";
import { presentEvent } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
const router = new Router();
router.post(
"events.list",
auth(),
pagination(),
validate(T.EventsListSchema),
async (ctx: APIContext<T.EventsListReq>) => {
const { user } = ctx.state;
const {
sort,
direction,
actorId,
documentId,
collectionId,
name,
auditLog,
} = ctx.input;
let where: WhereOptions<Event> = {
name: Event.ACTIVITY_EVENTS,
teamId: user.teamId,
};
if (actorId) {
where = { ...where, actorId };
}
if (documentId) {
where = { ...where, documentId };
}
if (auditLog) {
authorize(user, "manage", user.team);
where.name = Event.AUDIT_EVENTS;
}
if (name && (where.name as string[]).includes(name)) {
where.name = name;
}
if (collectionId) {
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.is]: null,
},
},
],
};
}
const events = await Event.findAll({
where,
order: [[sort, direction]],
include: [
{
model: User,
as: "actor",
paranoid: false,
},
],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
ctx.body = {
pagination: ctx.state.pagination,
data: await Promise.all(
events.map((event) => presentEvent(event, auditLog))
),
};
}
);
export default router;

View File

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

View File

@@ -0,0 +1,32 @@
import { z } from "zod";
export const EventsListSchema = z.object({
/** Id of the user who performed the action */
actorId: z.string().uuid().optional(),
/** Id of the document to filter the events for */
documentId: z.string().uuid().optional(),
/** Id of the collection to filter the events for */
collectionId: z.string().uuid().optional(),
/** Whether to include audit events */
auditLog: z.boolean().default(false),
/** Name of the event to retrieve */
name: z.string().optional(),
/** The attribute to sort the events by */
sort: z
.string()
.refine((val) => ["name", "createdAt"].includes(val))
.default("createdAt"),
/** The direction to sort the events */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
});
export type EventsListReq = z.infer<typeof EventsListSchema>;