Files
suaveplan/packages/api-stack/api/api-plugin-vault/src/routes/build-routes.test.ts
2026-05-27 23:29:53 +01:00

704 lines
24 KiB
TypeScript

import { OpenAPIHono } from "@hono/zod-openapi";
import {
createOpenAPIRegistry,
definePluginRoutes,
} from "@suaveplan/api-openapi";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { createFakeVaultService } from "../../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { VAULT_PLUGIN_CODES } from "../error-codes/error-codes.js";
import { resetVaultPluginInstruments } from "../otel/instruments.js";
import { defaultUnlockExtractor } from "../plugin/unlock.js";
import type { VaultPluginConfig } from "../types/types.js";
import { buildVaultRoutes } from "./build-routes.js";
function makeApp(args: {
principal: Principal | null;
config: VaultPluginConfig;
fake?: ReturnType<typeof createFakeVaultService>;
}): { app: OpenAPIHono; service: ReturnType<typeof createFakeVaultService> } {
const service = args.fake ?? createFakeVaultService();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", args.principal);
await next();
});
const registry = createOpenAPIRegistry({
info: { title: "vault-test", version: "0.1.0" },
});
const entries = buildVaultRoutes(service, args.config);
definePluginRoutes(entries, { app, registry, pluginName: "vault" });
app.onError((err, c) => {
const ctx = (err as { context?: { statusCode?: number } }).context;
const status = ctx?.statusCode ?? 500;
const code = (err as { code?: string }).code ?? "UNKNOWN";
return c.json(
{
success: false,
error: {
code,
message: (err as Error).message ?? "error",
timestamp: new Date().toISOString(),
},
},
status as 400 | 401 | 403 | 404 | 413 | 500
);
});
return { app, service };
}
const baseConfig = (): VaultPluginConfig => ({
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: true,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "test-pepper",
});
const principal: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
beforeEach(() => {
resetVaultPluginInstruments();
});
describe("routes/build-routes", () => {
it("registers exactly 5 routes", { timeout: 10_000 }, () => {
const service = createFakeVaultService();
const entries = buildVaultRoutes(service, baseConfig());
expect(entries.length).toBe(5);
expect(
entries.map(
(e) => `${e.route.method.toUpperCase()} ${e.route.path}`
)
).toEqual([
"GET /vault/items",
"POST /vault/items",
"GET /vault/items/{id}",
"PATCH /vault/items/{id}",
"DELETE /vault/items/{id}",
]);
});
it("requires auth — anonymous principal returns 401", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal: null, config: baseConfig() });
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "p" },
});
expect(res.status).toBe(401);
});
it("missing unlock header raises UNLOCK_HEADER_MISSING (400)", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items");
expect(res.status).toBe(400);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.UNLOCK_HEADER_MISSING);
});
it("POST /vault/items creates and returns the item", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "API key",
kind: "credential",
body: "k",
}),
});
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: { id: string; title: string; ownerId: string };
};
expect(json.data.title).toBe("API key");
expect(json.data.ownerId).toBe("user:alice");
});
it("POST rejects invalid body (400)", { timeout: 10_000 }, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
// Zod auto-validation at the @hono/zod-openapi boundary returns
// 400 before our handler runs.
expect(res.status).toBe(400);
});
it("POST rejects oversize bodies with ITEM_TOO_LARGE (413)", {
timeout: 10_000,
}, async () => {
const config: VaultPluginConfig = {
...baseConfig(),
maxItemSizeBytes: 16,
};
const { app } = makeApp({ principal, config });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "t",
kind: "k",
body: "x".repeat(64),
}),
});
expect(res.status).toBe(413);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_TOO_LARGE);
});
it("returns only principal's items in GET /vault/items", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
// Seed three items for Alice + two for Bob.
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const aliceHandle = await service.openForPrincipal(principal, "p");
await service.createItem(aliceHandle, {
title: "a1",
kind: "k",
body: "1",
});
await service.createItem(aliceHandle, {
title: "a2",
kind: "k",
body: "2",
});
await service.createItem(aliceHandle, {
title: "a3",
kind: "k",
body: "3",
});
await service.close(aliceHandle);
const bobHandle = await service.openForPrincipal(bob, "p");
await service.createItem(bobHandle, {
title: "b1",
kind: "k",
body: "1",
});
await service.createItem(bobHandle, {
title: "b2",
kind: "k",
body: "2",
});
await service.close(bobHandle);
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: Array<{ ownerId: string }>;
meta: { total: number };
};
expect(json.meta.total).toBe(3);
for (const it of json.data) expect(it.ownerId).toBe("user:alice");
});
it("GET /vault/items/:id of foreign item returns 404 (ITEM_NOT_FOUND)", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const bobHandle = await service.openForPrincipal(bob, "p");
const item = await service.createItem(bobHandle, {
title: "secret",
kind: "credential",
body: "k",
});
await service.close(bobHandle);
const res = await app.request(`/vault/items/${item.id}`, {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(404);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
});
it("GET /vault/items/:id returns the item when owned", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "body-value",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { body: string } };
expect(json.data.body).toBe("body-value");
});
it("PATCH /vault/items/:id rejects unknown fields (strict mode)", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "X", evil: "Y" }),
});
expect(res.status).toBe(400);
});
it("PATCH /vault/items/:id returns 404 for unknown id", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items/nope", {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "X" }),
});
expect(res.status).toBe(404);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
});
it("PATCH /vault/items/:id rejects oversize body", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const config: VaultPluginConfig = {
...baseConfig(),
maxItemSizeBytes: 8,
};
const { app } = makeApp({ principal, config, fake: service });
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "x".repeat(64) }),
});
expect(res.status).toBe(413);
});
it("PATCH /vault/items/:id updates the item and returns it", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "old",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "new" }),
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { title: string } };
expect(json.data.title).toBe("new");
});
it("DELETE /vault/items/:id of foreign item returns 404 (not 403)", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const bobHandle = await service.openForPrincipal(bob, "p");
const item = await service.createItem(bobHandle, {
title: "b",
kind: "k",
body: "b",
});
await service.close(bobHandle);
const res = await app.request(`/vault/items/${item.id}`, {
method: "DELETE",
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(404);
});
it("DELETE /vault/items/:id deletes and returns { deleted: true }", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "DELETE",
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { deleted: boolean } };
expect(json.data.deleted).toBe(true);
});
it("UNLOCK_FAILED surfaces 401 when service rejects the password", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService({
passwords: { "user:alice": "correct" },
});
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "wrong" },
});
expect(res.status).toBe(401);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.UNLOCK_FAILED);
});
it("POST with explicit metadata roundtrips", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "with-meta",
kind: "note",
body: "x",
metadata: { v: 1 },
}),
});
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: { metadata: { v: number } };
};
expect(json.data.metadata.v).toBe(1);
});
it("PATCH supports kind / body / metadata permutations", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
// metadata only
const r1 = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ metadata: { x: 1 } }),
});
expect(r1.status).toBe(200);
// kind only
const r2 = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ kind: "new-kind" }),
});
expect(r2.status).toBe(200);
// body only
const r3 = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "new-body" }),
});
expect(r3.status).toBe(200);
});
it("withOpenVault failure path on LIST forwards non-VaultPluginError", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
(service as { listItems: unknown }).listItems = async () => {
throw new Error("list-failure");
};
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBeGreaterThanOrEqual(500);
});
it("withOpenVault failure path on read forwards instrument increment for non-VaultPluginError", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
// Override readItem to throw a plain Error so the catch block
// exercises the non-VaultPluginError branch.
(service as { readItem: unknown }).readItem = async () => {
throw new Error("non-vault-plugin-error");
};
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items/something", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBeGreaterThanOrEqual(500);
});
it("invalid POST body is caught by handler safeParse when bypassing auto-validator", {
timeout: 10_000,
}, async () => {
// Mount routes on a plain Hono — handlers run with explicit
// safeParse path which throws VAULT_PLUGIN.INVALID_INPUT.
const { Hono } = await import("hono");
const service = createFakeVaultService();
const config = baseConfig();
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const entries = buildVaultRoutes(service, config);
for (const e of entries) {
const method = e.route.method as
| "get"
| "post"
| "patch"
| "delete";
const honoPath = e.route.path.replace(/\{(\w+)\}/gu, ":$1");
app[method](honoPath, async (c) => e.handler(c) as never);
}
app.onError((err, c) => {
return c.json(
{
success: false,
error: {
code: (err as { code?: string }).code ?? "X",
},
},
400
);
});
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.INVALID_INPUT);
});
it("invalid PATCH body via plain Hono throws INVALID_INPUT", {
timeout: 10_000,
}, async () => {
const { Hono } = await import("hono");
const service = createFakeVaultService();
const config = baseConfig();
const h = await service.openForPrincipal(principal, "p");
const created = await service.createItem(h, {
title: "t",
kind: "k",
body: "b",
});
await service.close(h);
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const entries = buildVaultRoutes(service, config);
for (const e of entries) {
const method = e.route.method as
| "get"
| "post"
| "patch"
| "delete";
const honoPath = e.route.path.replace(/\{(\w+)\}/gu, ":$1");
app[method](honoPath, async (c) => e.handler(c) as never);
}
app.onError((err, c) =>
c.json(
{
success: false,
error: { code: (err as { code?: string }).code ?? "X" },
},
400
)
);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: 0 }),
});
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.INVALID_INPUT);
});
it("PATCH /vault/items/:id with body field records size histogram", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "new-payload" }),
});
expect(res.status).toBe(200);
});
it("GET /vault/items/:id returns 500 if a non-VaultPluginError leaks from the service", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
// Override readItem to throw a plain Error.
(service as { readItem: unknown }).readItem = async () => {
throw new Error("boom");
};
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items/something", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBeGreaterThanOrEqual(500);
});
});