diff --git a/.gitignore b/.gitignore index 4bef9b7513b..b8450316fdd 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,6 @@ curriculum/build ### Playwright ### /playwright + +### Shadow Testing Log Files Folder ### +api/logs/ diff --git a/api/src/app.ts b/api/src/app.ts index 120be3c1444..a41fa566dd3 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -27,6 +27,8 @@ import bouncer from './plugins/bouncer'; import errorHandling from './plugins/error-handling'; import csrf from './plugins/csrf'; import notFound from './plugins/not-found'; +import shadowCapture from './plugins/shadow-capture'; + import * as publicRoutes from './routes/public'; import * as protectedRoutes from './routes/protected'; @@ -35,6 +37,7 @@ import { EMAIL_PROVIDER, FCC_ENABLE_DEV_LOGIN_MODE, FCC_ENABLE_SWAGGER_UI, + FCC_ENABLE_SHADOW_CAPTURE, FREECODECAMP_NODE_ENV } from './utils/env'; import { isObjectID } from './utils/validation'; @@ -127,6 +130,10 @@ export const build = async ( fastify.log.info(`Swagger UI available at ${API_LOCATION}/documentation`); } + if (FCC_ENABLE_SHADOW_CAPTURE) { + void fastify.register(shadowCapture); + } + void fastify.register(auth); void fastify.register(notFound); void fastify.register(prismaPlugin); diff --git a/api/src/plugins/shadow-capture.ts b/api/src/plugins/shadow-capture.ts new file mode 100644 index 00000000000..1fd74f5eca7 --- /dev/null +++ b/api/src/plugins/shadow-capture.ts @@ -0,0 +1,137 @@ +import { randomUUID } from 'crypto'; +import { appendFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import type { FastifyPluginCallback } from 'fastify'; + +import fp from 'fastify-plugin'; +import { FastifyReply } from 'fastify/types/reply'; +import { FastifyRequest } from 'fastify/types/request'; + +const LOGS_DIRECTORY = 'logs'; +const REQUEST_CAPTURE_FILE = 'request-capture.jsonl'; +const RESPONSE_CAPTURE_FILE = 'response-capture.jsonl'; + +let REQUEST_BUFFER: unknown[] = []; +let RESPONSE_BUFFER: unknown[] = []; + +/** + * Plugin for capturing requests and responses to allow shadow testing. + * + * @param fastify The Fastify instance. + * @param _options Options passed to the plugin via `fastify.register(plugin, options)`. + * @param done Callback to signal that the logic has completed. + */ +const shadowCapture: FastifyPluginCallback = (fastify, _options, done) => { + mkdirSync(LOGS_DIRECTORY, { recursive: true }); + fastify.addHook('onRequest', (req, rep, done) => { + // Attach timestamp at beginning of lifecycle + // @ts-expect-error Exists + req.__timestamp = Date.now(); + + // Give request and response same id to match. + const id = randomUUID(); + // @ts-expect-error Exists + req.__id = id; + // @ts-expect-error Exists + rep.__id = id; + done(); + }); + + // Body is only included after `Parsing` lifecycle + fastify.addHook('preValidation', (req, rep, done) => { + captureRequest(req); + done(); + }); + + fastify.addHook('onSend', async (_req, rep, payload) => { + // @ts-expect-error Exists + rep.__payload = payload; + return payload; + }); + + fastify.addHook('onResponse', (_req, rep, done) => { + captureReply(rep); + done(); + }); + + done(); +}; + +/* eslint-disable @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-return */ +function captureRequest(req: FastifyRequest) { + const savedRequest = { + // @ts-expect-error Exists + id: req.__id, + // @ts-expect-error Exists + timestamp: req.__timestamp, + url: req.url, + headers: omit(req.headers, 'cookie'), + cookies: include(req.cookies, '_csrf', 'csrf_token', 'jwt_access_token'), + user: req.user, + body: req.body + }; + + if (REQUEST_BUFFER.length > 10) { + appendFileSync( + join(LOGS_DIRECTORY, REQUEST_CAPTURE_FILE), + REQUEST_BUFFER.map(rb => JSON.stringify(rb)).join('\n') + '\n' + ); + REQUEST_BUFFER = [savedRequest]; + } else { + REQUEST_BUFFER.push(savedRequest); + } +} + +function captureReply(rep: FastifyReply) { + const savedReply = { + // @ts-expect-error Exists + id: rep.__id, + headers: rep.getHeaders(), + timestamp: Date.now(), + // @ts-expect-error Exists + payload: rep.__payload, + statusCode: rep.statusCode + }; + + if (RESPONSE_BUFFER.length > 10) { + appendFileSync( + join(LOGS_DIRECTORY, RESPONSE_CAPTURE_FILE), + RESPONSE_BUFFER.map(rb => JSON.stringify(rb)).join('\n') + '\n' + ); + RESPONSE_BUFFER = [savedReply]; + } else { + RESPONSE_BUFFER.push(savedReply); + } +} + +/** + * Returns a subset of the given object with the values or properties given removed. + * @param obj - An array or an object literal. + * @param vals - Items or properties to exclude from `obj`. + * @returns Subset of `obj`. + */ +function omit(obj: Record | unknown[], ...vals: unknown[]) { + if (Array.isArray(obj)) { + return obj.filter(o => !vals.includes(o)); + } else { + return Object.keys(obj) + .filter(k => { + return !vals.includes(k); + }) + .reduce((acc, curr) => ({ ...acc, [curr]: obj[curr] }), {}); + } +} + +function include(obj: Record | unknown[], ...vals: unknown[]) { + if (Array.isArray(obj)) { + return obj.filter(o => vals.includes(o)); + } else { + return Object.keys(obj) + .filter(k => { + return vals.includes(k); + }) + .reduce((acc, curr) => ({ ...acc, [curr]: obj[curr] }), {}); + } +} + +export default fp(shadowCapture); diff --git a/api/src/utils/env.ts b/api/src/utils/env.ts index 147a78e2e9d..c3abc0a1ea5 100644 --- a/api/src/utils/env.ts +++ b/api/src/utils/env.ts @@ -125,6 +125,8 @@ export const FCC_ENABLE_SWAGGER_UI = process.env.FCC_ENABLE_SWAGGER_UI === 'true'; export const FCC_ENABLE_DEV_LOGIN_MODE = process.env.FCC_ENABLE_DEV_LOGIN_MODE === 'true'; +export const FCC_ENABLE_SHADOW_CAPTURE = + process.env.FCC_ENABLE_SHADOW_CAPTURE === 'true'; export const SENTRY_DSN = process.env.SENTRY_DSN === 'dsn_from_sentry_dashboard' ? '' diff --git a/sample.env b/sample.env index 9574272d333..99d7d9cd59b 100644 --- a/sample.env +++ b/sample.env @@ -68,6 +68,7 @@ NODE_ENV=development PORT=3000 FCC_ENABLE_SWAGGER_UI=true FCC_ENABLE_DEV_LOGIN_MODE=true +FCC_ENABLE_SHADOW_CAPTURE=false # Email # use ses in production