diff --git a/packages/server/__mocks__/node-fetch.ts b/packages/server/__mocks__/node-fetch.ts deleted file mode 100644 index c556d0f2e9..0000000000 --- a/packages/server/__mocks__/node-fetch.ts +++ /dev/null @@ -1,206 +0,0 @@ -// @ts-ignore -import fs from "fs" - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -module FetchMock { - // @ts-ignore - const fetch = jest.requireActual("node-fetch") - let failCount = 0 - let mockSearch = false - - const func = async (url: any, opts: any) => { - const { host, pathname } = new URL(url) - function json(body: any, status = 200) { - return { - status, - headers: { - raw: () => { - return { "content-type": ["application/json"] } - }, - get: (name: string) => { - if (name.toLowerCase() === "content-type") { - return ["application/json"] - } - }, - }, - json: async () => { - //x-www-form-encoded body is a URLSearchParams - //The call to stringify it leaves it blank - if (body?.opts?.body instanceof URLSearchParams) { - const paramArray = Array.from(body.opts.body.entries()) - body.opts.body = paramArray.reduce((acc: any, pair: any) => { - acc[pair[0]] = pair[1] - return acc - }, {}) - } - return body - }, - } - } - - if (pathname.includes("/api/global")) { - const user = { - email: "test@example.com", - _id: "us_test@example.com", - status: "active", - roles: {}, - builder: { - global: false, - }, - admin: { - global: false, - }, - } - return pathname.endsWith("/users") && opts.method === "GET" - ? json([user]) - : json(user) - } - // mocked data based on url - else if (pathname.includes("api/apps")) { - return json({ - app1: { - url: "/app1", - }, - }) - } else if (host.includes("example.com")) { - return json({ - body: opts.body, - url, - method: opts.method, - }) - } else if (host.includes("invalid.com")) { - return json( - { - invalid: true, - }, - 404 - ) - } else if (mockSearch && pathname.includes("_search")) { - const body = opts.body - const parts = body.split("tableId:") - let tableId - if (parts && parts[1]) { - tableId = parts[1].split('"')[0] - } - return json({ - rows: [ - { - doc: { - _id: "test", - tableId: tableId, - query: opts.body, - }, - }, - ], - bookmark: "test", - }) - } else if (host.includes("google.com")) { - return json({ - url, - opts, - value: - '', - }) - } else if ( - url === "https://api.github.com/repos/my-repo/budibase-comment-box" - ) { - return Promise.resolve({ - json: () => { - return { - name: "budibase-comment-box", - releases_url: - "https://api.github.com/repos/my-repo/budibase-comment-box{/id}", - } - }, - }) - } else if ( - url === "https://api.github.com/repos/my-repo/budibase-comment-box/latest" - ) { - return Promise.resolve({ - json: () => { - return { - assets: [ - { - content_type: "application/gzip", - browser_download_url: - "https://github.com/my-repo/budibase-comment-box/releases/download/v1.0.2/comment-box-1.0.2.tar.gz", - }, - ], - } - }, - }) - } else if ( - url === - "https://github.com/my-repo/budibase-comment-box/releases/download/v1.0.2/comment-box-1.0.2.tar.gz" - ) { - return Promise.resolve({ - body: fs.createReadStream( - "src/api/routes/tests/data/comment-box-1.0.2.tar.gz" - ), - ok: true, - }) - } else if (url === "https://www.npmjs.com/package/budibase-component") { - return Promise.resolve({ - status: 200, - json: () => { - return { - name: "budibase-component", - "dist-tags": { - latest: "1.0.0", - }, - versions: { - "1.0.0": { - dist: { - tarball: - "https://registry.npmjs.org/budibase-component/-/budibase-component-1.0.2.tgz", - }, - }, - }, - } - }, - }) - } else if ( - url === - "https://registry.npmjs.org/budibase-component/-/budibase-component-1.0.2.tgz" - ) { - return Promise.resolve({ - body: fs.createReadStream( - "src/api/routes/tests/data/budibase-component-1.0.2.tgz" - ), - ok: true, - }) - } else if ( - url === "https://www.someurl.com/comment-box/comment-box-1.0.2.tar.gz" - ) { - return Promise.resolve({ - body: fs.createReadStream( - "src/api/routes/tests/data/comment-box-1.0.2.tar.gz" - ), - ok: true, - }) - } else if (url === "https://www.googleapis.com/oauth2/v4/token") { - // any valid response - return json({}) - } else if (host.includes("failonce.com")) { - failCount++ - if (failCount === 1) { - return json({ message: "error" }, 500) - } else { - return json({ - fails: failCount - 1, - url, - opts, - }) - } - } - return fetch(url, opts) - } - - func.Headers = fetch.Headers - - func.mockSearch = () => { - mockSearch = true - } - - module.exports = func -} diff --git a/packages/server/src/api/controllers/plugin/index.ts b/packages/server/src/api/controllers/plugin/index.ts index c7d4912db3..e1c51f0219 100644 --- a/packages/server/src/api/controllers/plugin/index.ts +++ b/packages/server/src/api/controllers/plugin/index.ts @@ -1,6 +1,13 @@ import { npmUpload, urlUpload, githubUpload } from "./uploaders" import { plugins as pluginCore } from "@budibase/backend-core" -import { PluginType, FileType, PluginSource } from "@budibase/types" +import { + PluginType, + FileType, + PluginSource, + Ctx, + CreatePluginRequest, + CreatePluginResponse, +} from "@budibase/types" import env from "../../../environment" import { clientAppSocket } from "../../../websockets" import sdk from "../../../sdk" @@ -29,7 +36,9 @@ export async function upload(ctx: any) { } } -export async function create(ctx: any) { +export async function create( + ctx: Ctx +) { const { source, url, headers, githubToken } = ctx.request.body try { @@ -75,14 +84,9 @@ export async function create(ctx: any) { const doc = await pro.plugins.storePlugin(metadata, directory, source) clientAppSocket?.emit("plugins-update", { name, hash: doc.hash }) - ctx.body = { - message: "Plugin uploaded successfully", - plugins: [doc], - } ctx.body = { plugin: doc } } catch (err: any) { const errMsg = err?.message ? err?.message : err - ctx.throw(400, `Failed to import plugin: ${errMsg}`) } } diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts index 13b7451a7e..6ae598ee84 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -20,6 +20,7 @@ import { type App } from "@budibase/types" import tk from "timekeeper" import * as uuid from "uuid" import { structures } from "@budibase/backend-core/tests" +import nock from "nock" describe("/applications", () => { let config = setup.getConfig() @@ -35,6 +36,7 @@ describe("/applications", () => { throw new Error("Failed to publish app") } jest.clearAllMocks() + nock.cleanAll() }) // These need to go first for the app totals to make sense @@ -324,18 +326,33 @@ describe("/applications", () => { describe("delete", () => { it("should delete published app and dev apps with dev app ID", async () => { + const prodAppId = app.appId.replace("_dev", "") + nock("http://localhost:10000") + .delete(`/api/global/roles/${prodAppId}`) + .reply(200, {}) + await config.api.application.delete(app.appId) expect(events.app.deleted).toHaveBeenCalledTimes(1) expect(events.app.unpublished).toHaveBeenCalledTimes(1) }) it("should delete published app and dev app with prod app ID", async () => { - await config.api.application.delete(app.appId.replace("_dev", "")) + const prodAppId = app.appId.replace("_dev", "") + nock("http://localhost:10000") + .delete(`/api/global/roles/${prodAppId}`) + .reply(200, {}) + + await config.api.application.delete(prodAppId) expect(events.app.deleted).toHaveBeenCalledTimes(1) expect(events.app.unpublished).toHaveBeenCalledTimes(1) }) it("should be able to delete an app after SQS_SEARCH_ENABLE has been set but app hasn't been migrated", async () => { + const prodAppId = app.appId.replace("_dev", "") + nock("http://localhost:10000") + .delete(`/api/global/roles/${prodAppId}`) + .reply(200, {}) + await config.withCoreEnv({ SQS_SEARCH_ENABLE: "true" }, async () => { await config.api.application.delete(app.appId) }) diff --git a/packages/server/src/api/routes/tests/datasource.spec.ts b/packages/server/src/api/routes/tests/datasource.spec.ts index 255e46167f..4ca766247b 100644 --- a/packages/server/src/api/routes/tests/datasource.spec.ts +++ b/packages/server/src/api/routes/tests/datasource.spec.ts @@ -19,6 +19,7 @@ import { } from "@budibase/types" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { tableForDatasource } from "../../../tests/utilities/structures" +import nock from "nock" describe("/datasources", () => { const config = setup.getConfig() @@ -37,6 +38,7 @@ describe("/datasources", () => { config: {}, }) jest.clearAllMocks() + nock.cleanAll() }) describe("create", () => { @@ -71,6 +73,12 @@ describe("/datasources", () => { describe("dynamic variables", () => { it("should invalidate changed or removed variables", async () => { + nock("http://www.example.com/") + .get("/") + .reply(200, [{ value: "test" }]) + .get("/?test=test") + .reply(200, [{ value: 1 }]) + let datasource = await config.api.datasource.create({ type: "datasource", name: "Rest", @@ -81,7 +89,7 @@ describe("/datasources", () => { const query = await config.api.query.save({ datasourceId: datasource._id!, fields: { - path: "www.google.com", + path: "www.example.com", }, parameters: [], transformer: null, diff --git a/packages/server/src/api/routes/tests/plugin.spec.ts b/packages/server/src/api/routes/tests/plugin.spec.ts index 788d3cf349..70bbfd3cea 100644 --- a/packages/server/src/api/routes/tests/plugin.spec.ts +++ b/packages/server/src/api/routes/tests/plugin.spec.ts @@ -15,6 +15,8 @@ jest.mock("@budibase/backend-core", () => { import { events, objectStore } from "@budibase/backend-core" import * as setup from "./utilities" +import nock from "nock" +import { PluginSource } from "@budibase/types" const mockUploadDirectory = objectStore.uploadDirectory as jest.Mock const mockDeleteFolder = objectStore.deleteFolder as jest.Mock @@ -28,6 +30,7 @@ describe("/plugins", () => { beforeEach(async () => { await config.init() jest.clearAllMocks() + nock.cleanAll() }) const createPlugin = async (status?: number) => { @@ -112,67 +115,108 @@ describe("/plugins", () => { }) describe("github", () => { - const createGithubPlugin = async (status?: number, url?: string) => { - return await request - .post(`/api/plugin`) - .send({ - source: "Github", - url, - githubToken: "token", + beforeEach(async () => { + nock("https://api.github.com") + .get("/repos/my-repo/budibase-comment-box") + .reply(200, { + name: "budibase-comment-box", + releases_url: + "https://api.github.com/repos/my-repo/budibase-comment-box{/id}", }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(status ? status : 200) - } - it("should be able to create a plugin from github", async () => { - const res = await createGithubPlugin( - 200, - "https://github.com/my-repo/budibase-comment-box.git" - ) - expect(res.body).toBeDefined() - expect(res.body.plugin).toBeDefined() - expect(res.body.plugin._id).toEqual("plg_comment-box") + .get("/repos/my-repo/budibase-comment-box/latest") + .reply(200, { + assets: [ + { + content_type: "application/gzip", + browser_download_url: + "https://github.com/my-repo/budibase-comment-box/releases/download/v1.0.2/comment-box-1.0.2.tar.gz", + }, + ], + }) + + nock("https://github.com") + .get( + "/my-repo/budibase-comment-box/releases/download/v1.0.2/comment-box-1.0.2.tar.gz" + ) + .replyWithFile( + 200, + "src/api/routes/tests/data/comment-box-1.0.2.tar.gz" + ) }) + + it("should be able to create a plugin from github", async () => { + const { plugin } = await config.api.plugin.create({ + source: PluginSource.GITHUB, + url: "https://github.com/my-repo/budibase-comment-box.git", + githubToken: "token", + }) + expect(plugin._id).toEqual("plg_comment-box") + }) + it("should fail if the url is not from github", async () => { - const res = await createGithubPlugin( - 400, - "https://notgithub.com/my-repo/budibase-comment-box" - ) - expect(res.body.message).toEqual( - "Failed to import plugin: The plugin origin must be from Github" + await config.api.plugin.create( + { + source: PluginSource.GITHUB, + url: "https://notgithub.com/my-repo/budibase-comment-box", + githubToken: "token", + }, + { + status: 400, + body: { + message: + "Failed to import plugin: The plugin origin must be from Github", + }, + } ) }) }) describe("npm", () => { it("should be able to create a plugin from npm", async () => { - const res = await request - .post(`/api/plugin`) - .send({ - source: "NPM", - url: "https://www.npmjs.com/package/budibase-component", + nock("https://registry.npmjs.org") + .get("/budibase-component") + .reply(200, { + name: "budibase-component", + "dist-tags": { + latest: "1.0.0", + }, + versions: { + "1.0.0": { + dist: { + tarball: + "https://registry.npmjs.org/budibase-component/-/budibase-component-1.0.1.tgz", + }, + }, + }, }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect(res.body).toBeDefined() - expect(res.body.plugin._id).toEqual("plg_budibase-component") + .get("/budibase-component/-/budibase-component-1.0.1.tgz") + .replyWithFile( + 200, + "src/api/routes/tests/data/budibase-component-1.0.1.tgz" + ) + + const { plugin } = await config.api.plugin.create({ + source: PluginSource.NPM, + url: "https://www.npmjs.com/package/budibase-component", + }) + expect(plugin._id).toEqual("plg_budibase-component") expect(events.plugin.imported).toHaveBeenCalled() }) }) describe("url", () => { it("should be able to create a plugin from a URL", async () => { - const res = await request - .post(`/api/plugin`) - .send({ - source: "URL", - url: "https://www.someurl.com/comment-box/comment-box-1.0.2.tar.gz", - }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect(res.body).toBeDefined() - expect(res.body.plugin._id).toEqual("plg_comment-box") + nock("https://www.someurl.com") + .get("/comment-box/comment-box-1.0.2.tar.gz") + .replyWithFile( + 200, + "src/api/routes/tests/data/comment-box-1.0.2.tar.gz" + ) + + const { plugin } = await config.api.plugin.create({ + source: PluginSource.URL, + url: "https://www.someurl.com/comment-box/comment-box-1.0.2.tar.gz", + }) + expect(plugin._id).toEqual("plg_comment-box") expect(events.plugin.imported).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/server/src/api/routes/tests/queries/rest.spec.ts b/packages/server/src/api/routes/tests/queries/rest.spec.ts index 29bbbf3a61..6f489bc723 100644 --- a/packages/server/src/api/routes/tests/queries/rest.spec.ts +++ b/packages/server/src/api/routes/tests/queries/rest.spec.ts @@ -5,8 +5,6 @@ import { getCachedVariable } from "../../../../threads/utils" import nock from "nock" import { generator } from "@budibase/backend-core/tests" -jest.unmock("node-fetch") - describe("rest", () => { let config: TestConfiguration let datasource: Datasource diff --git a/packages/server/src/automations/tests/discord.spec.js b/packages/server/src/automations/tests/discord.spec.js deleted file mode 100644 index 84c7e6f46e..0000000000 --- a/packages/server/src/automations/tests/discord.spec.js +++ /dev/null @@ -1,26 +0,0 @@ -const setup = require("./utilities") -const fetch = require("node-fetch") - -jest.mock("node-fetch") - -describe("test the outgoing webhook action", () => { - let inputs - let config = setup.getConfig() - - beforeAll(async () => { - await config.init() - inputs = { - username: "joe_bloggs", - url: "http://www.example.com", - } - }) - - afterAll(setup.afterAll) - - it("should be able to run the action", async () => { - const res = await setup.runStep(setup.actions.discord.stepId, inputs) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("post") - expect(res.success).toEqual(true) - }) -}) diff --git a/packages/server/src/automations/tests/discord.spec.ts b/packages/server/src/automations/tests/discord.spec.ts new file mode 100644 index 0000000000..07eab7205c --- /dev/null +++ b/packages/server/src/automations/tests/discord.spec.ts @@ -0,0 +1,26 @@ +import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities" +import nock from "nock" + +describe("test the outgoing webhook action", () => { + let config = getConfig() + + beforeAll(async () => { + await config.init() + }) + + afterAll(_afterAll) + + beforeEach(() => { + nock.cleanAll() + }) + + it("should be able to run the action", async () => { + nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) + const res = await runStep(actions.discord.stepId, { + url: "http://www.example.com", + username: "joe_bloggs", + }) + expect(res.response.foo).toEqual("bar") + expect(res.success).toEqual(true) + }) +}) diff --git a/packages/server/src/automations/tests/make.spec.ts b/packages/server/src/automations/tests/make.spec.ts index 62474ae2c0..388b197c7f 100644 --- a/packages/server/src/automations/tests/make.spec.ts +++ b/packages/server/src/automations/tests/make.spec.ts @@ -1,4 +1,5 @@ import { getConfig, afterAll, runStep, actions } from "./utilities" +import nock from "nock" describe("test the outgoing webhook action", () => { let config = getConfig() @@ -9,42 +10,45 @@ describe("test the outgoing webhook action", () => { afterAll() + beforeEach(() => { + nock.cleanAll() + }) + it("should be able to run the action", async () => { + nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) const res = await runStep(actions.integromat.stepId, { - value1: "test", url: "http://www.example.com", }) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("post") + expect(res.response.foo).toEqual("bar") expect(res.success).toEqual(true) }) it("should add the payload props when a JSON string is provided", async () => { - const payload = `{"value1":1,"value2":2,"value3":3,"value4":4,"value5":5,"name":"Adam","age":9}` + const payload = { + value1: 1, + value2: 2, + value3: 3, + value4: 4, + value5: 5, + name: "Adam", + age: 9, + } + + nock("http://www.example.com/") + .post("/", payload) + .reply(200, { foo: "bar" }) + const res = await runStep(actions.integromat.stepId, { - value1: "ONE", - value2: "TWO", - value3: "THREE", - value4: "FOUR", - value5: "FIVE", - body: { - value: payload, - }, + body: { value: JSON.stringify(payload) }, url: "http://www.example.com", }) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("post") - expect(res.response.body).toEqual(payload) + expect(res.response.foo).toEqual("bar") expect(res.success).toEqual(true) }) it("should return a 400 if the JSON payload string is malformed", async () => { - const payload = `{ value1 1 }` const res = await runStep(actions.integromat.stepId, { - value1: "ONE", - body: { - value: payload, - }, + body: { value: "{ invalid json }" }, url: "http://www.example.com", }) expect(res.httpStatus).toEqual(400) diff --git a/packages/server/src/automations/tests/n8n.spec.ts b/packages/server/src/automations/tests/n8n.spec.ts index d60a08b53b..0c18f313b1 100644 --- a/packages/server/src/automations/tests/n8n.spec.ts +++ b/packages/server/src/automations/tests/n8n.spec.ts @@ -1,4 +1,5 @@ import { getConfig, afterAll, runStep, actions } from "./utilities" +import nock from "nock" describe("test the outgoing webhook action", () => { let config = getConfig() @@ -9,31 +10,33 @@ describe("test the outgoing webhook action", () => { afterAll() + beforeEach(() => { + nock.cleanAll() + }) + it("should be able to run the action and default to 'get'", async () => { + nock("http://www.example.com/").get("/").reply(200, { foo: "bar" }) const res = await runStep(actions.n8n.stepId, { url: "http://www.example.com", body: { test: "IGNORE_ME", }, }) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("GET") - expect(res.response.body).toBeUndefined() + expect(res.response.foo).toEqual("bar") expect(res.success).toEqual(true) }) it("should add the payload props when a JSON string is provided", async () => { - const payload = `{ "name": "Adam", "age": 9 }` + nock("http://www.example.com/") + .post("/", { name: "Adam", age: 9 }) + .reply(200) const res = await runStep(actions.n8n.stepId, { body: { - value: payload, + value: JSON.stringify({ name: "Adam", age: 9 }), }, method: "POST", url: "http://www.example.com", }) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("POST") - expect(res.response.body).toEqual(`{"name":"Adam","age":9}`) expect(res.success).toEqual(true) }) @@ -53,6 +56,9 @@ describe("test the outgoing webhook action", () => { }) it("should not append the body if the method is HEAD", async () => { + nock("http://www.example.com/") + .head("/", body => body === "") + .reply(200) const res = await runStep(actions.n8n.stepId, { url: "http://www.example.com", method: "HEAD", @@ -60,9 +66,6 @@ describe("test the outgoing webhook action", () => { test: "IGNORE_ME", }, }) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("HEAD") - expect(res.response.body).toBeUndefined() expect(res.success).toEqual(true) }) }) diff --git a/packages/server/src/automations/tests/outgoingWebhook.spec.js b/packages/server/src/automations/tests/outgoingWebhook.spec.js deleted file mode 100644 index 06fe2e0a38..0000000000 --- a/packages/server/src/automations/tests/outgoingWebhook.spec.js +++ /dev/null @@ -1,41 +0,0 @@ -const setup = require("./utilities") -const fetch = require("node-fetch") - -jest.mock("node-fetch") - -describe("test the outgoing webhook action", () => { - let inputs - let config = setup.getConfig() - - beforeAll(async () => { - await config.init() - inputs = { - requestMethod: "POST", - url: "www.example.com", - requestBody: JSON.stringify({ - a: 1, - }), - } - }) - - afterAll(setup.afterAll) - - it("should be able to run the action", async () => { - const res = await setup.runStep( - setup.actions.OUTGOING_WEBHOOK.stepId, - inputs - ) - expect(res.success).toEqual(true) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("POST") - expect(JSON.parse(res.response.body).a).toEqual(1) - }) - - it("should return an error if something goes wrong in fetch", async () => { - const res = await setup.runStep(setup.actions.OUTGOING_WEBHOOK.stepId, { - requestMethod: "GET", - url: "www.invalid.com", - }) - expect(res.success).toEqual(false) - }) -}) diff --git a/packages/server/src/automations/tests/outgoingWebhook.spec.ts b/packages/server/src/automations/tests/outgoingWebhook.spec.ts new file mode 100644 index 0000000000..0e26927c55 --- /dev/null +++ b/packages/server/src/automations/tests/outgoingWebhook.spec.ts @@ -0,0 +1,37 @@ +import { getConfig, afterAll as _afterAll, runStep, actions } from "./utilities" +import nock from "nock" + +describe("test the outgoing webhook action", () => { + const config = getConfig() + + beforeAll(async () => { + await config.init() + }) + + afterAll(_afterAll) + + beforeEach(() => { + nock.cleanAll() + }) + + it("should be able to run the action", async () => { + nock("http://www.example.com") + .post("/", { a: 1 }) + .reply(200, { foo: "bar" }) + const res = await runStep(actions.OUTGOING_WEBHOOK.stepId, { + requestMethod: "POST", + url: "www.example.com", + requestBody: JSON.stringify({ a: 1 }), + }) + expect(res.success).toEqual(true) + expect(res.response.foo).toEqual("bar") + }) + + it("should return an error if something goes wrong in fetch", async () => { + const res = await runStep(actions.OUTGOING_WEBHOOK.stepId, { + requestMethod: "GET", + url: "www.invalid.com", + }) + expect(res.success).toEqual(false) + }) +}) diff --git a/packages/server/src/automations/tests/zapier.spec.ts b/packages/server/src/automations/tests/zapier.spec.ts index 994df3dc99..a7dc7d3eae 100644 --- a/packages/server/src/automations/tests/zapier.spec.ts +++ b/packages/server/src/automations/tests/zapier.spec.ts @@ -1,4 +1,5 @@ import { getConfig, afterAll, runStep, actions } from "./utilities" +import nock from "nock" describe("test the outgoing webhook action", () => { let config = getConfig() @@ -9,44 +10,45 @@ describe("test the outgoing webhook action", () => { afterAll() + beforeEach(() => { + nock.cleanAll() + }) + it("should be able to run the action", async () => { + nock("http://www.example.com/").post("/").reply(200, { foo: "bar" }) const res = await runStep(actions.zapier.stepId, { - value1: "test", url: "http://www.example.com", }) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("post") + expect(res.response.foo).toEqual("bar") expect(res.success).toEqual(true) }) it("should add the payload props when a JSON string is provided", async () => { - const payload = `{ "value1": 1, "value2": 2, "value3": 3, "value4": 4, "value5": 5, "name": "Adam", "age": 9 }` + const payload = { + value1: 1, + value2: 2, + value3: 3, + value4: 4, + value5: 5, + name: "Adam", + age: 9, + } + + nock("http://www.example.com/") + .post("/", { ...payload, platform: "budibase" }) + .reply(200, { foo: "bar" }) + const res = await runStep(actions.zapier.stepId, { - value1: "ONE", - value2: "TWO", - value3: "THREE", - value4: "FOUR", - value5: "FIVE", - body: { - value: payload, - }, + body: { value: JSON.stringify(payload) }, url: "http://www.example.com", }) - expect(res.response.url).toEqual("http://www.example.com") - expect(res.response.method).toEqual("post") - expect(res.response.body).toEqual( - `{"platform":"budibase","value1":1,"value2":2,"value3":3,"value4":4,"value5":5,"name":"Adam","age":9}` - ) + expect(res.response.foo).toEqual("bar") expect(res.success).toEqual(true) }) it("should return a 400 if the JSON payload string is malformed", async () => { - const payload = `{ value1 1 }` const res = await runStep(actions.zapier.stepId, { - value1: "ONE", - body: { - value: payload, - }, + body: { value: "{ invalid json }" }, url: "http://www.example.com", }) expect(res.httpStatus).toEqual(400) diff --git a/packages/server/src/integrations/rest.ts b/packages/server/src/integrations/rest.ts index 86c059bc82..ce2ec7d545 100644 --- a/packages/server/src/integrations/rest.ts +++ b/packages/server/src/integrations/rest.ts @@ -1,4 +1,5 @@ import { + BodyType, DatasourceFieldType, HttpMethod, Integration, @@ -15,7 +16,7 @@ import { import get from "lodash/get" import * as https from "https" import qs from "querystring" -import type { Response } from "node-fetch" +import type { Response, RequestInit } from "node-fetch" import fetch from "node-fetch" import { formatBytes } from "../utilities" import { performance } from "perf_hooks" @@ -28,15 +29,6 @@ import path from "path" import { Builder as XmlBuilder } from "xml2js" import { getAttachmentHeaders } from "./utils/restUtils" -enum BodyType { - NONE = "none", - FORM_DATA = "form", - XML = "xml", - ENCODED = "encoded", - JSON = "json", - TEXT = "text", -} - const coreFields = { path: { type: DatasourceFieldType.STRING, @@ -127,7 +119,23 @@ const SCHEMA: Integration = { }, } -class RestIntegration implements IntegrationBase { +interface ParsedResponse { + data: any + info: { + code: number + size: string + time: string + } + extra?: { + raw: string | undefined + headers: Record + } + pagination?: { + cursor: any + } +} + +export class RestIntegration implements IntegrationBase { private config: RestConfig private headers: { [key: string]: string @@ -138,7 +146,10 @@ class RestIntegration implements IntegrationBase { this.config = config } - async parseResponse(response: Response, pagination: PaginationConfig | null) { + async parseResponse( + response: Response, + pagination?: PaginationConfig + ): Promise { let data: any[] | string | undefined, raw: string | undefined, headers: Record = {}, @@ -235,8 +246,8 @@ class RestIntegration implements IntegrationBase { getUrl( path: string, queryString: string, - pagination: PaginationConfig | null, - paginationValues: PaginationValues | null + pagination?: PaginationConfig, + paginationValues?: PaginationValues ): string { // Add pagination params to query string if required if (pagination?.location === "query" && paginationValues) { @@ -279,10 +290,10 @@ class RestIntegration implements IntegrationBase { addBody( bodyType: string, body: string | any, - input: any, - pagination: PaginationConfig | null, - paginationValues: PaginationValues | null - ) { + input: RequestInit, + pagination?: PaginationConfig, + paginationValues?: PaginationValues + ): RequestInit { if (!input.headers) { input.headers = {} } @@ -345,6 +356,7 @@ class RestIntegration implements IntegrationBase { string = new XmlBuilder().buildObject(object) } input.body = string + // @ts-ignore input.headers["Content-Type"] = "application/xml" break case BodyType.JSON: @@ -356,13 +368,14 @@ class RestIntegration implements IntegrationBase { object[key] = value }) input.body = JSON.stringify(object) + // @ts-ignore input.headers["Content-Type"] = "application/json" break } return input } - getAuthHeaders(authConfigId: string): { [key: string]: any } { + getAuthHeaders(authConfigId?: string): { [key: string]: any } { let headers: any = {} if (this.config.authConfigs && authConfigId) { @@ -398,7 +411,7 @@ class RestIntegration implements IntegrationBase { headers = {}, method = HttpMethod.GET, disabledHeaders, - bodyType, + bodyType = BodyType.NONE, requestBody, authConfigId, pagination, @@ -407,7 +420,7 @@ class RestIntegration implements IntegrationBase { const authHeaders = this.getAuthHeaders(authConfigId) this.headers = { - ...this.config.defaultHeaders, + ...(this.config.defaultHeaders || {}), ...headers, ...authHeaders, } @@ -420,7 +433,7 @@ class RestIntegration implements IntegrationBase { } } - let input: any = { method, headers: this.headers } + let input: RequestInit = { method, headers: this.headers } input = this.addBody( bodyType, requestBody, @@ -437,7 +450,12 @@ class RestIntegration implements IntegrationBase { // Deprecated by rejectUnauthorized if (this.config.legacyHttpParser) { + // NOTE(samwho): it seems like this code doesn't actually work because it requires + // node-fetch >=3, and we're not on that because upgrading to it produces errors to + // do with ESM that are above my pay grade. + // https://github.com/nodejs/node/issues/43798 + // @ts-ignore input.extraHttpOptions = { insecureHTTPParser: true } } diff --git a/packages/server/src/integrations/tests/googlesheets.spec.ts b/packages/server/src/integrations/tests/googlesheets.spec.ts index 97ac35787d..9b1ea815f5 100644 --- a/packages/server/src/integrations/tests/googlesheets.spec.ts +++ b/packages/server/src/integrations/tests/googlesheets.spec.ts @@ -1,4 +1,5 @@ import type { GoogleSpreadsheetWorksheet } from "google-spreadsheet" +import nock from "nock" jest.mock("google-auth-library") const { OAuth2Client } = require("google-auth-library") @@ -62,6 +63,13 @@ describe("Google Sheets Integration", () => { await config.init() jest.clearAllMocks() + + nock.cleanAll() + nock("https://www.googleapis.com/").post("/oauth2/v4/token").reply(200, { + grant_type: "client_credentials", + client_id: "your-client-id", + client_secret: "your-client-secret", + }) }) function createBasicTable(name: string, columns: string[]): Table { diff --git a/packages/server/src/integrations/tests/rest.spec.ts b/packages/server/src/integrations/tests/rest.spec.ts index dee17a5497..e869c58875 100644 --- a/packages/server/src/integrations/tests/rest.spec.ts +++ b/packages/server/src/integrations/tests/rest.spec.ts @@ -1,281 +1,222 @@ -jest.mock("node-fetch", () => { - const obj = { - my_next_cursor: 123, - } - const str = JSON.stringify(obj) - return jest.fn(() => ({ - headers: { - raw: () => { - return { - "content-type": ["application/json"], - "content-length": str.length, - } - }, - get: (name: string) => { - const lcName = name.toLowerCase() - if (lcName === "content-type") { - return ["application/json"] - } else if (lcName === "content-length") { - return str.length - } - }, - }, - json: jest.fn(() => obj), - text: jest.fn(() => str), - })) -}) - -jest.mock("@budibase/backend-core", () => { - const core = jest.requireActual("@budibase/backend-core") - return { - ...core, - context: { - ...core.context, - getProdAppId: jest.fn(() => "app-id"), - }, - } -}) -jest.mock("uuid", () => ({ v4: () => "00000000-0000-0000-0000-000000000000" })) - -import { default as RestIntegration } from "../rest" -import { RestAuthType } from "@budibase/types" -import fetch from "node-fetch" -import { Readable } from "stream" - -const FormData = require("form-data") -const { URLSearchParams } = require("url") +import nock from "nock" +import { RestIntegration } from "../rest" +import { BodyType, RestAuthType } from "@budibase/types" +import { Response } from "node-fetch" +import TestConfiguration from "../../../src/tests/utilities/TestConfiguration" +const UUID_REGEX = + "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" const HEADERS = { Accept: "application/json", "Content-Type": "application/json", } -class TestConfiguration { - integration: any - - constructor(config: any = {}) { - this.integration = new RestIntegration.integration(config) - } -} - describe("REST Integration", () => { - const BASE_URL = "https://myapi.com" - let config: any + let integration: RestIntegration + const config = new TestConfiguration() + + beforeAll(async () => { + await config.init() + }) + + afterAll(async () => { + config.end() + }) beforeEach(() => { - config = new TestConfiguration({ - url: BASE_URL, - }) - jest.clearAllMocks() + integration = new RestIntegration({ url: "https://example.com" }) + nock.cleanAll() }) it("calls the create method with the correct params", async () => { - const query = { + const body = { name: "test" } + nock("https://example.com", { reqheaders: HEADERS }) + .post("/api?test=1", JSON.stringify(body)) + .reply(200, { foo: "bar" }) + + const { data } = await integration.create({ path: "api", queryString: "test=1", headers: HEADERS, - bodyType: "json", - requestBody: JSON.stringify({ - name: "test", - }), - } - await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { - method: "POST", - body: '{"name":"test"}', - headers: HEADERS, + bodyType: BodyType.JSON, + requestBody: JSON.stringify(body), }) + expect(data).toEqual({ foo: "bar" }) }) it("calls the read method with the correct params", async () => { - const query = { + nock("https://example.com") + .get("/api?test=1") + .matchHeader("Accept", "text/html") + .reply(200, { foo: "bar" }) + + const { data } = await integration.read({ path: "api", queryString: "test=1", headers: { Accept: "text/html", }, - } - await config.integration.read(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { - headers: { - Accept: "text/html", - }, - method: "GET", }) + expect(data).toEqual({ foo: "bar" }) }) it("calls the update method with the correct params", async () => { - const query = { + nock("https://example.com") + .put("/api?test=1", { name: "test" }) + .matchHeader("Accept", "application/json") + .reply(200, { foo: "bar" }) + + const { data } = await integration.update({ path: "api", queryString: "test=1", headers: { Accept: "application/json", }, - bodyType: "json", + bodyType: BodyType.JSON, requestBody: JSON.stringify({ name: "test", }), - } - await config.integration.update(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { - method: "PUT", - body: '{"name":"test"}', - headers: HEADERS, }) + expect(data).toEqual({ foo: "bar" }) }) it("calls the delete method with the correct params", async () => { - const query = { + nock("https://example.com") + .delete("/api?test=1", { name: "test" }) + .matchHeader("Accept", "application/json") + .reply(200, { foo: "bar" }) + + const { data } = await integration.delete({ path: "api", queryString: "test=1", headers: { Accept: "application/json", }, - bodyType: "json", + bodyType: BodyType.JSON, requestBody: JSON.stringify({ name: "test", }), - } - await config.integration.delete(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { - method: "DELETE", - headers: HEADERS, - body: '{"name":"test"}', }) + expect(data).toEqual({ foo: "bar" }) }) describe("request body", () => { const input = { a: 1, b: 2 } it("should allow no body", () => { - const output = config.integration.addBody("none", null, {}) + const output = integration.addBody("none", null, {}) expect(output.body).toBeUndefined() - expect(Object.keys(output.headers).length).toEqual(0) + expect(Object.keys(output.headers!).length).toEqual(0) }) it("should allow text body", () => { - const output = config.integration.addBody("text", "hello world", {}) + const output = integration.addBody("text", "hello world", {}) expect(output.body).toEqual("hello world") // gets added by fetch - expect(Object.keys(output.headers).length).toEqual(0) + expect(Object.keys(output.headers!).length).toEqual(0) }) it("should allow form data", () => { const FormData = require("form-data") - const output = config.integration.addBody("form", input, {}) + const output = integration.addBody("form", input, {}) expect(output.body instanceof FormData).toEqual(true) - expect(output.body._valueLength).toEqual(2) + expect((output.body! as any)._valueLength).toEqual(2) // gets added by fetch - expect(Object.keys(output.headers).length).toEqual(0) + expect(Object.keys(output.headers!).length).toEqual(0) }) it("should allow encoded form data", () => { const { URLSearchParams } = require("url") - const output = config.integration.addBody("encoded", input, {}) + const output = integration.addBody("encoded", input, {}) expect(output.body instanceof URLSearchParams).toEqual(true) - expect(output.body.toString()).toEqual("a=1&b=2") + expect(output.body!.toString()).toEqual("a=1&b=2") // gets added by fetch - expect(Object.keys(output.headers).length).toEqual(0) + expect(Object.keys(output.headers!).length).toEqual(0) }) it("should allow JSON", () => { - const output = config.integration.addBody("json", input, {}) + const output = integration.addBody("json", input, {}) expect(output.body).toEqual(JSON.stringify(input)) - expect(output.headers["Content-Type"]).toEqual("application/json") + expect((output.headers! as any)["Content-Type"]).toEqual( + "application/json" + ) }) it("should allow raw XML", () => { - const output = config.integration.addBody("xml", "12", {}) - expect(output.body.includes("1")).toEqual(true) - expect(output.body.includes("2")).toEqual(true) - expect(output.headers["Content-Type"]).toEqual("application/xml") + const output = integration.addBody("xml", "12", {}) + const body = output.body?.toString() + expect(body!.includes("1")).toEqual(true) + expect(body!.includes("2")).toEqual(true) + expect((output.headers! as any)["Content-Type"]).toEqual( + "application/xml" + ) }) it("should allow a valid js object and parse the contents to xml", () => { - const output = config.integration.addBody("xml", input, {}) - expect(output.body.includes("1")).toEqual(true) - expect(output.body.includes("2")).toEqual(true) - expect(output.headers["Content-Type"]).toEqual("application/xml") + const output = integration.addBody("xml", input, {}) + const body = output.body?.toString() + expect(body!.includes("1")).toEqual(true) + expect(body!.includes("2")).toEqual(true) + expect((output.headers! as any)["Content-Type"]).toEqual( + "application/xml" + ) }) it("should allow a valid json string and parse the contents to xml", () => { - const output = config.integration.addBody( - "xml", - JSON.stringify(input), - {} + const output = integration.addBody("xml", JSON.stringify(input), {}) + const body = output.body?.toString() + expect(body!.includes("1")).toEqual(true) + expect(body!.includes("2")).toEqual(true) + expect((output.headers! as any)["Content-Type"]).toEqual( + "application/xml" ) - expect(output.body.includes("1")).toEqual(true) - expect(output.body.includes("2")).toEqual(true) - expect(output.headers["Content-Type"]).toEqual("application/xml") }) }) describe("response", () => { - const contentTypes = ["application/json", "text/plain", "application/xml"] - function buildInput( - json: any, - text: any, - header: any, - status: number = 200 - ) { - return { - status, - json: json ? async () => json : undefined, - text: text ? async () => text : undefined, - headers: { - get: (key: string) => { - switch (key.toLowerCase()) { - case "content-length": - return 100 - case "content-type": - return header - default: - return "" - } - }, - raw: () => ({ "content-type": header }), - }, - } - } - it("should be able to parse JSON response", async () => { const obj = { a: 1 } - const input = buildInput(obj, JSON.stringify(obj), "application/json") - const output = await config.integration.parseResponse(input) - expect(output.data).toEqual({ a: 1 }) + const output = await integration.parseResponse( + new Response(JSON.stringify(obj), { + headers: { "content-type": "application/json" }, + }) + ) + expect(output.data).toEqual(obj) expect(output.info.code).toEqual(200) - expect(output.info.size).toEqual("100B") - expect(output.extra.raw).toEqual(JSON.stringify({ a: 1 })) - expect(output.extra.headers["content-type"]).toEqual("application/json") + expect(output.info.size).toEqual("7B") }) it("should be able to parse text response", async () => { const text = "hello world" - const input = buildInput(null, text, "text/plain") - const output = await config.integration.parseResponse(input) + const output = await integration.parseResponse( + new Response(text, { + headers: { "content-type": "text/plain" }, + }) + ) expect(output.data).toEqual(text) - expect(output.extra.raw).toEqual(text) - expect(output.extra.headers["content-type"]).toEqual("text/plain") }) it("should be able to parse XML response", async () => { const text = "12" - const input = buildInput(null, text, "application/xml") - const output = await config.integration.parseResponse(input) + const output = await integration.parseResponse( + new Response(text, { + headers: { "content-type": "application/xml" }, + }) + ) expect(output.data).toEqual({ a: "1", b: "2" }) - expect(output.extra.raw).toEqual(text) - expect(output.extra.headers["content-type"]).toEqual("application/xml") }) - test.each([...contentTypes, undefined])( - "should not throw an error on 204 no content", + test.each(["application/json", "text/plain", "application/xml", undefined])( + "should not throw an error on 204 no content for content type: %s", async contentType => { - const input = buildInput(undefined, "", contentType, 204) - const output = await config.integration.parseResponse(input) + const output = await integration.parseResponse( + new Response(undefined, { + headers: { "content-type": contentType! }, + status: 204, + }) + ) expect(output.data).toEqual([]) - expect(output.extra.raw).toEqual("") expect(output.info.code).toEqual(204) - expect(output.extra.headers["content-type"]).toEqual(contentType) } ) }) @@ -301,443 +242,311 @@ describe("REST Integration", () => { } beforeEach(() => { - config = new TestConfiguration({ - url: BASE_URL, + integration = new RestIntegration({ + url: "https://example.com", authConfigs: [basicAuth, bearerAuth], }) }) it("adds basic auth", async () => { - const query = { - authConfigId: "c59c14bd1898a43baa08da68959b24686", - } - await config.integration.read(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/`, { - method: "GET", - headers: { - Authorization: "Basic dXNlcjpwYXNzd29yZA==", - }, - }) + const auth = `Basic ${Buffer.from("user:password").toString("base64")}` + nock("https://example.com", { reqheaders: { Authorization: auth } }) + .get("/") + .reply(200, { foo: "bar" }) + + const { data } = await integration.read({ authConfigId: basicAuth._id }) + expect(data).toEqual({ foo: "bar" }) }) it("adds bearer auth", async () => { - const query = { - authConfigId: "0d91d732f34e4befabeff50b392a8ff3", - } - await config.integration.read(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/`, { - method: "GET", - headers: { - Authorization: "Bearer mytoken", - }, + nock("https://example.com", { + reqheaders: { Authorization: "Bearer mytoken" }, }) + .get("/") + .reply(200, { foo: "bar" }) + const { data } = await integration.read({ authConfigId: bearerAuth._id }) + expect(data).toEqual({ foo: "bar" }) }) }) describe("page based pagination", () => { it("can paginate using query params", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { + nock("https://example.com") + .get("/api?page=3&size=10") + .reply(200, { foo: "bar" }) + const { data } = await integration.read({ path: "api", pagination: { type: "page", location: "query", - pageParam, - sizeParam, + pageParam: "page", + sizeParam: "size", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - await config.integration.read(query) - expect(fetch).toHaveBeenCalledWith( - `${BASE_URL}/api?${pageParam}=${pageValue}&${sizeParam}=${sizeValue}`, - { - headers: {}, - method: "GET", - } - ) + paginationValues: { page: 3, limit: 10 }, + }) + expect(data).toEqual({ foo: "bar" }) }) it("can paginate using JSON request body", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { - bodyType: "json", + nock("https://example.com") + .post("/api", JSON.stringify({ page: 3, size: 10 })) + .reply(200, { foo: "bar" }) + const { data } = await integration.create({ + bodyType: BodyType.JSON, path: "api", pagination: { type: "page", location: "body", - pageParam, - sizeParam, + pageParam: "page", + sizeParam: "size", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api`, { - body: JSON.stringify({ - [pageParam]: pageValue, - [sizeParam]: sizeValue, - }), - headers: { - "Content-Type": "application/json", - }, - method: "POST", + paginationValues: { page: 3, limit: 10 }, }) + expect(data).toEqual({ foo: "bar" }) }) it("can paginate using form-data request body", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { - bodyType: "form", + nock("https://example.com") + .post("/api", body => { + return ( + body.includes(`name="page"\r\n\r\n3\r\n`) && + body.includes(`name="size"\r\n\r\n10\r\n`) + ) + }) + .reply(200, { foo: "bar" }) + + const { data } = await integration.create({ + bodyType: BodyType.FORM_DATA, path: "api", pagination: { type: "page", location: "body", - pageParam, - sizeParam, + pageParam: "page", + sizeParam: "size", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api`, { - body: expect.any(FormData), - headers: {}, - method: "POST", + paginationValues: { page: 3, limit: 10 }, }) - // @ts-ignore - const sentData = JSON.stringify(fetch.mock.calls[0][1].body) - expect(sentData).toContain(pageParam) - expect(sentData).toContain(sizeParam) + expect(data).toEqual({ foo: "bar" }) }) it("can paginate using form-encoded request body", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { - bodyType: "encoded", + nock("https://example.com") + .post("/api", { page: "3", size: "10" }) + .reply(200, { foo: "bar" }) + + const { data } = await integration.create({ + bodyType: BodyType.ENCODED, path: "api", pagination: { type: "page", location: "body", - pageParam, - sizeParam, + pageParam: "page", + sizeParam: "size", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api`, { - body: expect.any(URLSearchParams), - headers: {}, - method: "POST", + paginationValues: { page: 3, limit: 10 }, }) - // @ts-ignore - const sentData = fetch.mock.calls[0][1].body - expect(sentData.has(pageParam)).toEqual(true) - expect(sentData.get(pageParam)).toEqual(pageValue.toString()) - expect(sentData.has(pageParam)).toEqual(true) - expect(sentData.get(sizeParam)).toEqual(sizeValue.toString()) + expect(data).toEqual({ foo: "bar" }) }) }) describe("cursor based pagination", () => { it("can paginate using query params", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { + nock("https://example.com") + .get("/api?page=3&size=10") + .reply(200, { cursor: 123, foo: "bar" }) + const { data, pagination } = await integration.read({ path: "api", pagination: { type: "cursor", location: "query", - pageParam, - sizeParam, - responseParam: "my_next_cursor", + pageParam: "page", + sizeParam: "size", + responseParam: "cursor", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - const res = await config.integration.read(query) - expect(fetch).toHaveBeenCalledWith( - `${BASE_URL}/api?${pageParam}=${pageValue}&${sizeParam}=${sizeValue}`, - { - headers: {}, - method: "GET", - } - ) - expect(res.pagination.cursor).toEqual(123) + paginationValues: { page: 3, limit: 10 }, + }) + expect(pagination?.cursor).toEqual(123) + expect(data).toEqual({ cursor: 123, foo: "bar" }) }) it("can paginate using JSON request body", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { - bodyType: "json", + nock("https://example.com") + .post("/api", JSON.stringify({ page: 3, size: 10 })) + .reply(200, { cursor: 123, foo: "bar" }) + const { data, pagination } = await integration.create({ + bodyType: BodyType.JSON, path: "api", pagination: { type: "page", location: "body", - pageParam, - sizeParam, - responseParam: "my_next_cursor", + pageParam: "page", + sizeParam: "size", + responseParam: "cursor", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - const res = await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api`, { - body: JSON.stringify({ - [pageParam]: pageValue, - [sizeParam]: sizeValue, - }), - headers: { - "Content-Type": "application/json", - }, - method: "POST", + paginationValues: { page: 3, limit: 10 }, }) - expect(res.pagination.cursor).toEqual(123) + expect(data).toEqual({ cursor: 123, foo: "bar" }) + expect(pagination?.cursor).toEqual(123) }) it("can paginate using form-data request body", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { - bodyType: "form", + nock("https://example.com") + .post("/api", body => { + return ( + body.includes(`name="page"\r\n\r\n3\r\n`) && + body.includes(`name="size"\r\n\r\n10\r\n`) + ) + }) + .reply(200, { cursor: 123, foo: "bar" }) + const { data, pagination } = await integration.create({ + bodyType: BodyType.FORM_DATA, path: "api", pagination: { type: "page", location: "body", - pageParam, - sizeParam, - responseParam: "my_next_cursor", + pageParam: "page", + sizeParam: "size", + responseParam: "cursor", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - const res = await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api`, { - body: expect.any(FormData), - headers: {}, - method: "POST", + paginationValues: { page: 3, limit: 10 }, }) - // @ts-ignore - const sentData = JSON.stringify(fetch.mock.calls[0][1].body) - expect(sentData).toContain(pageParam) - expect(sentData).toContain(sizeParam) - expect(res.pagination.cursor).toEqual(123) + expect(data).toEqual({ cursor: 123, foo: "bar" }) + expect(pagination?.cursor).toEqual(123) }) it("can paginate using form-encoded request body", async () => { - const pageParam = "my_page_param" - const sizeParam = "my_size_param" - const pageValue = 3 - const sizeValue = 10 - const query = { - bodyType: "encoded", + nock("https://example.com") + .post("/api", { page: "3", size: "10" }) + .reply(200, { cursor: 123, foo: "bar" }) + const { data, pagination } = await integration.create({ + bodyType: BodyType.ENCODED, path: "api", pagination: { type: "page", location: "body", - pageParam, - sizeParam, - responseParam: "my_next_cursor", + pageParam: "page", + sizeParam: "size", + responseParam: "cursor", }, - paginationValues: { - page: pageValue, - limit: sizeValue, - }, - } - const res = await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api`, { - body: expect.any(URLSearchParams), - headers: {}, - method: "POST", + paginationValues: { page: 3, limit: 10 }, }) - // @ts-ignore - const sentData = fetch.mock.calls[0][1].body - expect(sentData.has(pageParam)).toEqual(true) - expect(sentData.get(pageParam)).toEqual(pageValue.toString()) - expect(sentData.has(pageParam)).toEqual(true) - expect(sentData.get(sizeParam)).toEqual(sizeValue.toString()) - expect(res.pagination.cursor).toEqual(123) + expect(data).toEqual({ cursor: 123, foo: "bar" }) + expect(pagination?.cursor).toEqual(123) }) it("should encode query string correctly", async () => { - const query = { + nock("https://example.com", { reqheaders: HEADERS }) + .post("/api?test=1%202", JSON.stringify({ name: "test" })) + .reply(200, { foo: "bar" }) + const { data } = await integration.create({ path: "api", queryString: "test=1 2", headers: HEADERS, - bodyType: "json", + bodyType: BodyType.JSON, requestBody: JSON.stringify({ name: "test", }), - } - await config.integration.create(query) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1%202`, { - method: "POST", - body: '{"name":"test"}', - headers: HEADERS, }) + expect(data).toEqual({ foo: "bar" }) }) }) describe("Configuration options", () => { - it("Attaches insecureHttpParams when legacy HTTP Parser option is set", async () => { - config = new TestConfiguration({ - url: BASE_URL, + // NOTE(samwho): it seems like this code doesn't actually work because it requires + // node-fetch >=3, and we're not on that because upgrading to it produces errors to + // do with ESM that are above my pay grade. + + // eslint-disable-next-line jest/no-commented-out-tests + // it("doesn't fail when legacyHttpParser is set", async () => { + // const server = createServer((req, res) => { + // res.writeHead(200, { + // "Transfer-Encoding": "chunked", + // "Content-Length": "10", + // }) + // res.end(JSON.stringify({ foo: "bar" })) + // }) + + // server.listen() + // await new Promise(resolve => server.once("listening", resolve)) + + // const address = server.address() as AddressInfo + + // const integration = new RestIntegration({ + // url: `http://localhost:${address.port}`, + // legacyHttpParser: true, + // }) + // const { data } = await integration.read({}) + // expect(data).toEqual({ foo: "bar" }) + // }) + + it("doesn't fail when legacyHttpParser is true", async () => { + nock("https://example.com").get("/").reply(200, { foo: "bar" }) + const integration = new RestIntegration({ + url: "https://example.com", legacyHttpParser: true, }) - await config.integration.read({}) - expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/`, { - method: "GET", - headers: {}, - extraHttpOptions: { - insecureHTTPParser: true, - }, + const { data } = await integration.read({}) + expect(data).toEqual({ foo: "bar" }) + }) + + it("doesn't fail when rejectUnauthorized is false", async () => { + nock("https://example.com").get("/").reply(200, { foo: "bar" }) + const integration = new RestIntegration({ + url: "https://example.com", + rejectUnauthorized: false, }) + const { data } = await integration.read({}) + expect(data).toEqual({ foo: "bar" }) }) }) - it("Attaches custom agent when Reject Unauthorized option is false", async () => { - config = new TestConfiguration({ - url: BASE_URL, - rejectUnauthorized: false, - }) - await config.integration.read({}) - - // @ts-ignore - const calls: any = fetch.mock.calls[0] - const url = calls[0] - expect(url).toBe(`${BASE_URL}/`) - - const calledConfig = calls[1] - expect(calledConfig.method).toBe("GET") - expect(calledConfig.headers).toEqual({}) - expect(calledConfig.agent.options.rejectUnauthorized).toBe(false) - }) - describe("File Handling", () => { it("uploads file to object store and returns signed URL", async () => { - const responseData = Buffer.from("teest file contnt") - const filename = "test.tar.gz" - const contentType = "application/gzip" - const mockReadable = new Readable() - mockReadable.push(responseData) - mockReadable.push(null) - ;(fetch as unknown as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ - status: 200, - headers: { - raw: () => ({ - "content-type": [contentType], - "content-disposition": [`attachment; filename="${filename}"`], - }), - get: (header: any) => { - if (header === "content-type") return contentType - if (header === "content-length") return responseData.byteLength - if (header === "content-disposition") - return `attachment; filename="${filename}"` - }, - }, - body: mockReadable, + await config.doInContext(config.getAppId(), async () => { + const content = "test file content" + nock("https://example.com").get("/api").reply(200, content, { + "content-disposition": `attachment; filename="testfile.tar.gz"`, + "content-type": "text/plain", }) - ) - const query = { - path: "api", - } - - const response = await config.integration.read(query) - - expect(response.data).toEqual({ - size: responseData.byteLength, - name: "00000000-0000-0000-0000-000000000000.tar.gz", - url: expect.stringContaining( - "/files/signed/tmp-file-attachments/app-id/00000000-0000-0000-0000-000000000000.tar.gz" - ), - extension: "tar.gz", - key: expect.stringContaining( - "app-id/00000000-0000-0000-0000-000000000000.tar.gz" - ), + const { data } = await integration.read({ path: "api" }) + expect(data).toEqual({ + size: content.length, + name: expect.stringMatching(new RegExp(`^${UUID_REGEX}.tar.gz$`)), + url: expect.stringMatching( + new RegExp( + `^/files/signed/tmp-file-attachments/app.*?/${UUID_REGEX}.tar.gz.*$` + ) + ), + extension: "tar.gz", + key: expect.stringMatching( + new RegExp(`^app.*?/${UUID_REGEX}.tar.gz$`) + ), + }) }) }) it("uploads file with non ascii filename to object store and returns signed URL", async () => { - const responseData = Buffer.from("teest file contnt") - const contentType = "text/plain" - const mockReadable = new Readable() - mockReadable.push(responseData) - mockReadable.push(null) - ;(fetch as unknown as jest.Mock).mockImplementationOnce(() => - Promise.resolve({ - status: 200, - headers: { - raw: () => ({ - "content-type": [contentType], - "content-disposition": [ - // eslint-disable-next-line no-useless-escape - `attachment; filename="£ and ? rates.pdf"; filename*=UTF-8'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf`, - ], - }), - get: (header: any) => { - if (header === "content-type") return contentType - if (header === "content-length") return responseData.byteLength - if (header === "content-disposition") - // eslint-disable-next-line no-useless-escape - return `attachment; filename="£ and ? rates.pdf"; filename*=UTF-8'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf` - }, - }, - body: mockReadable, + await config.doInContext(config.getAppId(), async () => { + const content = "test file content" + nock("https://example.com").get("/api").reply(200, content, { + // eslint-disable-next-line no-useless-escape + "content-disposition": `attachment; filename="£ and ? rates.pdf"; filename*=UTF-8'\'%C2%A3%20and%20%E2%82%AC%20rates.pdf`, + "content-type": "text/plain", }) - ) - const query = { - path: "api", - } - - const response = await config.integration.read(query) - - expect(response.data).toEqual({ - size: responseData.byteLength, - name: "00000000-0000-0000-0000-000000000000.pdf", - url: expect.stringContaining( - "/files/signed/tmp-file-attachments/app-id/00000000-0000-0000-0000-000000000000.pdf" - ), - extension: "pdf", - key: expect.stringContaining( - "app-id/00000000-0000-0000-0000-000000000000.pdf" - ), + const { data } = await integration.read({ path: "api" }) + expect(data).toEqual({ + size: content.length, + name: expect.stringMatching(new RegExp(`^${UUID_REGEX}.pdf$`)), + url: expect.stringMatching( + new RegExp( + `^/files/signed/tmp-file-attachments/app.*?/${UUID_REGEX}.pdf.*$` + ) + ), + extension: "pdf", + key: expect.stringMatching(new RegExp(`^app.*?/${UUID_REGEX}.pdf$`)), + }) }) }) }) diff --git a/packages/server/src/startup/tests/startup.spec.ts b/packages/server/src/startup/tests/startup.spec.ts index fef9270bb5..fd2e0df61a 100644 --- a/packages/server/src/startup/tests/startup.spec.ts +++ b/packages/server/src/startup/tests/startup.spec.ts @@ -1,6 +1,7 @@ import TestConfiguration from "../../tests/utilities/TestConfiguration" import { startup } from "../index" import { users, utils, tenancy } from "@budibase/backend-core" +import nock from "nock" describe("check BB_ADMIN environment variables", () => { const config = new TestConfiguration() @@ -8,7 +9,17 @@ describe("check BB_ADMIN environment variables", () => { await config.init() }) + beforeEach(() => { + nock.cleanAll() + }) + it("should be able to create a user with the BB_ADMIN environment variables", async () => { + nock("http://localhost:10000") + .get("/api/global/configs/checklist") + .reply(200, {}) + .get("/api/global/self/api_key") + .reply(200, {}) + const EMAIL = "budibase@budibase.com", PASSWORD = "budibase" await tenancy.doInTenant(tenancy.DEFAULT_TENANT_ID, async () => { diff --git a/packages/server/src/tests/jestSetup.ts b/packages/server/src/tests/jestSetup.ts index c01f415f9e..bc6384e4cd 100644 --- a/packages/server/src/tests/jestSetup.ts +++ b/packages/server/src/tests/jestSetup.ts @@ -1,6 +1,7 @@ import env from "../environment" import { env as coreEnv, timers } from "@budibase/backend-core" import { testContainerUtils } from "@budibase/backend-core/tests" +import nock from "nock" if (!process.env.CI) { // set a longer timeout in dev for debugging 100 seconds @@ -9,6 +10,15 @@ if (!process.env.CI) { jest.setTimeout(30 * 1000) } +nock.disableNetConnect() +nock.enableNetConnect(host => { + return ( + host.includes("localhost") || + host.includes("127.0.0.1") || + host.includes("::1") + ) +}) + testContainerUtils.setupEnv(env, coreEnv) afterAll(() => { diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts index 36a6ed0222..79514d4ece 100644 --- a/packages/server/src/tests/utilities/api/index.ts +++ b/packages/server/src/tests/utilities/api/index.ts @@ -15,6 +15,7 @@ import { RoleAPI } from "./role" import { TemplateAPI } from "./template" import { RowActionAPI } from "./rowAction" import { AutomationAPI } from "./automation" +import { PluginAPI } from "./plugin" export default class API { table: TableAPI @@ -33,6 +34,7 @@ export default class API { templates: TemplateAPI rowAction: RowActionAPI automation: AutomationAPI + plugin: PluginAPI constructor(config: TestConfiguration) { this.table = new TableAPI(config) @@ -51,5 +53,6 @@ export default class API { this.templates = new TemplateAPI(config) this.rowAction = new RowActionAPI(config) this.automation = new AutomationAPI(config) + this.plugin = new PluginAPI(config) } } diff --git a/packages/server/src/tests/utilities/api/plugin.ts b/packages/server/src/tests/utilities/api/plugin.ts new file mode 100644 index 0000000000..c2b3a3269d --- /dev/null +++ b/packages/server/src/tests/utilities/api/plugin.ts @@ -0,0 +1,11 @@ +import { Expectations, TestAPI } from "./base" +import { CreatePluginRequest, CreatePluginResponse } from "@budibase/types" + +export class PluginAPI extends TestAPI { + create = async (body: CreatePluginRequest, expectations?: Expectations) => { + return await this._post(`/api/plugin`, { + body, + expectations, + }) + } +} diff --git a/packages/types/src/api/web/index.ts b/packages/types/src/api/web/index.ts index 8a091afdba..27d51ce1b7 100644 --- a/packages/types/src/api/web/index.ts +++ b/packages/types/src/api/web/index.ts @@ -15,3 +15,4 @@ export * from "./automation" export * from "./layout" export * from "./query" export * from "./role" +export * from "./plugins" diff --git a/packages/types/src/api/web/plugins.ts b/packages/types/src/api/web/plugins.ts new file mode 100644 index 0000000000..458ad3f6ce --- /dev/null +++ b/packages/types/src/api/web/plugins.ts @@ -0,0 +1,12 @@ +import { PluginSource } from "../../documents" + +export interface CreatePluginRequest { + source: PluginSource + url: string + githubToken?: string + headers?: { [key: string]: string } +} + +export interface CreatePluginResponse { + plugin: any +} diff --git a/packages/types/src/documents/app/datasource.ts b/packages/types/src/documents/app/datasource.ts index e52019fc18..a0be7bd80d 100644 --- a/packages/types/src/documents/app/datasource.ts +++ b/packages/types/src/documents/app/datasource.ts @@ -45,15 +45,15 @@ export interface DynamicVariable { export interface RestConfig { url: string - rejectUnauthorized: boolean + rejectUnauthorized?: boolean downloadImages?: boolean - defaultHeaders: { + defaultHeaders?: { [key: string]: any } - legacyHttpParser: boolean - authConfigs: RestAuthConfig[] - staticVariables: { + legacyHttpParser?: boolean + authConfigs?: RestAuthConfig[] + staticVariables?: { [key: string]: string } - dynamicVariables: DynamicVariable[] + dynamicVariables?: DynamicVariable[] } diff --git a/packages/types/src/documents/app/query.ts b/packages/types/src/documents/app/query.ts index baba4def95..a545ca144e 100644 --- a/packages/types/src/documents/app/query.ts +++ b/packages/types/src/documents/app/query.ts @@ -36,31 +36,39 @@ export interface QueryResponse { pagination: any } +export enum BodyType { + NONE = "none", + FORM_DATA = "form", + XML = "xml", + ENCODED = "encoded", + JSON = "json", + TEXT = "text", +} + export interface RestQueryFields { - path: string + path?: string queryString?: string - headers: { [key: string]: any } - disabledHeaders: { [key: string]: any } - requestBody: any - bodyType: string - json: object - method: string - authConfigId: string - pagination: PaginationConfig | null - paginationValues: PaginationValues | null + headers?: { [key: string]: any } + disabledHeaders?: { [key: string]: any } + requestBody?: any + bodyType?: BodyType + method?: string + authConfigId?: string + pagination?: PaginationConfig + paginationValues?: PaginationValues } export interface PaginationConfig { type: string location: string pageParam: string - sizeParam: string | null - responseParam: string | null + sizeParam?: string + responseParam?: string } export interface PaginationValues { - page: string | number | null - limit: number | null + page?: string | number + limit?: number } export enum HttpMethod {