Request validation for /api/integrations.* (#5638)
This commit is contained in:
@@ -1,46 +0,0 @@
|
|||||||
import {
|
|
||||||
buildAdmin,
|
|
||||||
buildTeam,
|
|
||||||
buildUser,
|
|
||||||
buildIntegration,
|
|
||||||
} from "@server/test/factories";
|
|
||||||
import { getTestServer } from "@server/test/support";
|
|
||||||
|
|
||||||
const server = getTestServer();
|
|
||||||
|
|
||||||
describe("#integrations.update", () => {
|
|
||||||
it("should allow updating integration events", async () => {
|
|
||||||
const team = await buildTeam();
|
|
||||||
const user = await buildAdmin({ teamId: team.id });
|
|
||||||
const integration = await buildIntegration({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await server.post("/api/integrations.update", {
|
|
||||||
body: {
|
|
||||||
events: ["documents.update"],
|
|
||||||
token: user.getJwtToken(),
|
|
||||||
id: integration.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
expect(res.status).toEqual(200);
|
|
||||||
expect(body.data.id).toEqual(integration.id);
|
|
||||||
expect(body.data.events.length).toEqual(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should require authorization", async () => {
|
|
||||||
const user = await buildUser();
|
|
||||||
const integration = await buildIntegration({
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
const res = await server.post("/api/integrations.update", {
|
|
||||||
body: {
|
|
||||||
token: user.getJwtToken(),
|
|
||||||
id: integration.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(res.status).toEqual(403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
1
server/routes/api/integrations/index.ts
Normal file
1
server/routes/api/integrations/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./integrations";
|
||||||
176
server/routes/api/integrations/integrations.test.ts
Normal file
176
server/routes/api/integrations/integrations.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||||
|
import { IntegrationAuthentication, User } from "@server/models";
|
||||||
|
import Integration, {
|
||||||
|
UserCreatableIntegrationService,
|
||||||
|
} from "@server/models/Integration";
|
||||||
|
import {
|
||||||
|
buildAdmin,
|
||||||
|
buildTeam,
|
||||||
|
buildUser,
|
||||||
|
buildIntegration,
|
||||||
|
} from "@server/test/factories";
|
||||||
|
import { getTestServer } from "@server/test/support";
|
||||||
|
|
||||||
|
const server = getTestServer();
|
||||||
|
|
||||||
|
describe("#integrations.update", () => {
|
||||||
|
it("should allow updating integration events", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const user = await buildAdmin({ teamId: team.id });
|
||||||
|
const integration = await buildIntegration({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/integrations.update", {
|
||||||
|
body: {
|
||||||
|
events: ["documents.update"],
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: integration.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.id).toEqual(integration.id);
|
||||||
|
expect(body.data.events.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require authorization", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const integration = await buildIntegration({
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
const res = await server.post("/api/integrations.update", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: integration.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok when settings are updated", async () => {
|
||||||
|
const admin = await buildAdmin();
|
||||||
|
|
||||||
|
const integration = await buildIntegration({
|
||||||
|
userId: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
service: IntegrationService.Diagrams,
|
||||||
|
type: IntegrationType.Embed,
|
||||||
|
settings: { url: "https://example.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await server.post("/api/integrations.update", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: integration.id,
|
||||||
|
settings: { url: "https://foo.bar" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.data.id).toEqual(integration.id);
|
||||||
|
expect(body.data.settings.url).toEqual("https://foo.bar");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#integrations.create", () => {
|
||||||
|
it("should fail with status 400 bad request for an invalid url value supplied in settings param", async () => {
|
||||||
|
const admin = await buildAdmin();
|
||||||
|
|
||||||
|
const res = await server.post("/api/integrations.create", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
type: IntegrationType.Embed,
|
||||||
|
service: UserCreatableIntegrationService.Diagrams,
|
||||||
|
settings: { url: "not a url" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("url: Invalid url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok for an integration without url", async () => {
|
||||||
|
const admin = await buildAdmin();
|
||||||
|
|
||||||
|
const res = await server.post("/api/integrations.create", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
type: IntegrationType.Analytics,
|
||||||
|
service: UserCreatableIntegrationService.GoogleAnalytics,
|
||||||
|
settings: { measurementId: "123" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(body.data.type).toEqual(IntegrationType.Analytics);
|
||||||
|
expect(body.data.service).toEqual(
|
||||||
|
UserCreatableIntegrationService.GoogleAnalytics
|
||||||
|
);
|
||||||
|
expect(body.data.settings).not.toBeFalsy();
|
||||||
|
expect(body.data.settings.measurementId).toEqual("123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("#integrations.delete", () => {
|
||||||
|
let admin: User;
|
||||||
|
let integration: Integration;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
admin = await buildAdmin();
|
||||||
|
|
||||||
|
integration = await buildIntegration({
|
||||||
|
userId: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
service: IntegrationService.Diagrams,
|
||||||
|
type: IntegrationType.Embed,
|
||||||
|
settings: { url: "https://example.com" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 403 unauthorized when the user is not an admin", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
|
||||||
|
const res = await server.post("/api/integrations.delete", {
|
||||||
|
body: {
|
||||||
|
token: user.getJwtToken(),
|
||||||
|
id: integration.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(403);
|
||||||
|
expect(body.message).toEqual("Admin role required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fail with status 400 bad request when id is not sent", async () => {
|
||||||
|
const res = await server.post("/api/integrations.delete", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
expect(body.message).toEqual("id: Required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should succeed with status 200 ok when integration is deleted", async () => {
|
||||||
|
const res = await server.post("/api/integrations.delete", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: integration.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
|
||||||
|
const intg = await Integration.findByPk(integration.id);
|
||||||
|
expect(intg).toBeNull();
|
||||||
|
|
||||||
|
const auth = await IntegrationAuthentication.findByPk(
|
||||||
|
integration.authenticationId
|
||||||
|
);
|
||||||
|
expect(auth).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +1,16 @@
|
|||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
import { has } from "lodash";
|
|
||||||
import { WhereOptions } from "sequelize";
|
import { WhereOptions } from "sequelize";
|
||||||
import { IntegrationType } from "@shared/types";
|
import { IntegrationType } from "@shared/types";
|
||||||
import auth from "@server/middlewares/authentication";
|
import auth from "@server/middlewares/authentication";
|
||||||
import { Event } from "@server/models";
|
import { transaction } from "@server/middlewares/transaction";
|
||||||
import Integration, {
|
import validate from "@server/middlewares/validate";
|
||||||
UserCreatableIntegrationService,
|
import { Event, IntegrationAuthentication } from "@server/models";
|
||||||
} from "@server/models/Integration";
|
import Integration from "@server/models/Integration";
|
||||||
import { authorize } from "@server/policies";
|
import { authorize } from "@server/policies";
|
||||||
import { presentIntegration } from "@server/presenters";
|
import { presentIntegration } from "@server/presenters";
|
||||||
import { APIContext } from "@server/types";
|
import { APIContext } from "@server/types";
|
||||||
import {
|
import pagination from "../middlewares/pagination";
|
||||||
assertSort,
|
import * as T from "./schema";
|
||||||
assertUuid,
|
|
||||||
assertArray,
|
|
||||||
assertIn,
|
|
||||||
assertUrl,
|
|
||||||
} from "@server/validation";
|
|
||||||
import pagination from "./middlewares/pagination";
|
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
@@ -25,21 +18,16 @@ router.post(
|
|||||||
"integrations.list",
|
"integrations.list",
|
||||||
auth(),
|
auth(),
|
||||||
pagination(),
|
pagination(),
|
||||||
async (ctx: APIContext) => {
|
validate(T.IntegrationsListSchema),
|
||||||
let { direction } = ctx.request.body;
|
async (ctx: APIContext<T.IntegrationsListReq>) => {
|
||||||
|
const { direction, type, sort } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const { type, sort = "updatedAt" } = ctx.request.body;
|
|
||||||
if (direction !== "ASC") {
|
|
||||||
direction = "DESC";
|
|
||||||
}
|
|
||||||
assertSort(sort, Integration);
|
|
||||||
|
|
||||||
let where: WhereOptions<Integration> = {
|
let where: WhereOptions<Integration> = {
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type) {
|
if (type) {
|
||||||
assertIn(type, Object.values(IntegrationType));
|
|
||||||
where = {
|
where = {
|
||||||
...where,
|
...where,
|
||||||
type,
|
type,
|
||||||
@@ -63,20 +51,13 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"integrations.create",
|
"integrations.create",
|
||||||
auth({ admin: true }),
|
auth({ admin: true }),
|
||||||
async (ctx: APIContext) => {
|
validate(T.IntegrationsCreateSchema),
|
||||||
const { type, service, settings } = ctx.request.body;
|
async (ctx: APIContext<T.IntegrationsCreateReq>) => {
|
||||||
|
const { type, service, settings } = ctx.input.body;
|
||||||
assertIn(type, Object.values(IntegrationType));
|
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
authorize(user, "createIntegration", user.team);
|
authorize(user, "createIntegration", user.team);
|
||||||
|
|
||||||
assertIn(service, Object.values(UserCreatableIntegrationService));
|
|
||||||
|
|
||||||
if (has(settings, "url")) {
|
|
||||||
assertUrl(settings.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
const integration = await Integration.create({
|
const integration = await Integration.create({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
@@ -94,20 +75,14 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"integrations.update",
|
"integrations.update",
|
||||||
auth({ admin: true }),
|
auth({ admin: true }),
|
||||||
async (ctx: APIContext) => {
|
validate(T.IntegrationsUpdateSchema),
|
||||||
const { id, events = [], settings } = ctx.request.body;
|
async (ctx: APIContext<T.IntegrationsUpdateReq>) => {
|
||||||
assertUuid(id, "id is required");
|
const { id, events, settings } = ctx.input.body;
|
||||||
|
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
|
|
||||||
const integration = await Integration.findByPk(id);
|
const integration = await Integration.findByPk(id);
|
||||||
authorize(user, "update", integration);
|
authorize(user, "update", integration);
|
||||||
|
|
||||||
assertArray(events, "events must be an array");
|
|
||||||
|
|
||||||
if (has(settings, "url")) {
|
|
||||||
assertUrl(settings.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (integration.type === IntegrationType.Post) {
|
if (integration.type === IntegrationType.Post) {
|
||||||
integration.events = events.filter((event: string) =>
|
integration.events = events.filter((event: string) =>
|
||||||
["documents.update", "documents.publish"].includes(event)
|
["documents.update", "documents.publish"].includes(event)
|
||||||
@@ -127,22 +102,37 @@ router.post(
|
|||||||
router.post(
|
router.post(
|
||||||
"integrations.delete",
|
"integrations.delete",
|
||||||
auth({ admin: true }),
|
auth({ admin: true }),
|
||||||
async (ctx: APIContext) => {
|
validate(T.IntegrationsDeleteSchema),
|
||||||
const { id } = ctx.request.body;
|
transaction(),
|
||||||
assertUuid(id, "id is required");
|
async (ctx: APIContext<T.IntegrationsDeleteReq>) => {
|
||||||
|
const { id } = ctx.input.body;
|
||||||
const { user } = ctx.state.auth;
|
const { user } = ctx.state.auth;
|
||||||
const integration = await Integration.findByPk(id);
|
const { transaction } = ctx.state;
|
||||||
|
|
||||||
|
const integration = await Integration.findByPk(id, { transaction });
|
||||||
authorize(user, "delete", integration);
|
authorize(user, "delete", integration);
|
||||||
|
|
||||||
await integration.destroy();
|
await integration.destroy({ transaction });
|
||||||
await Event.create({
|
// also remove the corresponding authentication if it exists
|
||||||
|
if (integration.authenticationId) {
|
||||||
|
await IntegrationAuthentication.destroy({
|
||||||
|
where: {
|
||||||
|
id: integration.authenticationId,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
name: "integrations.delete",
|
name: "integrations.delete",
|
||||||
modelId: integration.id,
|
modelId: integration.id,
|
||||||
teamId: integration.teamId,
|
teamId: integration.teamId,
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
89
server/routes/api/integrations/schema.ts
Normal file
89
server/routes/api/integrations/schema.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { IntegrationType } from "@shared/types";
|
||||||
|
import { Integration } from "@server/models";
|
||||||
|
import { UserCreatableIntegrationService } from "@server/models/Integration";
|
||||||
|
import BaseSchema from "../BaseSchema";
|
||||||
|
|
||||||
|
export const IntegrationsListSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
/** Integrations sorting direction */
|
||||||
|
direction: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||||
|
|
||||||
|
/** Integrations sorting column */
|
||||||
|
sort: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => Object.keys(Integration.getAttributes()).includes(val), {
|
||||||
|
message: "Invalid sort parameter",
|
||||||
|
})
|
||||||
|
.default("updatedAt"),
|
||||||
|
|
||||||
|
/** Integration type */
|
||||||
|
type: z.nativeEnum(IntegrationType),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IntegrationsListReq = z.infer<typeof IntegrationsListSchema>;
|
||||||
|
|
||||||
|
export const IntegrationsCreateSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
/** Integration type */
|
||||||
|
type: z.nativeEnum(IntegrationType),
|
||||||
|
|
||||||
|
/** Integration service */
|
||||||
|
service: z.nativeEnum(UserCreatableIntegrationService),
|
||||||
|
|
||||||
|
/** Integration config/settings */
|
||||||
|
settings: z
|
||||||
|
.object({ url: z.string().url() })
|
||||||
|
.or(
|
||||||
|
z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
channel: z.string(),
|
||||||
|
channelId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.or(z.object({ measurementId: z.string() }))
|
||||||
|
.or(z.object({ serviceTeamId: z.string() }))
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IntegrationsCreateReq = z.infer<typeof IntegrationsCreateSchema>;
|
||||||
|
|
||||||
|
export const IntegrationsUpdateSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
/** Id of integration that needs update */
|
||||||
|
id: z.string().uuid(),
|
||||||
|
|
||||||
|
/** Integration config/settings */
|
||||||
|
settings: z
|
||||||
|
.object({ url: z.string().url() })
|
||||||
|
.or(
|
||||||
|
z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
channel: z.string(),
|
||||||
|
channelId: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.or(z.object({ measurementId: z.string() }))
|
||||||
|
.or(z.object({ serviceTeamId: z.string() }))
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
/** Integration events */
|
||||||
|
events: z.array(z.string()).optional().default([]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IntegrationsUpdateReq = z.infer<typeof IntegrationsUpdateSchema>;
|
||||||
|
|
||||||
|
export const IntegrationsDeleteSchema = BaseSchema.extend({
|
||||||
|
body: z.object({
|
||||||
|
/** Id of integration to be deleted */
|
||||||
|
id: z.string().uuid(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IntegrationsDeleteReq = z.infer<typeof IntegrationsDeleteSchema>;
|
||||||
@@ -95,13 +95,14 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
|||||||
? { measurementId: string }
|
? { measurementId: string }
|
||||||
: T extends IntegrationType.Post
|
: T extends IntegrationType.Post
|
||||||
? { url: string; channel: string; channelId: string }
|
? { url: string; channel: string; channelId: string }
|
||||||
: T extends IntegrationType.Post
|
: T extends IntegrationType.Command
|
||||||
? { serviceTeamId: string }
|
? { serviceTeamId: string }
|
||||||
:
|
:
|
||||||
| { url: string }
|
| { url: string }
|
||||||
| { url: string; channel: string; channelId: string }
|
| { url: string; channel: string; channelId: string }
|
||||||
| { serviceTeamId: string }
|
| { serviceTeamId: string }
|
||||||
| { measurementId: string };
|
| { measurementId: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
export enum UserPreference {
|
export enum UserPreference {
|
||||||
/** Whether reopening the app should redirect to the last viewed document. */
|
/** Whether reopening the app should redirect to the last viewed document. */
|
||||||
|
|||||||
Reference in New Issue
Block a user