@budibase/server
Version:
Budibase Web Server
1,198 lines (1,096 loc) • 39.1 kB
text/typescript
jest.mock("undici", () => {
const actual = jest.requireActual<typeof import("undici")>("undici")
return {
...actual,
fetch: jest.fn((...args: any[]) => (actual.fetch as any)(...args)),
__actualFetch: actual.fetch,
}
})
jest.mock("../../sdk/workspace/oauth2", () => {
const actual = jest.requireActual("../../sdk/workspace/oauth2")
return {
...actual,
getToken: jest.fn(),
cleanStoredToken: jest.fn(),
}
})
import { generator } from "@budibase/backend-core/tests"
import {
BasicRestAuthConfig,
BearerRestAuthConfig,
BodyType,
OAuth2CredentialsMethod,
OAuth2GrantType,
RestAuthType,
} from "@budibase/types"
import { createServer } from "http"
import { AddressInfo } from "net"
import * as undici from "undici"
import {
RequestInit as UndiciRequestInit,
Response as UndiciResponse,
FormData as UndiciFormData,
} from "undici"
import TestConfiguration from "../../../src/tests/utilities/TestConfiguration"
import sdk from "../../sdk"
import { RestIntegration } from "../rest"
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",
}
const { Response } = undici
const realFetch = (undici as any).__actualFetch as typeof undici.fetch
const fetchMock = undici.fetch as jest.MockedFunction<typeof realFetch>
const getFormDataBuffer = (body: any): string | undefined => {
if (!body) {
return undefined
}
const candidate = body.getBuffer
if (typeof candidate === "function") {
try {
const result = candidate.call(body)
if (typeof result === "string") {
return result
}
if (Buffer.isBuffer(result)) {
return result.toString("utf8")
}
if (ArrayBuffer.isView(result)) {
return Buffer.from(
result.buffer,
result.byteOffset,
result.byteLength
).toString("utf8")
}
} catch (_err) {
// fall through to other strategies
}
}
return undefined
}
const extractFormEntries = (body: any): Record<string, string> | undefined => {
if (!body) {
return undefined
}
if (typeof body.entries === "function") {
const result: Record<string, string> = {}
for (const [key, value] of body.entries()) {
result[String(key)] = String(value ?? "")
}
return result
}
if (typeof body.forEach === "function") {
const result: Record<string, string> = {}
body.forEach((value: unknown, key: string) => {
result[key] = String(value ?? "")
})
return result
}
return undefined
}
const expectFormDataToMatch = (
body: unknown,
expected: Record<string, string>
) => {
const entries = extractFormEntries(body)
if (entries) {
expect(entries).toMatchObject(expected)
return
}
const payload = getFormDataBuffer(body)
expect(payload).toBeDefined()
for (const [key, value] of Object.entries(expected)) {
expect(payload).toContain(`name="${key}"`)
expect(payload).toContain(String(value))
}
}
describe("REST Integration", () => {
let integration: RestIntegration
const pendingFetches: Array<
(url: string, init?: UndiciRequestInit) => Promise<UndiciResponse>
> = []
const queueResponse = (
handler: (url: string, init?: UndiciRequestInit) => Promise<UndiciResponse>
) => {
pendingFetches.push(handler)
}
const queueJsonResponse = (
assertFn: (url: string, init?: UndiciRequestInit) => void,
body: any,
status = 200,
headers: Record<string, string> = {}
) => {
queueResponse(async (url, options) => {
assertFn(url, options)
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...headers },
})
})
}
const config = new TestConfiguration()
beforeAll(async () => {
await config.init()
})
afterAll(async () => {
config.end()
})
beforeEach(() => {
pendingFetches.length = 0
fetchMock.mockImplementation((url, init?: UndiciRequestInit) => {
const urlString = typeof url === "string" ? url : String(url)
if (pendingFetches.length) {
return pendingFetches.shift()!(urlString, init)
}
if (urlString.startsWith("https://example.com")) {
throw new Error(`Unexpected fetch call to ${urlString}`)
}
return realFetch(
url as Parameters<typeof realFetch>[0],
init as Parameters<typeof realFetch>[1]
)
})
integration = new RestIntegration({ url: "https://example.com" })
})
afterEach(() => {
fetchMock.mockReset()
})
it("calls the create method with the correct params", async () => {
const body = { name: "test" }
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/api?test=1")
expect(options?.method).toEqual("POST")
expect(options?.headers).toMatchObject(HEADERS)
expect(options?.body).toEqual(JSON.stringify(body))
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data } = await integration.create({
path: "api",
queryString: "test=1",
headers: HEADERS,
bodyType: BodyType.JSON,
requestBody: JSON.stringify(body),
})
expect(data).toEqual({ foo: "bar" })
})
it("calls the read method with the correct params", async () => {
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/api?test=1")
expect(options?.method).toEqual("GET")
expect(options?.headers).toMatchObject({ Accept: "text/html" })
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data } = await integration.read({
path: "api",
queryString: "test=1",
headers: {
Accept: "text/html",
},
})
expect(data).toEqual({ foo: "bar" })
})
it("calls the update method with the correct params", async () => {
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/api?test=1")
expect(options?.method).toEqual("PUT")
expect(options?.headers).toMatchObject({
Accept: "application/json",
})
expect(options?.body).toEqual(JSON.stringify({ name: "test" }))
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data } = await integration.update({
path: "api",
queryString: "test=1",
headers: {
Accept: "application/json",
},
bodyType: BodyType.JSON,
requestBody: JSON.stringify({
name: "test",
}),
})
expect(data).toEqual({ foo: "bar" })
})
it("calls the delete method with the correct params", async () => {
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/api?test=1")
expect(options?.method).toEqual("DELETE")
expect(options?.headers).toMatchObject({
Accept: "application/json",
})
expect(options?.body).toEqual(JSON.stringify({ name: "test" }))
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data } = await integration.delete({
path: "api",
queryString: "test=1",
headers: {
Accept: "application/json",
},
bodyType: BodyType.JSON,
requestBody: JSON.stringify({
name: "test",
}),
})
expect(data).toEqual({ foo: "bar" })
})
describe("request body", () => {
const input = { a: 1, b: 2 }
it("should allow no body", () => {
const output = integration.addBody("none", null, {})
expect(output.body).toBeUndefined()
expect(Object.keys(output.headers!).length).toEqual(0)
})
it("should allow text body", () => {
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)
})
it("should allow form data", () => {
const output = integration.addBody("form", input, {})
const body: any = output.body
expect(body).toBeDefined()
expectFormDataToMatch(body, { a: "1", b: "2" })
})
it("should correctly clean conflicting Content-Type header for form data", async () => {
const input = { payload: "data", count: 42 }
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api/submit")
expect(options?.method).toEqual("POST")
expect(options?.body).toBeInstanceOf(UndiciFormData)
const headers = options?.headers as Record<string, any>
const contentTypeHeader =
headers["Content-Type"] || headers["content-type"]
// The original Content-Type inserted in the test data below should be stripped so that
// undici can automatically insert the correct multipart/form-data header with boundary
expect(contentTypeHeader).toBeUndefined()
},
{ success: true }
)
const { data } = await integration.create({
path: "api/submit",
bodyType: BodyType.FORM_DATA,
requestBody: input,
// Injects a conflicting header that the production code MUST delete
headers: {
"Content-Type": "application/json",
"X-Custom-Header": "KeepMe",
},
})
// Assert that the non-Content-Type header was kept
const lastFetchOptions = fetchMock.mock.calls[0][1]
expect((lastFetchOptions!.headers! as any)["X-Custom-Header"]).toEqual(
"KeepMe"
)
expect(
(lastFetchOptions!.headers! as any)["Content-Type"]
).toBeUndefined()
expect(data).toEqual({ success: true })
})
it("should allow encoded form data", () => {
const { URLSearchParams } = require("url")
const output = integration.addBody("encoded", input, {})
expect(output.body instanceof URLSearchParams).toEqual(true)
expect(output.body!.toString()).toEqual("a=1&b=2")
// gets added by fetch
expect(Object.keys(output.headers!).length).toEqual(0)
})
it("should allow JSON", () => {
const output = integration.addBody("json", input, {})
expect(output.body).toEqual(JSON.stringify(input))
expect((output.headers! as any)["Content-Type"]).toEqual(
"application/json"
)
})
it("should allow raw XML", () => {
const output = integration.addBody("xml", "<a>1</a><b>2</b>", {})
const body = output.body?.toString()
expect(body!.includes("<a>1</a>")).toEqual(true)
expect(body!.includes("<b>2</b>")).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 = integration.addBody("xml", input, {})
const body = output.body?.toString()
expect(body!.includes("<a>1</a>")).toEqual(true)
expect(body!.includes("<b>2</b>")).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 = integration.addBody("xml", JSON.stringify(input), {})
const body = output.body?.toString()
expect(body!.includes("<a>1</a>")).toEqual(true)
expect(body!.includes("<b>2</b>")).toEqual(true)
expect((output.headers! as any)["Content-Type"]).toEqual(
"application/xml"
)
})
})
describe("response", () => {
it("should be able to parse JSON response", async () => {
const obj = { 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("7B")
})
it("should be able to parse text response", async () => {
const text = "hello world"
const output = await integration.parseResponse(
new Response(text, {
headers: { "content-type": "text/plain" },
})
)
expect(output.data).toEqual(text)
})
it("should be able to parse XML response", async () => {
const text = "<root><a>1</a><b>2</b></root>"
const output = await integration.parseResponse(
new Response(text, {
headers: { "content-type": "application/xml" },
})
)
expect(output.data).toEqual({ a: "1", b: "2" })
})
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 output = await integration.parseResponse(
new Response(undefined, {
headers: { "content-type": contentType! },
status: 204,
})
)
expect(output.data).toEqual([])
expect(output.info.code).toEqual(204)
}
)
})
describe("authentication", () => {
const getTokenMock = sdk.oauth2.getToken as jest.MockedFunction<
typeof sdk.oauth2.getToken
>
const cleanStoredTokenMock = sdk.oauth2
.cleanStoredToken as jest.MockedFunction<
typeof sdk.oauth2.cleanStoredToken
>
const basicAuth: BasicRestAuthConfig = {
_id: "c59c14bd1898a43baa08da68959b24686",
name: "basic-1",
type: RestAuthType.BASIC,
config: {
username: "user",
password: "password",
},
}
const bearerAuth: BearerRestAuthConfig = {
_id: "0d91d732f34e4befabeff50b392a8ff3",
name: "bearer-1",
type: RestAuthType.BEARER,
config: {
token: "mytoken",
},
}
beforeEach(() => {
getTokenMock.mockReset()
cleanStoredTokenMock.mockReset()
getTokenMock.mockRejectedValue(
new Error("Unexpected oauth2.getToken call")
)
cleanStoredTokenMock.mockResolvedValue(undefined)
integration = new RestIntegration({
url: "https://example.com",
authConfigs: [basicAuth, bearerAuth],
})
})
afterEach(() => {
getTokenMock.mockReset()
cleanStoredTokenMock.mockReset()
})
it("adds basic auth", async () => {
const auth = `Basic ${Buffer.from("user:password").toString("base64")}`
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({ Authorization: auth })
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data } = await integration.read({ authConfigId: basicAuth._id })
expect(data).toEqual({ foo: "bar" })
})
it("adds bearer auth", async () => {
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({
Authorization: "Bearer mytoken",
})
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data } = await integration.read({ authConfigId: bearerAuth._id })
expect(data).toEqual({ foo: "bar" })
})
it("adds OAuth2 auth (via header)", async () => {
const oauth2Url = generator.url()
const secret = generator.hash()
const { config: oauthConfig } = await config.api.oauth2.create({
name: generator.guid(),
url: oauth2Url,
clientId: generator.guid(),
clientSecret: secret,
method: OAuth2CredentialsMethod.HEADER,
grantType: OAuth2GrantType.CLIENT_CREDENTIALS,
})
const token = `Bearer ${generator.guid()}`
getTokenMock.mockResolvedValueOnce(token)
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({ Authorization: token })
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data, info } = await config.doInContext(
config.devWorkspaceId,
async () =>
await integration.read({
authConfigId: oauthConfig._id,
authConfigType: RestAuthType.OAUTH2,
})
)
expect(data).toEqual({ foo: "bar" })
expect(info.code).toEqual(200)
expect(getTokenMock).toHaveBeenCalledWith(oauthConfig._id)
})
it("adds OAuth2 auth (via body)", async () => {
const oauth2Url = generator.url()
const secret = generator.hash()
const { config: oauthConfig } = await config.api.oauth2.create({
name: generator.guid(),
url: oauth2Url,
clientId: generator.guid(),
clientSecret: secret,
method: OAuth2CredentialsMethod.BODY,
grantType: OAuth2GrantType.CLIENT_CREDENTIALS,
})
const token = `Bearer ${generator.guid()}`
getTokenMock.mockResolvedValueOnce(token)
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({ Authorization: token })
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data, info } = await config.doInContext(
config.devWorkspaceId,
async () =>
await integration.read({
authConfigId: oauthConfig._id,
authConfigType: RestAuthType.OAUTH2,
})
)
expect(data).toEqual({ foo: "bar" })
expect(info.code).toEqual(200)
expect(getTokenMock).toHaveBeenCalledWith(oauthConfig._id)
})
it("handles OAuth2 auth cached expired token", async () => {
const oauth2Url = generator.url()
const secret = generator.hash()
const { config: oauthConfig } = await config.api.oauth2.create({
name: generator.guid(),
url: oauth2Url,
clientId: generator.guid(),
clientSecret: secret,
method: OAuth2CredentialsMethod.HEADER,
grantType: OAuth2GrantType.CLIENT_CREDENTIALS,
})
const token1 = `Bearer ${generator.guid()}`
const token2 = `Bearer ${generator.guid()}`
getTokenMock.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2)
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({ Authorization: token1 })
return new Response(JSON.stringify({}), {
status: 401,
headers: { "content-type": "application/json" },
})
})
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({ Authorization: token2 })
return new Response(JSON.stringify({ foo: "bar" }), {
status: 200,
headers: { "content-type": "application/json" },
})
})
const { data, info } = await config.doInContext(
config.devWorkspaceId,
async () =>
await integration.read({
authConfigId: oauthConfig._id,
authConfigType: RestAuthType.OAUTH2,
})
)
expect(data).toEqual({ foo: "bar" })
expect(info.code).toEqual(200)
expect(cleanStoredTokenMock).toHaveBeenCalledTimes(1)
expect(cleanStoredTokenMock).toHaveBeenCalledWith(oauthConfig._id)
expect(getTokenMock).toHaveBeenCalledTimes(2)
})
it("does not loop when handling OAuth2 auth cached expired token", async () => {
const oauth2Url = generator.url()
const secret = generator.hash()
const { config: oauthConfig } = await config.api.oauth2.create({
name: generator.guid(),
url: oauth2Url,
clientId: generator.guid(),
clientSecret: secret,
method: OAuth2CredentialsMethod.HEADER,
grantType: OAuth2GrantType.CLIENT_CREDENTIALS,
})
const token1 = `Bearer ${generator.guid()}`
const token2 = `Bearer ${generator.guid()}`
getTokenMock.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2)
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({ Authorization: token1 })
return new Response(JSON.stringify({}), {
status: 401,
headers: { "content-type": "application/json" },
})
})
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.headers).toMatchObject({ Authorization: token2 })
return new Response(JSON.stringify({}), {
status: 401,
headers: { "content-type": "application/json" },
})
})
const { data, info } = await config.doInContext(
config.devWorkspaceId,
async () =>
await integration.read({
authConfigId: oauthConfig._id,
authConfigType: RestAuthType.OAUTH2,
})
)
expect(info.code).toEqual(401)
expect(data).toEqual({})
expect(cleanStoredTokenMock).toHaveBeenCalledTimes(1)
expect(cleanStoredTokenMock).toHaveBeenCalledWith(oauthConfig._id)
expect(getTokenMock).toHaveBeenCalledTimes(2)
})
})
describe("page based pagination", () => {
it("can paginate using query params", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api?page=3&size=10")
expect(options?.method).toEqual("GET")
},
{ foo: "bar" }
)
const { data } = await integration.read({
path: "api",
pagination: {
type: "page",
location: "query",
pageParam: "page",
sizeParam: "size",
},
paginationValues: { page: 3, limit: 10 },
})
expect(data).toEqual({ foo: "bar" })
})
it("can paginate using JSON request body", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("POST")
expect(options?.body).toEqual(JSON.stringify({ page: 3, size: 10 }))
},
{ foo: "bar" }
)
const { data } = await integration.create({
bodyType: BodyType.JSON,
path: "api",
pagination: {
type: "page",
location: "body",
pageParam: "page",
sizeParam: "size",
},
paginationValues: { page: 3, limit: 10 },
})
expect(data).toEqual({ foo: "bar" })
})
it("can paginate using form-data request body", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("POST")
expectFormDataToMatch(options?.body, { page: "3", size: "10" })
},
{ foo: "bar" }
)
const { data } = await integration.create({
bodyType: BodyType.FORM_DATA,
path: "api",
pagination: {
type: "page",
location: "body",
pageParam: "page",
sizeParam: "size",
},
paginationValues: { page: 3, limit: 10 },
})
expect(data).toEqual({ foo: "bar" })
})
it("can paginate using form-encoded request body", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("POST")
expect(options?.body?.toString()).toEqual("page=3&size=10")
},
{ foo: "bar" }
)
const { data } = await integration.create({
bodyType: BodyType.ENCODED,
path: "api",
pagination: {
type: "page",
location: "body",
pageParam: "page",
sizeParam: "size",
},
paginationValues: { page: 3, limit: 10 },
})
expect(data).toEqual({ foo: "bar" })
})
})
describe("cursor based pagination", () => {
it("can paginate using query params", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api?page=3&size=10")
expect(options?.method).toEqual("GET")
},
{ cursor: 123, foo: "bar" }
)
const { data, pagination } = await integration.read({
path: "api",
pagination: {
type: "cursor",
location: "query",
pageParam: "page",
sizeParam: "size",
responseParam: "cursor",
},
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 () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("POST")
expect(options?.body).toEqual(JSON.stringify({ page: 3, size: 10 }))
},
{ cursor: 123, foo: "bar" }
)
const { data, pagination } = await integration.create({
bodyType: BodyType.JSON,
path: "api",
pagination: {
type: "page",
location: "body",
pageParam: "page",
sizeParam: "size",
responseParam: "cursor",
},
paginationValues: { page: 3, limit: 10 },
})
expect(data).toEqual({ cursor: 123, foo: "bar" })
expect(pagination?.cursor).toEqual(123)
})
it("can paginate using form-data request body", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("POST")
expectFormDataToMatch(options?.body, { page: "3", size: "10" })
},
{ cursor: 123, foo: "bar" }
)
const { data, pagination } = await integration.create({
bodyType: BodyType.FORM_DATA,
path: "api",
pagination: {
type: "page",
location: "body",
pageParam: "page",
sizeParam: "size",
responseParam: "cursor",
},
paginationValues: { page: 3, limit: 10 },
})
expect(data).toEqual({ cursor: 123, foo: "bar" })
expect(pagination?.cursor).toEqual(123)
})
it("can paginate using form-encoded request body", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("POST")
expect(options?.body?.toString()).toEqual("page=3&size=10")
},
{ cursor: 123, foo: "bar" }
)
const { data, pagination } = await integration.create({
bodyType: BodyType.ENCODED,
path: "api",
pagination: {
type: "page",
location: "body",
pageParam: "page",
sizeParam: "size",
responseParam: "cursor",
},
paginationValues: { page: 3, limit: 10 },
})
expect(data).toEqual({ cursor: 123, foo: "bar" })
expect(pagination?.cursor).toEqual(123)
})
it("should encode query string correctly", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api?test=1%202")
expect(options?.method).toEqual("POST")
expect(options?.headers).toMatchObject(HEADERS)
expect(options?.body).toEqual(JSON.stringify({ name: "test" }))
},
{ foo: "bar" }
)
const { data } = await integration.create({
path: "api",
queryString: "test=1 2",
headers: HEADERS,
bodyType: BodyType.JSON,
requestBody: JSON.stringify({
name: "test",
}),
})
expect(data).toEqual({ foo: "bar" })
})
it("should remove empty query parameters", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual(
"https://example.com/api?param2=value¶m3=another"
)
expect(options?.method).toEqual("GET")
},
{ success: true }
)
const { data } = await integration.read({
path: "api",
queryString: "param1=¶m2=value¶m3=another",
})
expect(data).toEqual({ success: true })
})
it("should handle query string with only empty parameters", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("GET")
},
{ success: true }
)
const { data } = await integration.read({
path: "api",
queryString: "param1=¶m2=",
})
expect(data).toEqual({ success: true })
})
it("should handle mixed empty and valid parameters", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/api?valid=test&another=123")
expect(options?.method).toEqual("GET")
},
{ success: true }
)
const { data } = await integration.read({
path: "api",
queryString: "empty1=&valid=test&empty2=&another=123&empty3=",
})
expect(data).toEqual({ success: true })
})
})
describe("Configuration options", () => {
// 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.
it.skip("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 () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.method).toEqual("GET")
expect((options as any)?.extraHttpOptions).toEqual({
insecureHTTPParser: true,
})
},
{ foo: "bar" }
)
const integration = new RestIntegration({
url: "https://example.com",
legacyHttpParser: true,
})
const { data } = await integration.read({})
expect(data).toEqual({ foo: "bar" })
})
it("doesn't fail when rejectUnauthorized is false", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.method).toEqual("GET")
const dispatcher = options?.dispatcher
expect(dispatcher).toBeInstanceOf(undici.Agent)
},
{ foo: "bar" }
)
const integration = new RestIntegration({
url: "https://example.com",
rejectUnauthorized: false,
})
const { data } = await integration.read({})
expect(data).toEqual({ foo: "bar" })
})
it("uses Agent when config rejectUnauthorized is false", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.method).toEqual("GET")
expect(options?.dispatcher).toBeInstanceOf(undici.Agent)
},
{ foo: "bar" }
)
const integration = new RestIntegration({
url: "https://example.com",
rejectUnauthorized: false,
})
const { data } = await integration.read({})
expect(data).toEqual({ foo: "bar" })
})
it("does not use custom dispatcher when config rejectUnauthorized is true", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.method).toEqual("GET")
expect(options?.dispatcher).toBeUndefined()
},
{ foo: "bar" }
)
const integration = new RestIntegration({
url: "https://example.com",
rejectUnauthorized: true,
})
const { data } = await integration.read({})
expect(data).toEqual({ foo: "bar" })
})
it("falls back to environment variable when config rejectUnauthorized is undefined", async () => {
const originalEnv = process.env.REST_REJECT_UNAUTHORIZED
try {
process.env.REST_REJECT_UNAUTHORIZED = "false"
// Force environment module to re-evaluate
const environment = require("../../environment").default
environment._set("REST_REJECT_UNAUTHORIZED", false)
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.method).toEqual("GET")
expect(options?.dispatcher).toBeInstanceOf(undici.Agent)
},
{ foo: "bar" }
)
const integration = new RestIntegration({
url: "https://example.com",
})
const { data } = await integration.read({})
expect(data).toEqual({ foo: "bar" })
} finally {
if (originalEnv !== undefined) {
process.env.REST_REJECT_UNAUTHORIZED = originalEnv
} else {
delete process.env.REST_REJECT_UNAUTHORIZED
}
const environment = require("../../environment").default
environment._set("REST_REJECT_UNAUTHORIZED", true)
}
})
it("does not use custom dispatcher when env REST_REJECT_UNAUTHORIZED is true (default)", async () => {
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.method).toEqual("GET")
expect(options?.dispatcher).toBeUndefined()
},
{ foo: "bar" }
)
// No rejectUnauthorized in config, env defaults to true
const integration = new RestIntegration({
url: "https://example.com",
})
const { data } = await integration.read({})
expect(data).toEqual({ foo: "bar" })
})
it("config rejectUnauthorized takes precedence over environment variable", async () => {
const originalEnv = process.env.REST_REJECT_UNAUTHORIZED
try {
process.env.REST_REJECT_UNAUTHORIZED = "true"
const environment = require("../../environment").default
environment._set("REST_REJECT_UNAUTHORIZED", true)
queueJsonResponse(
(url, options) => {
expect(url).toEqual("https://example.com/")
expect(options?.method).toEqual("GET")
// Config says false, so Agent should be used despite env being true
expect(options?.dispatcher).toBeInstanceOf(undici.Agent)
},
{ foo: "bar" }
)
const integration = new RestIntegration({
url: "https://example.com",
rejectUnauthorized: false,
})
const { data } = await integration.read({})
expect(data).toEqual({ foo: "bar" })
} finally {
if (originalEnv !== undefined) {
process.env.REST_REJECT_UNAUTHORIZED = originalEnv
} else {
delete process.env.REST_REJECT_UNAUTHORIZED
}
const environment = require("../../environment").default
environment._set("REST_REJECT_UNAUTHORIZED", true)
}
})
})
describe("File Handling", () => {
it("uploads file to object store and returns signed URL", async () => {
await config.doInContext(config.getDevWorkspaceId(), async () => {
const content = "test file content"
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("GET")
return new Response(content, {
status: 200,
headers: {
"content-disposition": `attachment; filename="testfile.tar.gz"`,
"content-type": "text/plain",
"content-length": `${content.length}`,
},
})
})
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 () => {
await config.doInContext(config.getDevWorkspaceId(), async () => {
const content = "test file content"
queueResponse(async (url, options) => {
expect(url).toEqual("https://example.com/api")
expect(options?.method).toEqual("GET")
return new Response(content, {
status: 200,
headers: {
// 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",
"content-length": `${content.length}`,
},
})
})
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$`)),
})
})
})
})
})