process.env.AUTOMATIC_ROUTES = "head,get,post,put,delete"; import axios, { AxiosError } from "axios"; import * as FormData from "form-data"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { api, Process, config, utils, route } from "./../../../src/index"; const actionhero = new Process(); let url: string; describe("Server: Web", () => { beforeAll(async () => { await actionhero.start(); url = "http://localhost:" + config.web!.port; }); afterAll(async () => await actionhero.stop()); test("should be up and return data", async () => { await axios.get(url + "/api/randomNumber"); // should throw no errors }); test("basic response should be JSON and have basic data", async () => { const response = await axios.get(url + "/api/randomNumber"); expect(response).toBeInstanceOf(Object); expect(response.data.requesterInformation).toBeInstanceOf(Object); }); test("returns JSON with errors", async () => { try { await axios.get(url + "/api"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); expect(error.response?.data.requesterInformation).toBeInstanceOf( Object, ); } else throw error; } }); test("params work", async () => { try { await axios.get(url + "/api?key=value"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); expect( error.response?.data.requesterInformation.receivedParams.key, ).toEqual("value"); } else throw error; } }); test("params are ignored unless they are in the whitelist", async () => { try { await axios.get(url + "/api?crazyParam123=something"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); expect( error.response?.data.requesterInformation.receivedParams .crazyParam123, ).toBeUndefined(); } else throw error; } }); describe("will properly destroy connections", () => { beforeAll(() => { config.web!.returnErrorCodes = true; api.actions.versions.customRender = [1]; api.actions.actions.customRender = { // @ts-ignore 1: { name: "customRender", description: "I am a test", version: 1, outputExample: {}, run: async (data) => { data.toRender = false; data.connection!.rawConnection.res.writeHead(200, { "Content-Type": "text/plain", }); data.connection!.rawConnection.res.end(`${Math.random()}`); }, }, }; api.routes.loadRoutes(); }); afterAll(() => { delete api.actions.actions.customRender; delete api.actions.versions.customRender; }); test("works for the API", async () => { expect(Object.keys(api.connections.connections)).toHaveLength(0); axios.get(url + "/api/sleepTest"); // don't await await utils.sleep(100); expect(Object.keys(api.connections.connections)).toHaveLength(1); await utils.sleep(1000); expect(Object.keys(api.connections.connections)).toHaveLength(0); }); test("works for files", async () => { expect(Object.keys(api.connections.connections)).toHaveLength(0); await axios.get(url + "/simple.html"); await utils.sleep(100); expect(Object.keys(api.connections.connections)).toHaveLength(0); }); test("works for actions with toRender: false", async () => { expect(Object.keys(api.connections.connections)).toHaveLength(0); const body = await axios.get(url + "/api/customRender"); expect(body).toBeTruthy(); await utils.sleep(200); expect(Object.keys(api.connections.connections)).toHaveLength(0); }); }); describe("errors", () => { beforeAll(() => { api.actions.versions.stringErrorTestAction = [1]; api.actions.actions.stringErrorTestAction = { // @ts-ignore 1: { name: "stringErrorTestAction", description: "stringErrorTestAction", version: 1, run: async (data) => { data.response!.error = "broken"; }, }, }; api.actions.versions.errorErrorTestAction = [1]; api.actions.actions.errorErrorTestAction = { // @ts-ignore 1: { name: "errorErrorTestAction", description: "errorErrorTestAction", version: 1, run: async () => { throw new Error("broken"); }, }, }; api.actions.versions.complexErrorTestAction = [1]; api.actions.actions.complexErrorTestAction = { // @ts-ignore 1: { name: "complexErrorTestAction", description: "complexErrorTestAction", version: 1, run: async (data) => { data.response!.error = { error: "broken", reason: "stuff" }; }, }, }; api.routes.loadRoutes(); }); afterAll(() => { delete api.actions.actions.stringErrorTestAction; delete api.actions.versions.stringErrorTestAction; delete api.actions.actions.errorErrorTestAction; delete api.actions.versions.errorErrorTestAction; delete api.actions.actions.complexErrorTestAction; delete api.actions.versions.complexErrorTestAction; }); test("errors can be error strings", async () => { try { await axios.get(url + "/api/stringErrorTestAction"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(500); expect(error.response?.data.error).toEqual("broken"); } else throw error; } }); test("errors can be error objects and returned plainly", async () => { try { await axios.get(url + "/api/errorErrorTestAction"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(500); expect(error.response?.data.error).toEqual("broken"); } else throw error; } }); test("errors can be complex JSON payloads", async () => { try { await axios.get(url + "/api/complexErrorTestAction"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(500); expect(error.response?.data.error).toEqual({ error: "broken", reason: "stuff", }); } else throw error; } }); }); describe("if disableParamScrubbing is set", () => { let orig: boolean; beforeAll(() => { orig = config.general!.disableParamScrubbing as boolean; config.general!.disableParamScrubbing = true; }); afterAll(() => { config.general!.disableParamScrubbing = orig; }); test("params are not ignored", async () => { try { await axios.get(url + "/api/testAction/?crazyParam123=something"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); expect( error.response?.data.requesterInformation.receivedParams .crazyParam123, ).toEqual("something"); } else throw error; } }); }); test("gibberish actions have the right response", async () => { try { await axios.get(url + "/api/IAMNOTANACTION"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); expect(error.response?.data.error).toEqual( "unknown action or invalid apiVersion", ); } else throw error; } }); test("real actions do not have an error response", async () => { const response = await axios.get(url + "/api/status"); expect(response.data.error).toBeUndefined(); }); test("HTTP Verbs should work: GET", async () => { const response = await axios.get(url + "/api/randomNumber"); expect(response.data.randomNumber).toBeGreaterThanOrEqual(0); expect(response.data.randomNumber).toBeLessThan(1); }); test("HTTP Verbs should work: PUT", async () => { const response = await axios.put(url + "/api/randomNumber"); expect(response.data.randomNumber).toBeGreaterThanOrEqual(0); expect(response.data.randomNumber).toBeLessThan(1); }); test("HTTP Verbs should work: POST", async () => { const response = await axios.post(url + "/api/randomNumber"); expect(response.data.randomNumber).toBeGreaterThanOrEqual(0); expect(response.data.randomNumber).toBeLessThan(1); }); test("HTTP Verbs should work: DELETE", async () => { const response = await axios.delete(url + "/api/randomNumber"); expect(response.data.randomNumber).toBeGreaterThanOrEqual(0); expect(response.data.randomNumber).toBeLessThan(1); }); test("HTTP Verbs should work: Post with Form", async () => { try { const formDataA = new FormData(); formDataA.append("key", "key"); await axios.post(url + "/api/cacheTest", formDataA); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(422); expect(error.response?.data.error).toEqual( "value is a required parameter for this action", ); } else throw error; } const formDataB = new FormData(); formDataB.append("key", "key"); formDataB.append("value", "value"); const successResponse = await axios.post(url + "/api/cacheTest", formDataB); expect(successResponse.data.cacheTestResults.saveResp).toEqual(true); }); test("HTTP Verbs should work: Post with JSON Payload as body", async () => { try { await axios.post(url + "/api/cacheTest", { key: "key" }); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(422); expect(error.response?.data.error).toEqual( "value is a required parameter for this action", ); } else throw error; } const successResponse = await axios.post(url + "/api/cacheTest", { key: "key", value: "value", }); expect(successResponse.data.cacheTestResults.saveResp).toEqual(true); }); describe("messageId", () => { test("generates unique messageIds for each request", async () => { const responseA = await axios.get(url + "/api/randomNumber"); const responseB = await axios.get(url + "/api/randomNumber"); expect(responseA.data.requesterInformation.messageId).not.toEqual( responseB.data.requesterInformation.messageId, ); }); test("messageIds can be provided by the client and returned by the server", async () => { const response = await axios.get(url + "/api/randomNumber?messageId=aaa"); expect(response.data.requesterInformation.messageId).not.toEqual("aaa"); }); test("a connection id should be a combination of fingerprint and message id", async () => { const response = await axios.get(url + "/api/randomNumber"); expect(response.data.requesterInformation.id).toEqual( `${response.data.requesterInformation.fingerprint}-${response.data.requesterInformation.messageId}`, ); }); }); describe("connection.rawConnection.params", () => { beforeAll(() => { api.actions.versions.paramTestAction = [1]; api.actions.actions.paramTestAction = { // @ts-ignore 1: { name: "paramTestAction", description: "I return connection.rawConnection.params", version: 1, run: async (data) => { data.response = data.connection!.rawConnection.params; if (data.connection!.rawConnection.params.rawBody) { data.response!.rawBody = data.connection!.rawConnection.params.rawBody.toString(); } }, }, }; api.routes.loadRoutes(); }); afterAll(() => { delete api.actions.actions.paramTestAction; delete api.actions.versions.paramTestAction; }); test(".query should contain unfiltered query params", async () => { const response = await axios.get( url + "/api/paramTestAction/?crazyParam123=something", ); expect(response.data.query.crazyParam123).toEqual("something"); }); test(".body should contain unfiltered, parsed request body params", async () => { const response = await axios.post(url + "/api/paramTestAction", { key: "value", }); expect(response.data.body.key).toEqual("value"); }); test(".rawBody can be disabled", async () => { config.web!.saveRawBody = false; const requestBody = '{"key": "value"}'; const response = await axios.post( url + "/api/paramTestAction", requestBody, { headers: { "Content-type": "application/json" } }, ); expect(response.data.body.key).toEqual("value"); expect(response.data.rawBody).toEqual(""); }); }); test("returnErrorCodes can be opted to change http header codes", async () => { try { await axios.delete(url + "/api/"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); } else throw error; } }); describe("http header", () => { beforeAll(() => { api.actions.versions.headerTestAction = [1]; api.actions.actions.headerTestAction = { // @ts-ignore 1: { name: "headerTestAction", description: "I am a test", version: 1, outputExample: {}, run: async (data) => { data.connection!.rawConnection.responseHeaders.push(["thing", "A"]); data.connection!.rawConnection.responseHeaders.push(["thing", "B"]); data.connection!.rawConnection.responseHeaders.push(["thing", "C"]); data.connection!.rawConnection.responseHeaders.push([ "Set-Cookie", "value_1=1", ]); data.connection!.rawConnection.responseHeaders.push([ "Set-Cookie", "value_2=2", ]); }, }, }; api.routes.loadRoutes(); }); afterAll(() => { delete api.actions.actions.headerTestAction; delete api.actions.versions.headerTestAction; }); test("duplicate headers should be removed (in favor of the last set)", async () => { const response = await axios.get(url + "/api/headerTestAction"); expect(response.status).toEqual(200); expect(response.headers.thing).toEqual("C"); }); test("but duplicate set-cookie requests should be allowed", async () => { const response = await axios.get(url + "/api/headerTestAction"); expect(response.status).toEqual(200); // this will convert node >= 10 header array to look like node <= 9 combined strings const cookieString = (response.headers["set-cookie"] || [""]).join(); const parts = cookieString.split(","); expect(parts[1]).toEqual("value_1=1"); expect(parts[0]).toEqual("value_2=2"); }); test("should respond to OPTIONS with only HTTP headers", async () => { const response = await axios.options(url + "/api/cacheTest"); expect(response.status).toEqual(200); expect(response.headers["access-control-allow-methods"]).toEqual( "HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS, TRACE", ); expect(response.headers["access-control-allow-origin"]).toEqual("*"); expect(response.headers["content-length"]).toEqual("0"); expect(response.data).toEqual(""); }); test("should respond to TRACE with parsed params received", async () => { const response = await axios({ method: "trace", url: url + "/api/x", data: { key: "someKey", value: "someValue" }, }); expect(response.status).toEqual(200); expect(response.data.receivedParams.key).toEqual("someKey"); expect(response.data.receivedParams.value).toEqual("someValue"); }); test("should respond to HEAD requests just like GET, but with no body", async () => { const response = await axios.head(url + "/api/headerTestAction"); expect(response.status).toEqual(200); expect(response.data).toEqual(""); }); test("keeps sessions with browser_fingerprint", async () => { const jar = new CookieJar(); const client = wrapper(axios.create({ jar })); const response1 = await client.post(url + "/api/randomNumber"); const response2 = await client.get(url + "/api/randomNumber"); const response3 = await client.put(url + "/api/randomNumber"); const response4 = await client.delete(url + "/api/randomNumber"); const response5 = await axios.delete(url + "/api/randomNumber"); expect(response1.headers["set-cookie"]).toBeTruthy(); expect(response2.headers["set-cookie"]).toBeUndefined(); expect(response3.headers["set-cookie"]).toBeUndefined(); expect(response4.headers["set-cookie"]).toBeUndefined(); expect(response5.headers["set-cookie"]).toBeTruthy(); const fingerprint1 = response1.data.requesterInformation.id.split("-")[0]; const fingerprint2 = response2.data.requesterInformation.id.split("-")[0]; const fingerprint3 = response3.data.requesterInformation.id.split("-")[0]; const fingerprint4 = response4.data.requesterInformation.id.split("-")[0]; const fingerprint5 = response5.data.requesterInformation.id.split("-")[0]; expect(fingerprint1).toEqual(fingerprint2); expect(fingerprint1).toEqual(fingerprint3); expect(fingerprint1).toEqual(fingerprint4); expect(fingerprint1).not.toEqual(fingerprint5); expect(fingerprint1).toEqual( response1.data.requesterInformation.fingerprint, ); expect(fingerprint2).toEqual( response2.data.requesterInformation.fingerprint, ); expect(fingerprint3).toEqual( response3.data.requesterInformation.fingerprint, ); expect(fingerprint4).toEqual( response4.data.requesterInformation.fingerprint, ); expect(fingerprint5).toEqual( response5.data.requesterInformation.fingerprint, ); }); }); describe("http returnErrorCodes true", () => { class ErrorWithCode extends Error { code: number; } beforeAll(() => { api.actions.versions.statusTestAction = [1]; api.actions.actions.statusTestAction = { // @ts-ignore 1: { name: "statusTestAction", description: "I am a test", inputs: { key: { required: true }, query: { required: false }, randomKey: { required: false }, }, run: async (data) => { if (data.params!.key !== "value") { data.connection!.rawConnection.responseHttpCode = 402; throw new ErrorWithCode("key != value"); } const hasQueryParam = !!data.params!.query; if (hasQueryParam) { const validQueryFilters = ["test", "search"]; const validQueryParam = validQueryFilters.indexOf(data.params!.query) > -1; if (!validQueryParam) { const notFoundError = new ErrorWithCode( `404: Filter '${data.params!.query}' not found `, ); notFoundError.code = 404; throw notFoundError; } } const hasRandomKey = !!data.params!.randomKey; if (hasRandomKey) { const validRandomKeys = ["key1", "key2", "key3"]; const validRandomKey = validRandomKeys.indexOf(data.params!.randomKey) > -1; if (!validRandomKey) { if (data.params!.randomKey === "expired-key") { const expiredError = new ErrorWithCode( `999: Key '${data.params!.randomKey}' is expired`, ); expiredError.code = 999; throw expiredError; } const suspiciousError = new ErrorWithCode( `402: Suspicious Activity detected with key ${ data.params!.randomKey }`, ); suspiciousError.code = 402; throw suspiciousError; } } data.response!.good = true; }, }, }; api.routes.loadRoutes(); }); afterAll(() => { delete api.actions.versions.statusTestAction; delete api.actions.actions.statusTestAction; }); test("actions that do not exists should return 404", async () => { try { await axios.post(url + "/api/aFakeAction"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); } else throw error; } }); test("missing params result in a 422", async () => { try { await axios.post(url + "/api/statusTestAction"); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(422); } else throw error; } }); test("status codes can be set for errors", async () => { try { await axios.post(url + "/api/statusTestAction", { key: "bannana" }); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(402); expect(error.response?.data.error).toEqual("key != value"); } else throw error; } }); test("status code should still be 200 if everything is OK", async () => { const response = await axios.post(url + "/api/statusTestAction", { key: "value", }); expect(response.status).toEqual(200); expect(response.data.good).toEqual(true); }); describe("setting status code using custom errors", () => { test("should work for 404 status code, set using custom error for invalid params", async () => { try { await axios.post(url + "/api/statusTestAction", { key: "value", query: "guess", }); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(404); expect(error.response?.data.error).toEqual( "404: Filter 'guess' not found ", ); } else throw error; } }); test("should work for 402 status code set using custom error for invalid params", async () => { try { await axios.post(url + "/api/statusTestAction", { key: "value", randomKey: "guessKey", }); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).toEqual(402); expect(error.response?.data.error).toEqual( "402: Suspicious Activity detected with key guessKey", ); } else throw error; } }); test("should not throw custom error for valid params", async () => { const responseWithQuery = await axios.post( url + "/api/statusTestAction", { key: "value", query: "test" }, ); expect(responseWithQuery.status).toEqual(200); expect(responseWithQuery.data.good).toEqual(true); const responseWithRandomKey = await axios.post( url + "/api/statusTestAction", { key: "value", randomKey: "key1" }, ); expect(responseWithRandomKey.status).toEqual(200); expect(responseWithRandomKey.data.good).toEqual(true); const responseWithKeyAndQuery = await axios.post( url + "/api/statusTestAction", { key: "value", query: "search", randomKey: "key2", }, ); expect(responseWithKeyAndQuery.status).toEqual(200); expect(responseWithKeyAndQuery.data.good).toEqual(true); }); test("should not work for 999 status code set using custom error and default error code, 400 is thrown", async () => { try { await axios.post(url + "/api/statusTestAction", { key: "value", randomKey: "expired-key", }); throw new Error("should not get here"); } catch (error) { if (error instanceof AxiosError) { expect(error.response?.status).not.toEqual(999); expect(error.response?.status).toEqual(500); expect(error.response?.data.error).toEqual( "999: Key 'expired-key' is expired", ); } else throw error; } }); }); }); describe("documentation", () => { test("documentation can be returned via a swagger action", async () => { const response = await axios.get(url + "/api/swagger"); expect(response.data.paths).toBeInstanceOf(Object); }); }); describe("files", () => { test("an HTML file", async () => { const response = await axios.get(url + "/public/simple.html"); expect(response.status).toEqual(200); expect(response.data).toContain("