UNPKG

@icebro/actionhero

Version:

The reusable, scalable, and quick node.js API server for stateless and stateful applications

549 lines (498 loc) 19.7 kB
import * as request from "request-promise-native"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { api, Process, config, route } from "../../../../src/index"; import { routerMethods } from "../../../../src/modules/route"; let url: string; let actionhero: Process; const toJson = async (string: string) => { try { return JSON.parse(string); } catch (error) { return error; } }; describe("Server: Web", () => { beforeAll(async () => { actionhero = new Process(); await actionhero.start(); url = "http://localhost:" + config.web.port; }); afterAll(async () => await actionhero.stop()); describe("routes", () => { let originalRoutes: typeof api.routes.routes; beforeAll(() => { originalRoutes = api.routes.routes; api.actions.versions.mimeTestAction = [1]; api.actions.actions.mimeTestAction = { // @ts-ignore 1: { name: "mimeTestAction", description: "I am a test", matchExtensionMimeType: true, inputs: { key: { required: true }, path: { required: false }, }, outputExample: {}, run: async (data) => { if (data.params.key === "fail") { throw new Error("failed"); } data.response.matchedRoute = data.connection.matchedRoute; }, }, }; api.actions.versions.login = [1, 2]; api.actions.actions.login = { // @ts-ignore 1: { name: "login", description: "login", version: 1, matchExtensionMimeType: true, inputs: { user_id: { required: true }, }, outputExample: {}, run: async (data) => { data.response.user_id = data.params.user_id; data.response.version = 1; }, }, // @ts-ignore 2: { name: "login", description: "login", version: 2, matchExtensionMimeType: true, inputs: { userID: { required: true }, }, outputExample: {}, run: async (data) => { data.response.userID = data.params.userID; data.response.version = 2; }, }, // @ts-ignore three: { name: "login", description: "login", version: "three", matchExtensionMimeType: true, inputs: { userID: { required: true }, }, outputExample: {}, run: async (data) => { data.response.userID = data.params.userID; data.response.version = "three"; }, }, }; api.params.buildPostVariables(); api.routes.loadRoutes({ all: [{ path: "/user/:userID", action: "user" }], get: [ { path: "/bogus/:bogusID", action: "bogusAction" }, { path: "/users", action: "usersList" }, { path: "/c/:key/:value", action: "cacheTest" }, { path: "/mimeTestAction/:key", action: "mimeTestAction" }, { path: "/thing", action: "thing" }, { path: "/thing/stuff", action: "thingStuff" }, { path: "/v:apiVersion/login", action: "login" }, { path: "/login/v:apiVersion/stuff", action: "login" }, { path: "/login", action: "login" }, { path: "/old_login", action: "login", apiVersion: "1" }, { path: "/a/wild/:key/:path(^.*$)", action: "mimeTestAction", apiVersion: "1", matchTrailingPathParts: true, }, { path: "/a/complex/:key/__:path(^.*$)", action: "mimeTestAction", apiVersion: "1", matchTrailingPathParts: true, }, ], post: [{ path: "/login/:userID(^(\\d{3}|admin)$)", action: "login" }], }); }); afterAll(() => { api.routes.routes = originalRoutes; delete api.actions.versions.mimeTestAction; delete api.actions.actions.mimeTestAction; delete api.actions.versions.login; delete api.actions.actions.login; }); test("new params will not be allowed in route definitions (an action should do it)", () => { expect(api.params.postVariables).not.toContain("bogusID"); }); test("'all' routes are duplicated properly", () => { route.registerRoute("all", "/other-login", "login", null); const loaded: Partial<Record<typeof routerMethods[number], boolean>> = {}; const registered: Partial<Record<typeof routerMethods[number], boolean>> = {}; routerMethods.forEach((verb) => { api.routes.routes[verb].forEach((route) => { if (!loaded[verb]) { loaded[verb] = route.action === "user" && route.path === "/user/:userID"; } if (!registered[verb]) { registered[verb] = route.action === "login" && route.path === "/other-login"; } }); }); expect(Object.keys(loaded).length).toEqual(routerMethods.length); expect(Object.keys(registered).length).toEqual(routerMethods.length); }); test("unknown actions are still unknown", async () => { try { await request.get(url + "/api/a_crazy_action"); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.error).toEqual("unknown action or invalid apiVersion"); } }); test("route actions will override explicit actions, if the defined action is null", async () => { try { await request .get(url + "/api/user/123?action=someFakeAction") .then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual("user"); } }); test("returns application/json when the mime type cannot be determined for an action", async () => { const response = await request.get( url + "/api/mimeTestAction/thing.bogus", { resolveWithFullResponse: true } ); expect(response.headers["content-type"]).toMatch(/json/); const body = JSON.parse(response.body); expect(body.matchedRoute.path).toEqual("/mimeTestAction/:key"); expect(body.matchedRoute.action).toEqual("mimeTestAction"); }); test("route actions have the matched route available to the action", async () => { const body = await request .get(url + "/api/mimeTestAction/thing.json") .then(toJson); expect(body.matchedRoute.path).toEqual("/mimeTestAction/:key"); expect(body.matchedRoute.action).toEqual("mimeTestAction"); }); test("Routes should recognize apiVersion as default param", async () => { const body = await request .get(url + "/api/old_login?user_id=7") .then(toJson); expect(body.user_id).toEqual("7"); expect(body.requesterInformation.receivedParams.action).toEqual("login"); }); test("Routes should be mapped for GET (simple)", async () => { try { await request.get(url + "/api/users").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual( "usersList" ); } }); test("Routes should be mapped for GET (complex)", async () => { try { await request.get(url + "/api/user/1234").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual("user"); expect(body.requesterInformation.receivedParams.userID).toEqual("1234"); } }); test("Routes should be mapped for POST", async () => { try { await request.post(url + "/api/user/1234?key=value").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual("user"); expect(body.requesterInformation.receivedParams.userID).toEqual("1234"); expect(body.requesterInformation.receivedParams.key).toEqual("value"); } }); test("Routes should be mapped for PUT", async () => { try { await request.put(url + "/api/user/1234?key=value").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual("user"); expect(body.requesterInformation.receivedParams.userID).toEqual("1234"); expect(body.requesterInformation.receivedParams.key).toEqual("value"); } }); test("Routes should be mapped for DELETE", async () => { try { await request.del(url + "/api/user/1234?key=value").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual("user"); expect(body.requesterInformation.receivedParams.userID).toEqual("1234"); expect(body.requesterInformation.receivedParams.key).toEqual("value"); } }); test("route params trump explicit params", async () => { try { await request.get(url + "/api/user/1?userID=2").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual("user"); expect(body.requesterInformation.receivedParams.userID).toEqual("1"); } }); test("to match, a route much match all parts of the URL", async () => { try { await request.get(url + "/api/thing").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual( "thing" ); } try { await request.get(url + "/api/thing/stuff").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.requesterInformation.receivedParams.action).toEqual( "thingStuff" ); } }); test("regexp matches will provide proper variables", async () => { const body = await request.post(url + "/api/login/123").then(toJson); expect(body.requesterInformation.receivedParams.action).toEqual("login"); expect(body.requesterInformation.receivedParams.userID).toEqual("123"); const bodyAgain = await request .post(url + "/api/login/admin") .then(toJson); expect(bodyAgain.requesterInformation.receivedParams.action).toEqual( "login" ); expect(bodyAgain.requesterInformation.receivedParams.userID).toEqual( "admin" ); }); test("regexp matches will still work with params with periods and other wacky chars", async () => { const body = await request .get(url + "/api/c/key/log_me-in.com$123.") .then(toJson); expect(body.requesterInformation.receivedParams.action).toEqual( "cacheTest" ); expect(body.requesterInformation.receivedParams.value).toEqual( "log_me-in.com$123." ); }); test("regexp match failures will be rejected", async () => { try { await request.get(url + "/api/login/1234").then(toJson); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); const body = await toJson(error.response.body); expect(body.error).toEqual("unknown action or invalid apiVersion"); expect(body.requesterInformation.receivedParams.userID).toBeUndefined(); } }); describe("file extensions + routes", () => { test("will change header information based on extension (when active)", async () => { const response = await request.get( url + "/api/mimeTestAction/val.png", { resolveWithFullResponse: true } ); expect(response.headers["content-type"]).toEqual("image/png"); }); test("will not change header information if there is a connection.error", async () => { try { await request.get(url + "/api/mimeTestAction/fail"); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(500); const body = await toJson(error.response.body); expect(error.response.headers["content-type"]).toEqual( "application/json; charset=utf-8" ); expect(body.error).toEqual("failed"); } }); test("works with with matchTrailingPathParts", async () => { const body = await request .get(url + "/api/a/wild/theKey/and/some/more/path") .then(toJson); expect(body.requesterInformation.receivedParams.action).toEqual( "mimeTestAction" ); expect(body.requesterInformation.receivedParams.path).toEqual( "and/some/more/path" ); expect(body.requesterInformation.receivedParams.key).toEqual("theKey"); }); test("works with with matchTrailingPathParts and ignored variable prefixes", async () => { const body = await request .get(url + "/api/a/complex/theKey/__path-stuff") .then(toJson); expect(body.requesterInformation.receivedParams.action).toEqual( "mimeTestAction" ); expect(body.requesterInformation.receivedParams.path).toEqual( "path-stuff" ); expect(body.requesterInformation.receivedParams.key).toEqual("theKey"); }); }); describe("spaces in URL with public files", () => { const source = path.join( __dirname, "/../../../../public/logo/actionhero.png" ); beforeAll(async () => { const tmpDir = os.tmpdir(); const readStream = fs.createReadStream(source); api.staticFile.searchLocations.push(tmpDir); await new Promise((resolve) => { readStream.pipe( fs.createWriteStream( tmpDir + path.sep + "actionhero with space.png" ) ); readStream.on("close", resolve); }); }); afterAll(() => { fs.unlinkSync(os.tmpdir() + path.sep + "actionhero with space.png"); api.staticFile.searchLocations.pop(); }); test("will decode %20 or plus sign to a space so that file system can read", async () => { const response = await request.get( url + "/actionhero%20with%20space.png", { resolveWithFullResponse: true } ); expect(response.statusCode).toEqual(200); expect(response.body).toMatch(/PNG/); expect(response.headers["content-type"]).toEqual("image/png"); }); test("will capture bad encoding in URL and return NOT FOUND", async () => { try { await request.get(url + "/actionhero%20%%%%%%%%%%with+space.png"); throw new Error("should not get here"); } catch (error) { expect(error.statusCode).toEqual(404); expect(typeof error.response.body).toEqual("string"); expect(error.response.body).toMatch(/^that file is not found/); } }); }); describe("versions", () => { test("versions can be numbers", async () => { const body = await request .get(url + "/api/v1/login?user_id=123") .then(toJson); expect(body.version).toEqual(1); expect(body.user_id).toEqual("123"); }); test("versions can be strings", async () => { const body = await request .get(url + "/api/vthree/login?userID=123") .then(toJson); expect(body.version).toEqual("three"); expect(body.userID).toEqual("123"); }); test("versions have an ignored prefix", async () => { const body = await request .get(url + "/api/v1/login?user_id=123") .then(toJson); expect(body.version).toEqual(1); expect(body.user_id).toEqual("123"); expect(body.requesterInformation.receivedParams.apiVersion).toBe("1"); expect(body.requesterInformation.receivedParams.action).toBe("login"); }); [ [false, "/api/v0/login"], // there is no version 0 [true, "/api/v1/login"], // ✅ [true, "/api/v2/login"], // ✅ [true, "/api/vthree/login"], // ✅ [false, "/api/v9999/login"], // there is no version 99 [false, "/api/1/login"], // "1" is not "v1" [false, "/api/three/login"], // "1" is not "v3" [true, "/api/login"], // ✅ [false, "/api/foo/login"], // foo is not a matching prefix [false, "/api/vv/login"], // "v" is not a version [false, "/api/login/v1"], // "stuff" is needed at the end [true, "/api/login/v1/stuff"], // ✅ [true, "/api/login/v2/stuff"], // ✅ [true, "/api/login/vthree/stuff"], // ✅ [false, "/api/login/v99/stuff"], // there is no version 99 ].forEach((group) => { test(`routes match (${group[1]} - ${group[0]})`, async () => { const [match, path] = group; await expect(request.get(url + path).then(toJson)).rejects.toThrow( match ? "is a required parameter for this action" : "unknown action or invalid apiVersion" ); }); }); test("routes with no version will default to the highest version number", async () => { // sorting numerically, 2 > 'three' const body = await request .get(url + "/api/login?userID=123") .then(toJson); expect(body.version).toEqual(2); expect(body.userID).toEqual("123"); }); }); }); describe("manually set routes persist a reload", () => { let originalRoutes: typeof api.routes.routes; beforeAll(() => { originalRoutes = api.routes.routes; }); afterAll(() => { api.routes.routes = originalRoutes; }); test("it remembers manually loaded routes", async () => { route.registerRoute("get", "/a-custom-route", "randomNumber", null); const response = await request.get(url + "/api/a-custom-route", { resolveWithFullResponse: true, }); expect(response.statusCode).toEqual(200); api.routes.loadRoutes(); const responseAgain = await request.get(url + "/api/a-custom-route", { resolveWithFullResponse: true, }); expect(responseAgain.statusCode).toEqual(200); }); }); });