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; }): { app: OpenAPIHono; service: ReturnType } { 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); }); });