2b92c2ab6c
feat(security): GRO-2294 Route Optimization security hardening [squash] Co-authored-by: Flea Flicker <22+gb_flea@noreply.git.farh.net> Co-committed-by: Flea Flicker <22+gb_flea@noreply.git.farh.net>
90 lines
2.9 KiB
TypeScript
90 lines
2.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { Hono } from "hono";
|
|
|
|
// ─── Mocks ──────────────────────────────────────────────────────────────────
|
|
// GRO-2294: the POST /clients/geocode-batch handler must clamp ?limit to the
|
|
// documented maximum (500) before invoking the geocoding service. We mock the
|
|
// service to capture the exact limit the route forwards.
|
|
|
|
const geocodeUngeocodedClients = vi.fn(async () => ({
|
|
totalRemaining: 0,
|
|
processed: 0,
|
|
geocoded: 0,
|
|
failed: 0,
|
|
remaining: 0,
|
|
}));
|
|
|
|
vi.mock("../services/clientGeocoding.js", () => ({
|
|
geocodeUngeocodedClients,
|
|
geocodeClient: vi.fn(),
|
|
resolveClientGeocodingProvider: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@groombook/db", () => {
|
|
const tableProxy = (name: string) =>
|
|
new Proxy(
|
|
{ _name: name },
|
|
{ get: (_t, p) => (p === "_name" ? name : { table: name, column: p }) }
|
|
);
|
|
return {
|
|
getDb: () => ({}),
|
|
clients: tableProxy("clients"),
|
|
appointments: tableProxy("appointments"),
|
|
and: vi.fn(),
|
|
eq: vi.fn(),
|
|
or: vi.fn(),
|
|
exists: vi.fn(),
|
|
};
|
|
});
|
|
|
|
const { clientsRouter } = await import("../routes/clients.js");
|
|
|
|
const app = new Hono();
|
|
app.route("/clients", clientsRouter);
|
|
|
|
function postBatch(query: string) {
|
|
return app.request(`/clients/geocode-batch${query}`, { method: "POST" });
|
|
}
|
|
|
|
describe("POST /clients/geocode-batch — ?limit cap (GRO-2294)", () => {
|
|
beforeEach(() => {
|
|
geocodeUngeocodedClients.mockClear();
|
|
});
|
|
|
|
it("defaults to 50 when no ?limit is supplied", async () => {
|
|
const res = await postBatch("");
|
|
expect(res.status).toBe(200);
|
|
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 50);
|
|
});
|
|
|
|
it("passes through a value within the cap", async () => {
|
|
const res = await postBatch("?limit=120");
|
|
expect(res.status).toBe(200);
|
|
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 120);
|
|
});
|
|
|
|
it("clamps an over-cap value to 500", async () => {
|
|
const res = await postBatch("?limit=100000");
|
|
expect(res.status).toBe(200);
|
|
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 500);
|
|
});
|
|
|
|
it("floors a fractional value before clamping", async () => {
|
|
const res = await postBatch("?limit=49.9");
|
|
expect(res.status).toBe(200);
|
|
expect(geocodeUngeocodedClients).toHaveBeenCalledWith(expect.anything(), 49);
|
|
});
|
|
|
|
it("rejects a non-positive limit with 400", async () => {
|
|
const res = await postBatch("?limit=0");
|
|
expect(res.status).toBe(400);
|
|
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a non-numeric limit with 400", async () => {
|
|
const res = await postBatch("?limit=abc");
|
|
expect(res.status).toBe(400);
|
|
expect(geocodeUngeocodedClients).not.toHaveBeenCalled();
|
|
});
|
|
});
|