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
46 changes: 46 additions & 0 deletions api/__tests__/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,14 @@ describe("GET /api/v1/vaults", () => {
});
});

it("rejects non-GET methods with 405", async () => {
const res = makeRes();
await vaultsHandler(fakeReq({ method: "POST" }), res);
expect(res.statusCode).toBe(405);
expect(res.headers["Allow"]).toBe("GET");
expect(fetchAllVaults).not.toHaveBeenCalled();
});

it("reports cached: true when served from cache", async () => {
vi.mocked(fetchVaultsWithCache).mockResolvedValueOnce({
vaults: [
Expand Down Expand Up @@ -748,6 +756,17 @@ describe("GET /api/v1/vaults/:vaultId", () => {
expect(res.statusCode).toBe(404);
expect(res.headers["Cache-Control"]).toBeUndefined();
});

it("rejects non-GET methods with 405", async () => {
const res = makeRes();
await vaultDetailHandler(
fakeReq({ method: "POST", query: { vaultId: "meridian-usdc" } }),
res
);
expect(res.statusCode).toBe(405);
expect(res.headers["Allow"]).toBe("GET");
expect(fetchAllVaults).not.toHaveBeenCalled();
});
});

describe("GET /api/v1/positions/:publicKey", () => {
Expand All @@ -761,6 +780,17 @@ describe("GET /api/v1/positions/:publicKey", () => {
expect(resolvePositions).not.toHaveBeenCalled();
});

it("rejects non-GET methods with 405", async () => {
const res = makeRes();
await positionsHandler(
fakeReq({ method: "POST", query: { publicKey: PUBKEY } }),
res
);
expect(res.statusCode).toBe(405);
expect(res.headers["Allow"]).toBe("GET");
expect(resolvePositions).not.toHaveBeenCalled();
});

it("returns the resolved positions for a valid key", async () => {
const res = makeRes();
await positionsHandler(
Expand Down Expand Up @@ -1489,4 +1519,20 @@ describe("GET /api/v1/admin/vault-state", () => {

expect(res.statusCode).toBe(503);
});

it("rejects non-GET methods with 405", async () => {
const res = makeRes();
await adminHandler(
fakeReq({
query: { resource: "vault-state" },
method: "POST",
headers: {},
}),
res
);

expect(res.statusCode).toBe(405);
expect(res.headers["Allow"]).toBe("GET");
expect(fetchCoordinatorState).not.toHaveBeenCalled();
});
});
20 changes: 20 additions & 0 deletions api/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
checkRateLimit,
isCronAuthorized,
isCronSecretConfigured,
requireGetMethod,
resetRateLimitForTesting,
} from "../_lib/middleware.js";

Expand Down Expand Up @@ -98,6 +99,25 @@ describe("applyCors", () => {
});
});

// requireGetMethod ----------------------------------------------------------------

describe("requireGetMethod", () => {
it("allows GET requests without writing a response", () => {
const res = fakeRes();
expect(requireGetMethod(fakeReq("GET"), res)).toBe(false);
expect(res.statusCode).toBe(200);
expect(res.ended).toBe(false);
});

it("returns 405 with an Allow header for non-GET requests", () => {
const res = fakeRes();
expect(requireGetMethod(fakeReq("POST"), res)).toBe(true);
expect(res.statusCode).toBe(405);
expect(res.headers["Allow"]).toBe("GET");
expect(res.body).toEqual({ error: "Method not allowed" });
});
});

// checkRateLimit ------------------------------------------------------------------

describe("checkRateLimit", () => {
Expand Down
15 changes: 15 additions & 0 deletions api/_lib/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ export function applyCors(req: VercelRequest, res: VercelResponse): boolean {
return false;
}

/**
* Reject any method other than GET on a read-only route. Returns true when a
* 405 has already been sent, so callers use `if (requireGetMethod(req, res))
* return;`.
*/
export function requireGetMethod(
req: VercelRequest,
res: VercelResponse
): boolean {
if (req.method === "GET") return false;
res.setHeader("Allow", "GET");
res.status(405).json({ error: "Method not allowed" });
return true;
}

// Cache policy shared by both vault-read endpoints: the list in
// v1/vaults/index.ts and the single-vault read in v1/vaults/[vaultId].ts.
// Both must use the same policy — #1051 happened because the detail endpoint
Expand Down
12 changes: 7 additions & 5 deletions api/v1/admin/[resource].ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { handleGetAdminHistory, handleGetVaultState } from "@meridian/api-core";
import { applyCors, checkRateLimit } from "../../_lib/middleware.js";
import {
applyCors,
checkRateLimit,
requireGetMethod,
} from "../../_lib/middleware.js";

const HISTORY_CACHE_CONTROL = "public, s-maxage=30, stale-while-revalidate=120";

Expand All @@ -25,17 +29,15 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
const { resource } = req.query;

if (resource === "vault-state") {
if (requireGetMethod(req, res)) return;
const result = await handleGetVaultState();
if (result.error) console.error("[admin/vault-state] error:", result.error);
res.setHeader("Cache-Control", "no-store");
return res.status(result.status).json(result.body);
}

if (resource === "history") {
if (req.method !== "GET") {
res.setHeader("Allow", "GET");
return res.status(405).json({ error: "Method not allowed" });
}
if (requireGetMethod(req, res)) return;
const raw = req.query["vaultId"];
const vaultId = typeof raw === "string" ? raw : undefined;
if (!vaultId) return res.status(400).json({ error: "vaultId is required" });
Expand Down
40 changes: 21 additions & 19 deletions api/v1/keepers/[action].ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,21 @@ import {
isCronSecretConfigured,
} from "../../_lib/middleware.js";

// Consolidated from four separate files (accrue/health/rebalance/alert) to
// stay under Vercel's per-deployment Serverless Functions cap. URL paths are
// unchanged: /api/v1/keepers/accrue, /api/v1/keepers/health,
// /api/v1/keepers/rebalance, and /api/v1/keepers/alert all route here via
// the [action].ts dynamic segment, with req.query.action set accordingly.
// Consolidated from the former per-keeper files to stay under Vercel's
// per-deployment Serverless Functions cap. URL paths are unchanged:
// /api/v1/keepers/accrue, /api/v1/keepers/health,
// /api/v1/keepers/rebalance, /api/v1/keepers/alert, and
// /api/v1/keepers/snapshot all route here via the [action].ts dynamic
// segment, with req.query.action set accordingly.
//
// health is read-only and public, same as before; accrue/rebalance/alert
// still require the cron bearer token and are never CORS-gated. accrue and
// rebalance sign and submit real transactions off a funded/admin account;
// alert signs nothing but still costs an RPC round trip per known vault plus
// a webhook POST per unauthenticated probe, so it gets the same rate-limit
// backstop. That auth split is preserved exactly as it was when these were
// separate files.
// health is read-only and public, same as before; accrue/rebalance/alert/
// snapshot still require the cron bearer token and are never CORS-gated.
// accrue and rebalance sign and submit real transactions off a funded/admin
// account; alert signs nothing but still costs an RPC round trip per known
// vault plus a webhook POST per unauthenticated probe; snapshot reads every
// tracked wallet and writes the resulting series. They all keep the same
// rate-limit backstop. That auth split is preserved exactly as it was when
// these were separate files.
export default async function handler(req: VercelRequest, res: VercelResponse) {
const { action } = req.query;

Expand Down Expand Up @@ -71,14 +73,14 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
return res.status(405).json({ error: "Method not allowed" });
}

// No applyCors: cron-invoked, never browser-facing. But all three sign or
// submit real transactions, or read/post on their behalf, unlike simple
// reads, so they still get a rate-limit backstop. Checked before auth,
// deliberately, even though that costs a Redis round trip on an
// No applyCors: cron-invoked, never browser-facing. But these actions sign
// or submit real transactions, or read and write on their behalf, unlike
// simple reads, so they still get a rate-limit backstop. Checked before
// auth, deliberately, even though that costs a Redis round trip on an
// unauthenticated probe: this is the volume-abuse backstop for *all*
// traffic, not just correctly authenticated traffic — if it only ran
// after a successful auth check, unauthenticated/wrong-token spam would
// be entirely unbounded.
// traffic, not just correctly authenticated traffic. If it only ran after
// a successful auth check, unauthenticated or wrong-token spam would be
// entirely unbounded.
try {
if (!(await checkRateLimit(req, res, { strict: true }))) return;
} catch (err) {
Expand Down
7 changes: 6 additions & 1 deletion api/v1/positions/[publicKey].ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { VercelRequest, VercelResponse } from "@vercel/node";
import { handleGetPositions } from "@meridian/api-core";
import { applyCors, checkRateLimit } from "../../_lib/middleware.js";
import {
applyCors,
checkRateLimit,
requireGetMethod,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requireGetMethod is imported from ../../_lib/middleware.js, but that module neither defines nor exports it (git grep finds only this import and the call sites). Add the helper to api/_lib/middleware.ts, or this file will not compile.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AgentX010 api/_lib/middleware.ts does not export requireGetMethod at this head, so this import and both admin calls fail to build. Add the helper there.

} from "../../_lib/middleware.js";

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (applyCors(req, res)) return;
if (requireGetMethod(req, res)) return;
try {
if (!(await checkRateLimit(req, res))) return;
} catch (err) {
Expand Down
2 changes: 2 additions & 0 deletions api/v1/vaults/[vaultId].ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { handleGetVaultById } from "@meridian/api-core";
import {
applyCors,
checkRateLimit,
requireGetMethod,
vaultReadCacheControl,
} from "../../_lib/middleware.js";

Expand All @@ -16,6 +17,7 @@ const KNOWN_VAULT_IDS = new Set(

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (applyCors(req, res)) return;
if (requireGetMethod(req, res)) return;
try {
if (!(await checkRateLimit(req, res))) return;
} catch (err) {
Expand Down
2 changes: 2 additions & 0 deletions api/v1/vaults/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { handleGetVaults } from "@meridian/api-core";
import {
applyCors,
checkRateLimit,
requireGetMethod,
vaultReadCacheControl,
} from "../../_lib/middleware.js";

export default async function handler(req: VercelRequest, res: VercelResponse) {
if (applyCors(req, res)) return;
if (requireGetMethod(req, res)) return;
try {
if (!(await checkRateLimit(req, res))) return;
} catch (err) {
Expand Down
78 changes: 71 additions & 7 deletions apps/api-local/src/__tests__/admin-keepers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ import { adminRoute } from "../routes/admin.js";
import { keepersRoute } from "../routes/keepers.js";

vi.mock("@meridian/api-core", () => ({
handleGetAdminHistory: vi.fn(),
handleGetVaultState: vi.fn(),
handleGetKeeperHealth: vi.fn(),
}));

import { handleGetVaultState, handleGetKeeperHealth } from "@meridian/api-core";
import {
handleGetAdminHistory,
handleGetVaultState,
handleGetKeeperHealth,
} from "@meridian/api-core";

function buildApp() {
const app = Fastify({ logger: false });
Expand Down Expand Up @@ -98,20 +103,79 @@ describe("GET /api/v1/admin/vault-state", () => {
expect(logError).not.toHaveBeenCalled();
});

it("returns 404 for unregistered admin paths and non-GET methods", async () => {
it("returns 404 for unregistered admin paths", async () => {
const app = buildApp();
const unknown = await app.inject({
method: "GET",
url: "/api/v1/admin/nope",
});
expect(unknown.statusCode).toBe(404);
});

const post = await app.inject({
method: "POST",
url: "/api/v1/admin/vault-state",
});
expect(post.statusCode).toBe(404);
it("rejects non-GET admin methods with 405", async () => {
const app = buildApp();
for (const url of [
"/api/v1/admin/vault-state",
"/api/v1/admin/history?vaultId=meridian-usdc",
]) {
const post = await app.inject({ method: "POST", url });
expect(post.statusCode).toBe(405);
expect(post.headers.allow).toBe("GET");
}
expect(handleGetVaultState).not.toHaveBeenCalled();
expect(handleGetAdminHistory).not.toHaveBeenCalled();
});
});

describe("GET /api/v1/admin/history", () => {
it("returns admin actions for the requested vault", async () => {
const app = buildApp();
const actions = [{ action: "pause", ledger: 42 }];
vi.mocked(handleGetAdminHistory).mockResolvedValue({
status: 200,
body: { vaultId: "meridian-usdc", actions },
});

const res = await app.inject({
method: "GET",
url: "/api/v1/admin/history?vaultId=meridian-usdc",
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ vaultId: "meridian-usdc", actions });
expect(handleGetAdminHistory).toHaveBeenCalledWith("meridian-usdc");
});

it("returns 400 without calling the handler when vaultId is missing", async () => {
const app = buildApp();
const res = await app.inject({
method: "GET",
url: "/api/v1/admin/history",
});
expect(res.statusCode).toBe(400);
expect(res.json()).toEqual({ error: "vaultId is required" });
expect(handleGetAdminHistory).not.toHaveBeenCalled();
});

it("returns the handler status and logs an error", async () => {
const app = buildApp();
const logError = vi.spyOn(app.log, "error");
const err = new Error("horizon unavailable");
vi.mocked(handleGetAdminHistory).mockResolvedValue({
status: 500,
body: { error: "Failed to fetch admin history" },
error: err,
});

const res = await app.inject({
method: "GET",
url: "/api/v1/admin/history?vaultId=meridian-usdc",
});
expect(res.statusCode).toBe(500);
expect(res.json()).toEqual({ error: "Failed to fetch admin history" });
expect(logError).toHaveBeenCalledWith(
err,
"[admin] failed to read admin history"
);
});
});

Expand Down
Loading
Loading