diff --git a/server/routes/api/integrations.test.ts b/server/routes/api/integrations.test.ts deleted file mode 100644 index 85724ce19..000000000 --- a/server/routes/api/integrations.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/server/routes/api/integrations/index.ts b/server/routes/api/integrations/index.ts new file mode 100644 index 000000000..86c094847 --- /dev/null +++ b/server/routes/api/integrations/index.ts @@ -0,0 +1 @@ +export { default } from "./integrations"; diff --git a/server/routes/api/integrations/integrations.test.ts b/server/routes/api/integrations/integrations.test.ts new file mode 100644 index 000000000..792e83abb --- /dev/null +++ b/server/routes/api/integrations/integrations.test.ts @@ -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(); + }); +}); diff --git a/server/routes/api/integrations.ts b/server/routes/api/integrations/integrations.ts similarity index 57% rename from server/routes/api/integrations.ts rename to server/routes/api/integrations/integrations.ts index b3c331407..f45a6a485 100644 --- a/server/routes/api/integrations.ts +++ b/server/routes/api/integrations/integrations.ts @@ -1,23 +1,16 @@ import Router from "koa-router"; -import { has } from "lodash"; import { WhereOptions } from "sequelize"; import { IntegrationType } from "@shared/types"; import auth from "@server/middlewares/authentication"; -import { Event } from "@server/models"; -import Integration, { - UserCreatableIntegrationService, -} from "@server/models/Integration"; +import { transaction } from "@server/middlewares/transaction"; +import validate from "@server/middlewares/validate"; +import { Event, IntegrationAuthentication } from "@server/models"; +import Integration from "@server/models/Integration"; import { authorize } from "@server/policies"; import { presentIntegration } from "@server/presenters"; import { APIContext } from "@server/types"; -import { - assertSort, - assertUuid, - assertArray, - assertIn, - assertUrl, -} from "@server/validation"; -import pagination from "./middlewares/pagination"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; const router = new Router(); @@ -25,21 +18,16 @@ router.post( "integrations.list", auth(), pagination(), - async (ctx: APIContext) => { - let { direction } = ctx.request.body; + validate(T.IntegrationsListSchema), + async (ctx: APIContext) => { + const { direction, type, sort } = ctx.input.body; const { user } = ctx.state.auth; - const { type, sort = "updatedAt" } = ctx.request.body; - if (direction !== "ASC") { - direction = "DESC"; - } - assertSort(sort, Integration); let where: WhereOptions = { teamId: user.teamId, }; if (type) { - assertIn(type, Object.values(IntegrationType)); where = { ...where, type, @@ -63,20 +51,13 @@ router.post( router.post( "integrations.create", auth({ admin: true }), - async (ctx: APIContext) => { - const { type, service, settings } = ctx.request.body; - - assertIn(type, Object.values(IntegrationType)); - + validate(T.IntegrationsCreateSchema), + async (ctx: APIContext) => { + const { type, service, settings } = ctx.input.body; const { user } = ctx.state.auth; + authorize(user, "createIntegration", user.team); - assertIn(service, Object.values(UserCreatableIntegrationService)); - - if (has(settings, "url")) { - assertUrl(settings.url); - } - const integration = await Integration.create({ userId: user.id, teamId: user.teamId, @@ -94,20 +75,14 @@ router.post( router.post( "integrations.update", auth({ admin: true }), - async (ctx: APIContext) => { - const { id, events = [], settings } = ctx.request.body; - assertUuid(id, "id is required"); - + validate(T.IntegrationsUpdateSchema), + async (ctx: APIContext) => { + const { id, events, settings } = ctx.input.body; const { user } = ctx.state.auth; + const integration = await Integration.findByPk(id); authorize(user, "update", integration); - assertArray(events, "events must be an array"); - - if (has(settings, "url")) { - assertUrl(settings.url); - } - if (integration.type === IntegrationType.Post) { integration.events = events.filter((event: string) => ["documents.update", "documents.publish"].includes(event) @@ -127,22 +102,37 @@ router.post( router.post( "integrations.delete", auth({ admin: true }), - async (ctx: APIContext) => { - const { id } = ctx.request.body; - assertUuid(id, "id is required"); - + validate(T.IntegrationsDeleteSchema), + transaction(), + async (ctx: APIContext) => { + const { id } = ctx.input.body; 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); - await integration.destroy(); - await Event.create({ - name: "integrations.delete", - modelId: integration.id, - teamId: integration.teamId, - actorId: user.id, - ip: ctx.request.ip, - }); + await integration.destroy({ transaction }); + // 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", + modelId: integration.id, + teamId: integration.teamId, + actorId: user.id, + ip: ctx.request.ip, + }, + { transaction } + ); ctx.body = { success: true, diff --git a/server/routes/api/integrations/schema.ts b/server/routes/api/integrations/schema.ts new file mode 100644 index 000000000..4b9377f96 --- /dev/null +++ b/server/routes/api/integrations/schema.ts @@ -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; + +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; + +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; + +export const IntegrationsDeleteSchema = BaseSchema.extend({ + body: z.object({ + /** Id of integration to be deleted */ + id: z.string().uuid(), + }), +}); + +export type IntegrationsDeleteReq = z.infer; diff --git a/shared/types.ts b/shared/types.ts index a01325862..a2af6fd22 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -95,13 +95,14 @@ export type IntegrationSettings = T extends IntegrationType.Embed ? { measurementId: string } : T extends IntegrationType.Post ? { url: string; channel: string; channelId: string } - : T extends IntegrationType.Post + : T extends IntegrationType.Command ? { serviceTeamId: string } : | { url: string } | { url: string; channel: string; channelId: string } | { serviceTeamId: string } - | { measurementId: string }; + | { measurementId: string } + | undefined; export enum UserPreference { /** Whether reopening the app should redirect to the last viewed document. */