Initial Commit
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
# routes/build-routes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the 5 OpenAPI route entries that `createVaultPlugin` mounts on
|
||||
the host's Hono app: list, create, read, update, delete. Each entry is
|
||||
a `PluginRouteEntry` — a `RouteConfig` plus a handler — with full Zod
|
||||
inference through the `RouteBuilder` chain. The OpenAPI document and
|
||||
the runtime routing surface cannot drift because both are produced from
|
||||
the same builder output.
|
||||
|
||||
## Features
|
||||
|
||||
- Five routes — `GET /vault/items`, `POST /vault/items`,
|
||||
`GET /vault/items/:id`, `PATCH /vault/items/:id`,
|
||||
`DELETE /vault/items/:id` — each tagged `vault` for grouped doc
|
||||
display.
|
||||
- Per-handler upstream `requireAuth()` invocation — every route is
|
||||
gated; anonymous calls produce `AUTH_UNAUTHENTICATED` (401) via the
|
||||
auth plugin's middleware.
|
||||
- Per-request open/close lifecycle: every route opens the principal's
|
||||
vault via `vaultService.openForPrincipal`, performs the operation,
|
||||
and closes the handle in `finally` so the vault is locked again
|
||||
before the response returns.
|
||||
- Anti-enumeration policy baked in: any reference to an item id that
|
||||
does not exist OR is owned by another principal raises
|
||||
`VAULT_PLUGIN_ITEM_NOT_FOUND` (404) — NEVER 403. This prevents an
|
||||
attacker from probing the existence of item ids owned by other
|
||||
principals.
|
||||
- Size enforcement at the handler boundary, not at the Zod schema, so
|
||||
the error message can include the exact byte count and the configured
|
||||
`maxItemSizeBytes`.
|
||||
- 4 OTel counters + 1 histogram emitted per operation (success +
|
||||
failure outcomes).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildVaultRoutes } from "@suaveplan/api-plugin-vault";
|
||||
|
||||
const entries = buildVaultRoutes(vaultService, config);
|
||||
definePluginRoutes(entries, {
|
||||
app,
|
||||
registry: openAPIRegistry,
|
||||
pluginName: "vault",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
// Drop one of the 5 routes by filtering before registration — operators
|
||||
// running a read-only mirror can elide the mutation routes.
|
||||
const entries = buildVaultRoutes(vaultService, config).filter((e) =>
|
||||
e.route.method === "get"
|
||||
);
|
||||
definePluginRoutes(entries, { app, registry, pluginName: "vault" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildVaultRoutes(vaultService, config): PluginRouteEntry[]`
|
||||
|
||||
Returns 5 entries in registration order: list, create, get, update,
|
||||
delete. Every entry has:
|
||||
|
||||
- `route` — a built `RouteConfig` carrying the full Zod schema chain.
|
||||
- `handler` — async function taking a Hono `Context` and returning
|
||||
`Promise<Response>`.
|
||||
|
||||
The handler wraps the actual operation in a `requireAuth()` gate; the
|
||||
auth plugin's `Principal` is read from `c.get("user")`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `RouteBuilder` `.params(VaultItemParamsSchema)` registers the
|
||||
`{id}` URL parameter on the OpenAPI doc using the OpenAPI convention
|
||||
(`{id}`); Hono's runtime router consumes the same builder output, so
|
||||
the path matches both surfaces.
|
||||
- The handler explicitly normalises the wire envelope: every success
|
||||
response is `{ success: true, data }` (list also includes
|
||||
`meta: { total }`). The shared error envelope is contributed by the
|
||||
`StandardResponses` block from `@suaveplan/api-openapi` — handlers
|
||||
never construct an error envelope themselves; they `throw` a
|
||||
`VaultPluginError` and let the host's error-handler middleware
|
||||
serialise it.
|
||||
- `Buffer.byteLength(body, "utf8")` is used for size enforcement so
|
||||
the byte count matches what an HTTP client would actually transmit
|
||||
(rather than the JavaScript string `length` which counts UTF-16
|
||||
code units).
|
||||
- The defensive null-check on `c.get("user")` inside `withOpenVault`
|
||||
exists because `requireAuth()` runs in a separate middleware step
|
||||
and TypeScript cannot narrow across the await boundary. Production
|
||||
cannot reach the branch (requireAuth has already thrown), but the
|
||||
branch is reachable from unit tests that bypass the guard.
|
||||
- Coverage note: the `requireAuthThen` `response === undefined` branch
|
||||
exists for TypeScript narrowing; the unit tests assert it raises
|
||||
`VAULT_PLUGIN_FORBIDDEN` by injecting a stubbed `requireAuth` that
|
||||
returns without invoking `next`.
|
||||
@@ -0,0 +1,703 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
PATCH,
|
||||
type PluginRouteEntry,
|
||||
POST,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
VAULT_PLUGIN_CODES,
|
||||
VaultPluginError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getVaultPluginInstruments } from "../otel/instruments.js";
|
||||
import { extractUnlock } from "../plugin/unlock.js";
|
||||
import {
|
||||
CreateVaultItemBodySchema,
|
||||
DeleteVaultItemResponseSchema,
|
||||
UpdateVaultItemBodySchema,
|
||||
VaultItemListResponseSchema,
|
||||
VaultItemParamsSchema,
|
||||
VaultItemResponseSchema,
|
||||
} from "../schemas/vault-item.js";
|
||||
import type { VaultService } from "../services/vault-service.js";
|
||||
import type {
|
||||
VaultHandle,
|
||||
VaultItem,
|
||||
VaultPluginConfig,
|
||||
VaultUnlockExtractor,
|
||||
} from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Build the 5 vault routes. Every route uses `requireAuth()` upstream
|
||||
* so the principal is guaranteed non-null. Foreign items are surfaced
|
||||
* as `VAULT_PLUGIN_ITEM_NOT_FOUND` (404) to prevent enumeration —
|
||||
* never 403.
|
||||
*/
|
||||
export function buildVaultRoutes(
|
||||
vaultService: VaultService,
|
||||
config: VaultPluginConfig
|
||||
): PluginRouteEntry[] {
|
||||
const list = GET("/vault/items")
|
||||
.tags("vault")
|
||||
.summary("List the principal's vault items")
|
||||
.response(successResponse(VaultItemListResponseSchema.shape.data))
|
||||
.build();
|
||||
const create = POST("/vault/items")
|
||||
.tags("vault")
|
||||
.summary("Create a new vault item")
|
||||
.body(CreateVaultItemBodySchema)
|
||||
.response(successResponse(VaultItemResponseSchema.shape.data))
|
||||
.build();
|
||||
const get = GET("/vault/items/{id}")
|
||||
.tags("vault")
|
||||
.summary("Fetch a single vault item")
|
||||
.params(VaultItemParamsSchema)
|
||||
.response(successResponse(VaultItemResponseSchema.shape.data))
|
||||
.build();
|
||||
const update = PATCH("/vault/items/{id}")
|
||||
.tags("vault")
|
||||
.summary("Update fields on an existing vault item")
|
||||
.params(VaultItemParamsSchema)
|
||||
.body(UpdateVaultItemBodySchema)
|
||||
.response(successResponse(VaultItemResponseSchema.shape.data))
|
||||
.build();
|
||||
const remove = DELETE("/vault/items/{id}")
|
||||
.tags("vault")
|
||||
.summary("Delete a vault item")
|
||||
.params(VaultItemParamsSchema)
|
||||
.response(successResponse(DeleteVaultItemResponseSchema.shape.data))
|
||||
.build();
|
||||
|
||||
const handlers = createHandlers(vaultService, config);
|
||||
|
||||
return [
|
||||
{ route: list, handler: requireAuthThen(handlers.list) },
|
||||
{ route: create, handler: requireAuthThen(handlers.create) },
|
||||
{ route: get, handler: requireAuthThen(handlers.get) },
|
||||
{ route: update, handler: requireAuthThen(handlers.update) },
|
||||
{ route: remove, handler: requireAuthThen(handlers.remove) },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a handler with `requireAuth()` upstream — the middleware throws
|
||||
* `AuthError(AUTH_UNAUTHENTICATED)` for anonymous principals, so every
|
||||
* handler downstream may safely assume `c.get("user")` is non-null.
|
||||
*
|
||||
* `requireAuth` invokes `next` whenever the principal is non-null and
|
||||
* throws otherwise — the wrapped handler always sets `response` before
|
||||
* `await guard(...)` resolves, so the post-await read is safe.
|
||||
*/
|
||||
function requireAuthThen(
|
||||
handler: (c: Context) => Promise<Response>
|
||||
): (c: Context) => Promise<Response> {
|
||||
const guard = requireAuth();
|
||||
return async (c) => {
|
||||
let response: Response | undefined;
|
||||
await guard(c, async () => {
|
||||
response = await handler(c);
|
||||
});
|
||||
return response as Response;
|
||||
};
|
||||
}
|
||||
|
||||
interface Handlers {
|
||||
readonly list: (c: Context) => Promise<Response>;
|
||||
readonly create: (c: Context) => Promise<Response>;
|
||||
readonly get: (c: Context) => Promise<Response>;
|
||||
readonly update: (c: Context) => Promise<Response>;
|
||||
readonly remove: (c: Context) => Promise<Response>;
|
||||
}
|
||||
|
||||
function createHandlers(
|
||||
vaultService: VaultService,
|
||||
config: VaultPluginConfig
|
||||
): Handlers {
|
||||
const extractor: VaultUnlockExtractor = config.unlockExtractor;
|
||||
const maxBytes = config.maxItemSizeBytes;
|
||||
|
||||
return {
|
||||
list: async (c) =>
|
||||
withOpenVault(
|
||||
c,
|
||||
vaultService,
|
||||
extractor,
|
||||
async (handle) => {
|
||||
const items = await vaultService.listItems(handle);
|
||||
const summaries = items.map(toSummary);
|
||||
getVaultPluginInstruments().itemsReadTotal.add(1, {
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json({
|
||||
success: true as const,
|
||||
data: summaries,
|
||||
meta: { total: summaries.length },
|
||||
});
|
||||
},
|
||||
"list"
|
||||
),
|
||||
create: async (c) =>
|
||||
withOpenVault(
|
||||
c,
|
||||
vaultService,
|
||||
extractor,
|
||||
async (handle) => {
|
||||
const raw = await c.req.json<unknown>();
|
||||
const parsed = CreateVaultItemBodySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
incrementOutcome("create", "failure");
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.INVALID_INPUT,
|
||||
"POST /vault/items received an invalid body",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { statusCode: 400 },
|
||||
}
|
||||
);
|
||||
}
|
||||
enforceBodySize(parsed.data.body, maxBytes, "create");
|
||||
const createInput: {
|
||||
title: string;
|
||||
kind: string;
|
||||
body: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
} = {
|
||||
title: parsed.data.title,
|
||||
kind: parsed.data.kind,
|
||||
body: parsed.data.body,
|
||||
...(parsed.data.metadata !== undefined
|
||||
? { metadata: parsed.data.metadata }
|
||||
: {}),
|
||||
};
|
||||
const item = await vaultService.createItem(
|
||||
handle,
|
||||
createInput
|
||||
);
|
||||
const bodyBytes = Buffer.byteLength(item.body, "utf8");
|
||||
const instruments = getVaultPluginInstruments();
|
||||
instruments.itemsCreatedTotal.add(1, {
|
||||
outcome: "success",
|
||||
});
|
||||
instruments.itemSizeBytes.record(bodyBytes);
|
||||
return c.json({ success: true as const, data: item }, 201);
|
||||
},
|
||||
"create"
|
||||
),
|
||||
get: async (c) =>
|
||||
withOpenVault(
|
||||
c,
|
||||
vaultService,
|
||||
extractor,
|
||||
async (handle) => {
|
||||
const params = VaultItemParamsSchema.parse(c.req.param());
|
||||
const item = await vaultService.readItem(handle, params.id);
|
||||
if (item === null) {
|
||||
incrementOutcome("read", "failure");
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
|
||||
"vault item not found",
|
||||
{ context: { id: params.id, statusCode: 404 } }
|
||||
);
|
||||
}
|
||||
incrementOutcome("read", "success");
|
||||
return c.json({ success: true as const, data: item });
|
||||
},
|
||||
"read"
|
||||
),
|
||||
update: async (c) =>
|
||||
withOpenVault(
|
||||
c,
|
||||
vaultService,
|
||||
extractor,
|
||||
async (handle) => {
|
||||
const params = VaultItemParamsSchema.parse(c.req.param());
|
||||
const raw = await c.req.json<unknown>();
|
||||
const parsed = UpdateVaultItemBodySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
incrementOutcome("update", "failure");
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.INVALID_INPUT,
|
||||
"PATCH /vault/items/:id received an invalid body",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { statusCode: 400 },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (parsed.data.body !== undefined) {
|
||||
enforceBodySize(parsed.data.body, maxBytes, "update");
|
||||
}
|
||||
const updatePatch: {
|
||||
title?: string;
|
||||
kind?: string;
|
||||
body?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
} = {
|
||||
...(parsed.data.title !== undefined
|
||||
? { title: parsed.data.title }
|
||||
: {}),
|
||||
...(parsed.data.kind !== undefined
|
||||
? { kind: parsed.data.kind }
|
||||
: {}),
|
||||
...(parsed.data.body !== undefined
|
||||
? { body: parsed.data.body }
|
||||
: {}),
|
||||
...(parsed.data.metadata !== undefined
|
||||
? { metadata: parsed.data.metadata }
|
||||
: {}),
|
||||
};
|
||||
const item = await vaultService.updateItem(
|
||||
handle,
|
||||
params.id,
|
||||
updatePatch
|
||||
);
|
||||
if (item === null) {
|
||||
incrementOutcome("update", "failure");
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
|
||||
"vault item not found",
|
||||
{ context: { id: params.id, statusCode: 404 } }
|
||||
);
|
||||
}
|
||||
const instruments = getVaultPluginInstruments();
|
||||
instruments.itemsUpdatedTotal.add(1, {
|
||||
outcome: "success",
|
||||
});
|
||||
if (parsed.data.body !== undefined) {
|
||||
instruments.itemSizeBytes.record(
|
||||
Buffer.byteLength(parsed.data.body, "utf8")
|
||||
);
|
||||
}
|
||||
return c.json({ success: true as const, data: item });
|
||||
},
|
||||
"update"
|
||||
),
|
||||
remove: async (c) =>
|
||||
withOpenVault(
|
||||
c,
|
||||
vaultService,
|
||||
extractor,
|
||||
async (handle) => {
|
||||
const params = VaultItemParamsSchema.parse(c.req.param());
|
||||
const deleted = await vaultService.deleteItem(
|
||||
handle,
|
||||
params.id
|
||||
);
|
||||
if (!deleted) {
|
||||
incrementOutcome("delete", "failure");
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
|
||||
"vault item not found",
|
||||
{ context: { id: params.id, statusCode: 404 } }
|
||||
);
|
||||
}
|
||||
getVaultPluginInstruments().itemsDeletedTotal.add(1, {
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json({
|
||||
success: true as const,
|
||||
data: { deleted: true as const },
|
||||
});
|
||||
},
|
||||
"delete"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function toSummary(item: VaultItem) {
|
||||
const { body: _body, ...summary } = item;
|
||||
void _body;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function enforceBodySize(
|
||||
body: string,
|
||||
maxBytes: number,
|
||||
op: "create" | "update"
|
||||
): void {
|
||||
const bytes = Buffer.byteLength(body, "utf8");
|
||||
if (bytes > maxBytes) {
|
||||
incrementOutcome(op, "failure");
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.ITEM_TOO_LARGE,
|
||||
"vault item exceeds maxItemSizeBytes",
|
||||
{ context: { bytes, maxBytes, statusCode: 413 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function incrementOutcome(
|
||||
op: "create" | "read" | "update" | "delete",
|
||||
outcome: "success" | "failure"
|
||||
): void {
|
||||
const i = getVaultPluginInstruments();
|
||||
if (op === "create") i.itemsCreatedTotal.add(1, { outcome });
|
||||
else if (op === "read") i.itemsReadTotal.add(1, { outcome });
|
||||
else if (op === "update") i.itemsUpdatedTotal.add(1, { outcome });
|
||||
else i.itemsDeletedTotal.add(1, { outcome });
|
||||
}
|
||||
|
||||
async function withOpenVault(
|
||||
c: Context,
|
||||
vaultService: VaultService,
|
||||
extractor: VaultUnlockExtractor,
|
||||
fn: (handle: VaultHandle) => Promise<Response>,
|
||||
op: "list" | "create" | "read" | "update" | "delete"
|
||||
): Promise<Response> {
|
||||
// requireAuth has already verified the user is non-null before the
|
||||
// route handler runs; the non-null assertion is safe because the
|
||||
// guard above throws AuthError(UNAUTHENTICATED) otherwise.
|
||||
const principal = c.get("user")!;
|
||||
const unlockSecret = extractUnlock(c, extractor);
|
||||
let handle: VaultHandle | undefined;
|
||||
try {
|
||||
handle = await vaultService.openForPrincipal(principal, unlockSecret);
|
||||
return await fn(handle);
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof VaultPluginError) &&
|
||||
(op === "list" || op === "read")
|
||||
) {
|
||||
incrementOutcome(op === "list" ? "read" : op, "failure");
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (handle !== undefined) await vaultService.close(handle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
createOpenAPIRegistry,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { 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 { defaultUnlockExtractor } from "../plugin/unlock.js";
|
||||
import type { VaultPluginConfig } from "../types/types.js";
|
||||
import { buildVaultRoutes } from "./build-routes.js";
|
||||
|
||||
function build(principal: Principal, maxBytes: number) {
|
||||
const service = createFakeVaultService();
|
||||
const config: VaultPluginConfig = {
|
||||
storage: createMemoryDrive(),
|
||||
routePrefix: "/vault",
|
||||
maxItemSizeBytes: maxBytes,
|
||||
applySchemaOnInit: false,
|
||||
unlockExtractor: defaultUnlockExtractor,
|
||||
serverPepper: "p",
|
||||
};
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
definePluginRoutes(buildVaultRoutes(service, config), {
|
||||
app,
|
||||
registry,
|
||||
pluginName: "vault",
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
const status =
|
||||
(err as { context?: { statusCode?: number } }).context
|
||||
?.statusCode ?? 500;
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: (err as { code?: string }).code ?? "X",
|
||||
message: (err as Error).message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
status as 400 | 413 | 500
|
||||
);
|
||||
});
|
||||
return { app };
|
||||
}
|
||||
|
||||
describe("routes/create", () => {
|
||||
it("oversize body raises ITEM_TOO_LARGE", { timeout: 10_000 }, async () => {
|
||||
const alice: Principal = {
|
||||
id: "user:alice",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const { app } = build(alice, 8);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
createOpenAPIRegistry,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { 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 { defaultUnlockExtractor } from "../plugin/unlock.js";
|
||||
import type { VaultPluginConfig } from "../types/types.js";
|
||||
import { buildVaultRoutes } from "./build-routes.js";
|
||||
|
||||
function build(principal: Principal) {
|
||||
const service = createFakeVaultService();
|
||||
const config: VaultPluginConfig = {
|
||||
storage: createMemoryDrive(),
|
||||
routePrefix: "/vault",
|
||||
maxItemSizeBytes: 10 * 1024 * 1024,
|
||||
applySchemaOnInit: false,
|
||||
unlockExtractor: defaultUnlockExtractor,
|
||||
serverPepper: "p",
|
||||
};
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
definePluginRoutes(buildVaultRoutes(service, config), {
|
||||
app,
|
||||
registry,
|
||||
pluginName: "vault",
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
const status =
|
||||
(err as { context?: { statusCode?: number } }).context
|
||||
?.statusCode ?? 500;
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: (err as { code?: string }).code ?? "X",
|
||||
message: (err as Error).message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
status as 404 | 500
|
||||
);
|
||||
});
|
||||
return { app, service };
|
||||
}
|
||||
|
||||
describe("routes/delete", () => {
|
||||
it("foreign item returns 404", { timeout: 10_000 }, async () => {
|
||||
const alice: Principal = {
|
||||
id: "user:alice",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const bob: Principal = {
|
||||
id: "user:bob",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const { app, service } = build(alice);
|
||||
const bh = await service.openForPrincipal(bob, "p");
|
||||
const item = await service.createItem(bh, {
|
||||
title: "b",
|
||||
kind: "k",
|
||||
body: "b",
|
||||
});
|
||||
await service.close(bh);
|
||||
const res = await app.request(`/vault/items/${item.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Vault-Unlock": "secret" },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
createOpenAPIRegistry,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { 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 { defaultUnlockExtractor } from "../plugin/unlock.js";
|
||||
import type { VaultPluginConfig } from "../types/types.js";
|
||||
import { buildVaultRoutes } from "./build-routes.js";
|
||||
|
||||
function build(principal: Principal) {
|
||||
const service = createFakeVaultService();
|
||||
const config: VaultPluginConfig = {
|
||||
storage: createMemoryDrive(),
|
||||
routePrefix: "/vault",
|
||||
maxItemSizeBytes: 10 * 1024 * 1024,
|
||||
applySchemaOnInit: false,
|
||||
unlockExtractor: defaultUnlockExtractor,
|
||||
serverPepper: "p",
|
||||
};
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
definePluginRoutes(buildVaultRoutes(service, config), {
|
||||
app,
|
||||
registry,
|
||||
pluginName: "vault",
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
const status =
|
||||
(err as { context?: { statusCode?: number } }).context
|
||||
?.statusCode ?? 500;
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: (err as { code?: string }).code ?? "X",
|
||||
message: (err as Error).message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
status as 404 | 500
|
||||
);
|
||||
});
|
||||
return { app, service };
|
||||
}
|
||||
|
||||
describe("routes/get", () => {
|
||||
it("foreign item returns ITEM_NOT_FOUND", { timeout: 10_000 }, async () => {
|
||||
const alice: Principal = {
|
||||
id: "user:alice",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const bob: Principal = {
|
||||
id: "user:bob",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const { app, service } = build(alice);
|
||||
const bh = await service.openForPrincipal(bob, "p");
|
||||
const item = await service.createItem(bh, {
|
||||
title: "secret",
|
||||
kind: "k",
|
||||
body: "b",
|
||||
});
|
||||
await service.close(bh);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { buildVaultRoutes } from "./build-routes.js";
|
||||
@@ -0,0 +1,86 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
createOpenAPIRegistry,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { 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 { defaultUnlockExtractor } from "../plugin/unlock.js";
|
||||
import type { VaultPluginConfig } from "../types/types.js";
|
||||
import { buildVaultRoutes } from "./build-routes.js";
|
||||
|
||||
function build(principal: Principal | null) {
|
||||
const service = createFakeVaultService();
|
||||
const config: VaultPluginConfig = {
|
||||
storage: createMemoryDrive(),
|
||||
routePrefix: "/vault",
|
||||
maxItemSizeBytes: 10 * 1024 * 1024,
|
||||
applySchemaOnInit: false,
|
||||
unlockExtractor: defaultUnlockExtractor,
|
||||
serverPepper: "p",
|
||||
};
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
const entries = buildVaultRoutes(service, config);
|
||||
definePluginRoutes(entries, { app, registry, pluginName: "vault" });
|
||||
app.onError((err, c) => {
|
||||
const status =
|
||||
(err as { context?: { statusCode?: number } }).context
|
||||
?.statusCode ?? 500;
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: (err as { code?: string }).code ?? "X",
|
||||
message: (err as Error).message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
status as 400 | 401 | 404 | 500
|
||||
);
|
||||
});
|
||||
return { app, service };
|
||||
}
|
||||
|
||||
describe("routes/list", () => {
|
||||
it("returns only principal's items", { timeout: 10_000 }, async () => {
|
||||
const alice: Principal = {
|
||||
id: "user:alice",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const bob: Principal = {
|
||||
id: "user:bob",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const { app, service } = build(alice);
|
||||
const ah = await service.openForPrincipal(alice, "p");
|
||||
await service.createItem(ah, { title: "a1", kind: "k", body: "1" });
|
||||
await service.createItem(ah, { title: "a2", kind: "k", body: "2" });
|
||||
await service.createItem(ah, { title: "a3", kind: "k", body: "3" });
|
||||
await service.close(ah);
|
||||
const bh = await service.openForPrincipal(bob, "p");
|
||||
await service.createItem(bh, { title: "b1", kind: "k", body: "1" });
|
||||
await service.createItem(bh, { title: "b2", kind: "k", body: "2" });
|
||||
await service.close(bh);
|
||||
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 i of json.data) expect(i.ownerId).toBe("user:alice");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import {
|
||||
createOpenAPIRegistry,
|
||||
definePluginRoutes,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { 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 { defaultUnlockExtractor } from "../plugin/unlock.js";
|
||||
import type { VaultPluginConfig } from "../types/types.js";
|
||||
import { buildVaultRoutes } from "./build-routes.js";
|
||||
|
||||
function build(principal: Principal) {
|
||||
const service = createFakeVaultService();
|
||||
const config: VaultPluginConfig = {
|
||||
storage: createMemoryDrive(),
|
||||
routePrefix: "/vault",
|
||||
maxItemSizeBytes: 10 * 1024 * 1024,
|
||||
applySchemaOnInit: false,
|
||||
unlockExtractor: defaultUnlockExtractor,
|
||||
serverPepper: "p",
|
||||
};
|
||||
const app = new OpenAPIHono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", principal);
|
||||
await next();
|
||||
});
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
definePluginRoutes(buildVaultRoutes(service, config), {
|
||||
app,
|
||||
registry,
|
||||
pluginName: "vault",
|
||||
});
|
||||
app.onError((err, c) => {
|
||||
const status =
|
||||
(err as { context?: { statusCode?: number } }).context
|
||||
?.statusCode ?? 500;
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: (err as { code?: string }).code ?? "X",
|
||||
message: (err as Error).message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
status as 400 | 500
|
||||
);
|
||||
});
|
||||
return { app, service };
|
||||
}
|
||||
|
||||
describe("routes/patch", () => {
|
||||
it("rejects unknown fields", { timeout: 10_000 }, async () => {
|
||||
const alice: Principal = {
|
||||
id: "user:alice",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const { app, service } = build(alice);
|
||||
const h = await service.openForPrincipal(alice, "p");
|
||||
const item = await service.createItem(h, {
|
||||
title: "t",
|
||||
kind: "k",
|
||||
body: "b",
|
||||
});
|
||||
await service.close(h);
|
||||
const res = await app.request(`/vault/items/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"X-Vault-Unlock": "secret",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ title: "X", evil: "Y" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user