diff --git a/TODO.md b/TODO.md index a85d531..1e829cb 100644 --- a/TODO.md +++ b/TODO.md @@ -34,8 +34,7 @@ - [x] Implement `GET /wikipedia/pages?title=Node.js` to search a page by title (not necessarily with the title sanitized, search with input by user to check if page exists) - [ ] Implement `GET /wikipedia/pages/internal-links/paths?from=Node.js&to=Linux` to get all the possible paths between 2 pages with titles sanitized - [x] Setup tests with database + add coverage - - [ ] Setup HTTP Requests logging in development (not needed in `test` mode) - - [ ] Setup Health checks + - [x] Setup Health checks - [x] Setup Rate limiting - [ ] Share VineJS validators between `website` and `api` - [ ] Implement Wikipedia Game Solver (`website`) diff --git a/apps/api/src/app/middleware/app_key_security_middleware.ts b/apps/api/src/app/middleware/app_key_security_middleware.ts new file mode 100644 index 0000000..40992b4 --- /dev/null +++ b/apps/api/src/app/middleware/app_key_security_middleware.ts @@ -0,0 +1,12 @@ +import { APP_KEY, APP_KEY_HEADER_NAME } from "#config/app.js" +import type { HttpContext } from "@adonisjs/core/http" +import type { NextFn } from "@adonisjs/core/types/http" + +export default class AppKeySecurityMiddleware { + public async handle(context: HttpContext, next: NextFn): Promise { + if (context.request.header(APP_KEY_HEADER_NAME) === APP_KEY) { + return next() + } + return context.response.unauthorized({ message: "Unauthorized access" }) + } +} diff --git a/apps/api/src/app/middleware/auth_middleware.ts b/apps/api/src/app/middleware/auth_middleware.ts index 367206e..dbf2d46 100644 --- a/apps/api/src/app/middleware/auth_middleware.ts +++ b/apps/api/src/app/middleware/auth_middleware.ts @@ -3,12 +3,11 @@ import type { HttpContext } from "@adonisjs/core/http" import type { NextFn } from "@adonisjs/core/types/http" /** - * Auth middleware is used authenticate HTTP requests and deny - * access to unauthenticated users. + * Auth middleware is used authenticate HTTP requests and deny access to unauthenticated users. */ export default class AuthMiddleware { /** - * The URL to redirect to, when authentication fails + * The URL to redirect to, when authentication fails. */ redirectTo = "/login" diff --git a/apps/api/src/app/routes/health/get.ts b/apps/api/src/app/routes/health/get.ts new file mode 100644 index 0000000..7391d68 --- /dev/null +++ b/apps/api/src/app/routes/health/get.ts @@ -0,0 +1,16 @@ +import { healthChecks } from "#start/health.js" +import { middleware } from "#start/kernel.js" +import type { HttpContext } from "@adonisjs/core/http" +import router from "@adonisjs/core/services/router" + +class Controller { + public async handle(context: HttpContext): Promise { + const report = await healthChecks.run() + if (report.isHealthy) { + return context.response.ok(report) + } + return context.response.serviceUnavailable(report) + } +} + +router.get("/health", [Controller]).use(middleware.appKeySecurity()) diff --git a/apps/api/src/app/routes/health/tests/get.test.ts b/apps/api/src/app/routes/health/tests/get.test.ts new file mode 100644 index 0000000..376d520 --- /dev/null +++ b/apps/api/src/app/routes/health/tests/get.test.ts @@ -0,0 +1,33 @@ +import { APP_KEY, APP_KEY_HEADER_NAME } from "#config/app.js" +import { test } from "@japa/runner" + +test.group("GET /health", () => { + test("should succeeds and get `isHealthy: true`", async ({ + client, + assert, + }) => { + // Arrange - Given + + // Act - When + const response = await client + .get("/health") + .header(APP_KEY_HEADER_NAME, APP_KEY) + const responseBody = response.body() + + // Assert - Then + response.assertStatus(200) + assert.equal(responseBody.isHealthy, true) + }) + + test("should fails and unauthorized when the app key is not provided", async ({ + client, + }) => { + // Arrange - Given + + // Act - When + const response = await client.get("/health") + + // Assert - Then + response.assertStatus(401) + }) +}) diff --git a/apps/api/src/app/routes/index.ts b/apps/api/src/app/routes/index.ts index e7686a1..e19546a 100644 --- a/apps/api/src/app/routes/index.ts +++ b/apps/api/src/app/routes/index.ts @@ -1,2 +1,3 @@ import "#app/routes/get.js" +import "#app/routes/health/get.js" import "#app/routes/wikipedia/index.js" diff --git a/apps/api/src/app/routes/tests/get.test.ts b/apps/api/src/app/routes/tests/get.test.ts index 2e6a815..6403faa 100644 --- a/apps/api/src/app/routes/tests/get.test.ts +++ b/apps/api/src/app/routes/tests/get.test.ts @@ -1,7 +1,7 @@ import { test } from "@japa/runner" test.group("GET /", () => { - test("should get hello world", async ({ client }) => { + test("should succeeds and get hello world", async ({ client }) => { // Arrange - Given // Act - When diff --git a/apps/api/src/app/routes/wikipedia/pages/tests/get.test.ts b/apps/api/src/app/routes/wikipedia/pages/tests/get.test.ts index 9f9ce62..b458c2e 100644 --- a/apps/api/src/app/routes/wikipedia/pages/tests/get.test.ts +++ b/apps/api/src/app/routes/wikipedia/pages/tests/get.test.ts @@ -51,6 +51,7 @@ test.group("GET /wikipedia/pages", (group) => { const response = await client.get( `/wikipedia/pages?${searchParams.toString()}`, ) + const responseBody = response.body() // Assert - Then response.assertStatus(200) @@ -59,7 +60,7 @@ test.group("GET /wikipedia/pages", (group) => { return page.toJSON() }), ) - assert.equal(response.body().length, limit) + assert.equal(responseBody.length, limit) }) test('should fails when "title" is not provided', async ({ client }) => { diff --git a/apps/api/src/config/app.ts b/apps/api/src/config/app.ts index 5f6b3f8..e74162a 100644 --- a/apps/api/src/config/app.ts +++ b/apps/api/src/config/app.ts @@ -9,7 +9,9 @@ import app from "@adonisjs/core/services/app" * The encryption module will fail to decrypt data if the key is lost or changed. * Therefore it is recommended to keep the app key secure. */ -export const appKey = new Secret(env.get("APP_KEY")) +export const APP_KEY_HEADER_NAME = "x-app-key" +export const APP_KEY = env.get("APP_KEY") +export const appKey = new Secret(APP_KEY) /** * The configuration settings used by the HTTP server diff --git a/apps/api/src/start/health.ts b/apps/api/src/start/health.ts new file mode 100644 index 0000000..27ca78b --- /dev/null +++ b/apps/api/src/start/health.ts @@ -0,0 +1,13 @@ +import { + DiskSpaceCheck, + HealthChecks, + MemoryHeapCheck, +} from "@adonisjs/core/health" +import { DbCheck } from "@adonisjs/lucid/database" +import db from "@adonisjs/lucid/services/db" + +export const healthChecks = new HealthChecks().register([ + new DiskSpaceCheck(), + new MemoryHeapCheck(), + new DbCheck(db.connection()), +]) diff --git a/apps/api/src/start/kernel.ts b/apps/api/src/start/kernel.ts index 7b0e8ae..ac3201b 100644 --- a/apps/api/src/start/kernel.ts +++ b/apps/api/src/start/kernel.ts @@ -45,6 +45,9 @@ router.use([ * Named middleware collection must be explicitly assigned to the routes or the routes group. */ export const middleware = router.named({ + appKeySecurity: async () => { + return await import("#app/middleware/app_key_security_middleware.js") + }, auth: async () => { return await import("#app/middleware/auth_middleware.js") }, diff --git a/apps/api/src/start/limiter.ts b/apps/api/src/start/limiter.ts index 7e84be9..4e78749 100644 --- a/apps/api/src/start/limiter.ts +++ b/apps/api/src/start/limiter.ts @@ -4,11 +4,12 @@ * The "limiter.define" method creates an HTTP middleware to apply rate limits on a route or a group of routes. Feel free to define as many throttle middleware as needed. */ +import { APP_KEY, APP_KEY_HEADER_NAME } from "#config/app.js" import app from "@adonisjs/core/services/app" import limiter from "@adonisjs/limiter/services/main" -export const throttle = limiter.define("global", () => { - if (app.inTest) { +export const throttle = limiter.define("global", (context) => { + if (app.inTest || context.request.header(APP_KEY_HEADER_NAME) === APP_KEY) { return limiter.noLimit() } return limiter.allowRequests(120).every("1 minute")