Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/mock-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ import {
getCookiesRoute,
postCookieRoute,
} from "./routes/cookies/index.js";
import {
base64Route,
bytesRoute,
delayRoute,
dripRoute,
linksRoute,
rangeRoute,
streamBytesRoute,
streamRoute,
uuidRoute,
} from "./routes/dynamic-data/index.js";
import {
deleteRoute,
getRoute,
Expand Down Expand Up @@ -63,6 +74,7 @@ export type HttpBinOptions = {
anything?: boolean;
auth?: boolean;
images?: boolean;
dynamicData?: boolean;
};

export type MockHttpOptions = {
Expand Down Expand Up @@ -124,6 +136,7 @@ export class MockHttp extends Hookified {
anything: true,
auth: true,
images: true,
dynamicData: true,
};

private _rateLimit?: RateLimitPluginOptions = {
Expand Down Expand Up @@ -424,6 +437,7 @@ export class MockHttp extends Hookified {
anything,
auth,
images,
dynamicData,
} = this._httpBin;

if (httpMethods) {
Expand Down Expand Up @@ -466,6 +480,10 @@ export class MockHttp extends Hookified {
await this.registerImageRoutes();
}

if (dynamicData) {
await this.registerDynamicDataRoutes();
}

if (this._autoDetectPort) {
const originalPort = this._port;
this._port = await this.detectPort();
Expand Down Expand Up @@ -640,6 +658,25 @@ export class MockHttp extends Hookified {
const fastify = fastifyInstance ?? this._server;
await fastify.register(imageRoutes);
}

/**
* Register the dynamic data routes.
* @param fastifyInstance - the server instance to register the routes on.
*/
public async registerDynamicDataRoutes(
fastifyInstance?: FastifyInstance,
): Promise<void> {
const fastify = fastifyInstance ?? this._server;
await fastify.register(uuidRoute);
await fastify.register(bytesRoute);
await fastify.register(streamBytesRoute);
await fastify.register(delayRoute);
await fastify.register(base64Route);
await fastify.register(streamRoute);
await fastify.register(rangeRoute);
await fastify.register(dripRoute);
await fastify.register(linksRoute);
}
}

export const mockhttp = MockHttp;
74 changes: 74 additions & 0 deletions src/routes/dynamic-data/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type {
FastifyInstance,
FastifyReply,
FastifyRequest,
FastifySchema,
} from "fastify";

type Base64Request = FastifyRequest<{
Params: { value: string };
}>;

const base64Schema: FastifySchema = {
description: "Decodes base64url-encoded string",
tags: ["Dynamic Data"],
params: {
type: "object",
properties: {
value: {
type: "string",
description: "Base64 encoded value to decode",
},
},
required: ["value"],
},
response: {
200: {
type: "string",
description: "Decoded value",
},
400: {
type: "object",
properties: {
error: { type: "string" },
},
},
},
};

// Validate base64 string - returns true if valid
function isValidBase64(str: string): boolean {
// Base64 should only contain A-Z, a-z, 0-9, +, /, = (or - and _ for base64url)
const base64Regex = /^[A-Za-z0-9+/\-_]*={0,2}$/;
if (!base64Regex.test(str)) {
return false;
}

// Check for valid length: base64 length mod 4 cannot be 1
// (0, 2, 3 are valid; 1 is invalid as it can't represent valid encoded data)
const strippedLength = str.replace(/=+$/, "").length;
return strippedLength % 4 !== 1;
}

export const base64Route = (fastify: FastifyInstance) => {
fastify.get(
"/base64/:value",
{ schema: base64Schema },
async (request: Base64Request, reply: FastifyReply) => {
const { value } = request.params;

// Validate base64 input
if (!isValidBase64(value)) {
return reply.code(400).send({ error: "Incorrect Base64 data" });
}

// Support both standard base64 and base64url encoding
const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/");
const decoded = Buffer.from(normalizedValue, "base64").toString("utf-8");

return reply
.header("Content-Type", "text/html; charset=utf-8")
.send(decoded);
},
);
};
100 changes: 100 additions & 0 deletions src/routes/dynamic-data/bytes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { randomBytes } from "node:crypto";
import type {
FastifyInstance,
FastifyReply,
FastifyRequest,
FastifySchema,
} from "fastify";

type BytesRequest = FastifyRequest<{
Params: { n: string };
Querystring: { seed?: string };
}>;

const bytesSchema: FastifySchema = {
description: "Returns n random bytes generated with given seed",
tags: ["Dynamic Data"],
params: {
type: "object",
properties: {
n: { type: "string", description: "Number of bytes to generate" },
},
required: ["n"],
},
querystring: {
type: "object",
properties: {
seed: {
type: "string",
description: "Seed for random number generation",
},
},
},
response: {
200: {
type: "string",
description: "Random binary data",
},
400: {
type: "object",
properties: {
error: { type: "string" },
},
},
},
};

// Simple seeded random number generator (LCG)
function seededRandom(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
}

function generateSeededBytes(n: number, seed: number): Buffer {
const random = seededRandom(seed);
const bytes = Buffer.alloc(n);
for (let i = 0; i < n; i++) {
bytes[i] = Math.floor(random() * 256);
}
return bytes;
}

const MAX_BYTES = 100 * 1024; // 100KB limit

export const bytesRoute = (fastify: FastifyInstance) => {
fastify.get(
"/bytes/:n",
{ schema: bytesSchema },
async (request: BytesRequest, reply: FastifyReply) => {
const n = Number.parseInt(request.params.n, 10);

if (Number.isNaN(n) || n < 0) {
return reply
.code(400)
.send({ error: "n must be a non-negative integer" });
}

const limitedN = Math.min(n, MAX_BYTES);
const seedParam = request.query.seed;

let bytes: Buffer;
if (seedParam !== undefined) {
const seed = Number.parseInt(seedParam, 10);
if (Number.isNaN(seed)) {
return reply.code(400).send({ error: "seed must be an integer" });
}
bytes = generateSeededBytes(limitedN, seed);
} else {
bytes = randomBytes(limitedN);
}

return reply
.header("Content-Type", "application/octet-stream")
.header("Content-Length", bytes.length)
.send(bytes);
},
);
};
93 changes: 93 additions & 0 deletions src/routes/dynamic-data/delay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type {
FastifyInstance,
FastifyReply,
FastifyRequest,
FastifySchema,
} from "fastify";

type DelayRequest = FastifyRequest<{
Params: { delay: string };
Querystring: Record<string, string>;
}>;

const delaySchema: FastifySchema = {
description: "Returns a delayed response (max of 10 seconds)",
tags: ["Dynamic Data"],
params: {
type: "object",
properties: {
delay: {
type: "string",
description: "Delay in seconds (max 10)",
},
},
required: ["delay"],
},
querystring: {
type: "object",
additionalProperties: true,
},
response: {
200: {
type: "object",
properties: {
args: { type: "object" },
data: { type: "string" },
files: { type: "object" },
form: { type: "object" },
headers: { type: "object" },
origin: { type: "string" },
url: { type: "string" },
},
},
400: {
type: "object",
properties: {
error: { type: "string" },
},
},
},
};

const MAX_DELAY = 10; // Maximum delay in seconds

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export const delayRoute = (fastify: FastifyInstance) => {
const handler = async (request: DelayRequest, reply: FastifyReply) => {
const delay = Number.parseFloat(request.params.delay);

if (Number.isNaN(delay) || delay < 0) {
return reply
.code(400)
.send({ error: "delay must be a non-negative number" });
}

// Cap delay at MAX_DELAY seconds
const actualDelay = Math.min(delay, MAX_DELAY);
await sleep(actualDelay * 1000);

const { protocol } = request;
/* v8 ignore next -- @preserve */
const host = request.headers.host || "localhost";

return {
/* v8 ignore next -- @preserve */
args: request.query || {},
data: "",
files: {},
form: {},
headers: request.headers,
origin: request.ip,
url: `${protocol}://${host}${request.url}`,
};
};

fastify.get("/delay/:delay", { schema: delaySchema }, handler);
fastify.post("/delay/:delay", { schema: delaySchema }, handler);
fastify.put("/delay/:delay", { schema: delaySchema }, handler);
fastify.patch("/delay/:delay", { schema: delaySchema }, handler);
fastify.delete("/delay/:delay", { schema: delaySchema }, handler);
};
Loading
Loading