Initial Commit
This commit is contained in:
112
packages/api-stack/api/api-plugin-vault/tests/dep-graph.test.ts
Normal file
112
packages/api-stack/api/api-plugin-vault/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkgRoot = resolve(__dirname, "..");
|
||||
const srcRoot = join(pkgRoot, "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("@suaveplan/api-plugin-vault dep graph + layout", () => {
|
||||
it("package.json carries required dependencies", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
const deps = pkg.dependencies;
|
||||
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-plugin-auth"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-openapi"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-migrations"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/vault"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/crypto-vault"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/error"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/surrealdb"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/types"]).toBe("workspace:^");
|
||||
expect(deps["hono"]).toBe("catalog:");
|
||||
expect(deps["@hono/zod-openapi"]).toBe("catalog:server");
|
||||
expect(deps["zod"]).toBe("catalog:forms");
|
||||
expect(deps["@opentelemetry/api"]).toBe("catalog:observability");
|
||||
});
|
||||
|
||||
it("package.json exposes the 10 documented subpath exports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subs = Object.keys(pkg.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "./package.json"
|
||||
);
|
||||
// 9 named feature subpaths (root `.` and `./package.json` are
|
||||
// filtered separately).
|
||||
expect(subs.length).toBe(9);
|
||||
});
|
||||
|
||||
it("every subpath export resolves to a file within src/", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as {
|
||||
exports: Record<string, { import?: { default?: string } } | string>;
|
||||
};
|
||||
const missing: string[] = [];
|
||||
for (const [sub, val] of Object.entries(pkg.exports)) {
|
||||
if (sub === "./package.json") continue;
|
||||
const defaultPath =
|
||||
typeof val === "string"
|
||||
? val
|
||||
: (val?.import?.default ?? undefined);
|
||||
if (defaultPath === undefined) continue;
|
||||
const rel = defaultPath
|
||||
.replace(/^\.\/dist\//, "")
|
||||
.replace(/\.js$/, ".ts");
|
||||
const srcPath = join(srcRoot, rel);
|
||||
try {
|
||||
statSync(srcPath);
|
||||
} catch {
|
||||
missing.push(`${sub} → ${srcPath}`);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const missing: string[] = [];
|
||||
for (const file of walk(srcRoot)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (
|
||||
file.endsWith(".test.ts") ||
|
||||
file.endsWith(".spec.ts") ||
|
||||
file.endsWith(".types.ts") ||
|
||||
file.endsWith("/index.ts")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const md = file.replace(/\.ts$/, ".md");
|
||||
try {
|
||||
statSync(md);
|
||||
} catch {
|
||||
missing.push(md);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("README.md is present at package root", { timeout: 5000 }, () => {
|
||||
const readme = readFileSync(join(pkgRoot, "README.md"), "utf8");
|
||||
expect(readme.length).toBeGreaterThan(200);
|
||||
expect(readme).toContain("@suaveplan/api-plugin-vault");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
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 { VAULT_PLUGIN_CODES } from "../../src/error-codes/error-codes.js";
|
||||
import { defaultUnlockExtractor } from "../../src/plugin/unlock.js";
|
||||
import { buildVaultRoutes } from "../../src/routes/build-routes.js";
|
||||
import { createVaultService } from "../../src/services/vault-service.js";
|
||||
import type { VaultPluginConfig } from "../../src/types/types.js";
|
||||
import { createMemoryDrive } from "../setup/memory-drive.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function buildApp(principal: Principal) {
|
||||
const drive = createMemoryDrive();
|
||||
const service = createVaultService({
|
||||
storage: drive,
|
||||
serverPepper: "test-pepper",
|
||||
logger: noopLogger,
|
||||
});
|
||||
const config: VaultPluginConfig = {
|
||||
storage: drive,
|
||||
routePrefix: "/vault",
|
||||
maxItemSizeBytes: 10 * 1024 * 1024,
|
||||
applySchemaOnInit: false,
|
||||
unlockExtractor: defaultUnlockExtractor,
|
||||
serverPepper: "test-pepper",
|
||||
};
|
||||
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 | 401 | 403 | 404 | 500
|
||||
);
|
||||
});
|
||||
return { app, service, drive };
|
||||
}
|
||||
|
||||
describe("cross-user-isolation", () => {
|
||||
it("user A cannot see / modify user B's items", {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
const alice: Principal = {
|
||||
id: "user:alice",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const bob: Principal = {
|
||||
id: "user:bob",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
// Alice's setup shares the drive with Bob's app — but each
|
||||
// principal opens its own vault container.
|
||||
const drive = createMemoryDrive();
|
||||
function makeApp(principal: Principal) {
|
||||
const service = createVaultService({
|
||||
storage: drive,
|
||||
serverPepper: "test-pepper",
|
||||
logger: noopLogger,
|
||||
});
|
||||
const config: VaultPluginConfig = {
|
||||
storage: drive,
|
||||
routePrefix: "/vault",
|
||||
maxItemSizeBytes: 10 * 1024 * 1024,
|
||||
applySchemaOnInit: false,
|
||||
unlockExtractor: defaultUnlockExtractor,
|
||||
serverPepper: "test-pepper",
|
||||
};
|
||||
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 | 401 | 403 | 404 | 500
|
||||
);
|
||||
});
|
||||
return { app };
|
||||
}
|
||||
|
||||
// Bob creates an item.
|
||||
const bobApp = makeApp(bob).app;
|
||||
const bobCreate = await bobApp.request("/vault/items", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Vault-Unlock": "BobPwd-2026",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: "bob-secret",
|
||||
kind: "credential",
|
||||
body: "bob-content",
|
||||
}),
|
||||
});
|
||||
expect(bobCreate.status).toBe(201);
|
||||
const bobItem = (await bobCreate.json()) as {
|
||||
data: { id: string };
|
||||
};
|
||||
|
||||
// Alice tries to read Bob's item. Even though Alice supplies
|
||||
// a valid X-Vault-Unlock header, her vault container is
|
||||
// distinct from Bob's, so the lookup yields ITEM_NOT_FOUND.
|
||||
const aliceApp = makeApp(alice).app;
|
||||
const aliceRead = await aliceApp.request(
|
||||
`/vault/items/${bobItem.data.id}`,
|
||||
{
|
||||
headers: { "X-Vault-Unlock": "AlicePwd-2026" },
|
||||
}
|
||||
);
|
||||
expect(aliceRead.status).toBe(404);
|
||||
const json = (await aliceRead.json()) as { error: { code: string } };
|
||||
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
|
||||
|
||||
// Alice's own listing returns zero items.
|
||||
const aliceList = await aliceApp.request("/vault/items", {
|
||||
headers: { "X-Vault-Unlock": "AlicePwd-2026" },
|
||||
});
|
||||
const listJson = (await aliceList.json()) as {
|
||||
meta: { total: number };
|
||||
};
|
||||
expect(listJson.meta.total).toBe(0);
|
||||
});
|
||||
|
||||
it("makeApp helper is exported (sanity)", { timeout: 5000 }, () => {
|
||||
const result = buildApp({
|
||||
id: "x",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
});
|
||||
expect(result.app).toBeDefined();
|
||||
expect(result.service).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createVaultPlugin } from "../../src/plugin/plugin.js";
|
||||
import { createMemoryDrive } from "../setup/memory-drive.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("full integration boot — auth + vault against real SurrealDB", () => {
|
||||
it("sign-up → POST → GET → PATCH → DELETE /vault/items roundtrip against real SurrealDB", {
|
||||
timeout: 180_000,
|
||||
}, async () => {
|
||||
if (client === null) throw new Error("client not ready");
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "vault-integration", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("db", client);
|
||||
|
||||
// 1. Auth plugin — applies schema + registers authService +
|
||||
// installs session middleware on the app.
|
||||
const authPlugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000/auth",
|
||||
emailAndPassword: { enabled: true },
|
||||
},
|
||||
});
|
||||
await authPlugin.onInit?.(
|
||||
createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: authPlugin.defaults as never,
|
||||
services,
|
||||
})
|
||||
);
|
||||
|
||||
// 2. Vault plugin — applies vault schema + registers vaultService
|
||||
// + mounts 5 routes.
|
||||
const drive = createMemoryDrive();
|
||||
const vaultPlugin = createVaultPlugin({
|
||||
storage: drive,
|
||||
applySchemaOnInit: true,
|
||||
});
|
||||
await vaultPlugin.onInit?.(
|
||||
createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: vaultPlugin.defaults as never,
|
||||
services,
|
||||
})
|
||||
);
|
||||
// Error-handler so VaultPluginError → JSON envelope w/ statusCode.
|
||||
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 | 403 | 404 | 413 | 500
|
||||
);
|
||||
});
|
||||
|
||||
// 3. Sign up via Better-Auth.
|
||||
const authService = services.get("authService") as AuthService;
|
||||
expect(authService).toBeDefined();
|
||||
const auth = authService.getBetterAuthInstance();
|
||||
const signUp = (await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: "vault-integration@example.test",
|
||||
password: "Passw0rd!2026",
|
||||
name: "Vault Integration",
|
||||
},
|
||||
asResponse: true,
|
||||
})) as Response;
|
||||
expect(signUp.status).toBeLessThan(400);
|
||||
const cookie = signUp.headers.get("set-cookie") ?? "";
|
||||
|
||||
// 4. POST /vault/items
|
||||
const createRes = await app.request("/vault/items", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Vault-Unlock": "MyVaultPwd-2026",
|
||||
"Content-Type": "application/json",
|
||||
Cookie: cookie.split(";")[0] ?? "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: "API key",
|
||||
kind: "credential",
|
||||
body: "secret-body-value",
|
||||
}),
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const created = (await createRes.json()) as {
|
||||
data: { id: string; title: string; body: string };
|
||||
};
|
||||
expect(created.data.body).toBe("secret-body-value");
|
||||
const itemId = created.data.id;
|
||||
|
||||
// 5. GET /vault/items
|
||||
const listRes = await app.request("/vault/items", {
|
||||
headers: {
|
||||
"X-Vault-Unlock": "MyVaultPwd-2026",
|
||||
Cookie: cookie.split(";")[0] ?? "",
|
||||
},
|
||||
});
|
||||
expect(listRes.status).toBe(200);
|
||||
const list = (await listRes.json()) as {
|
||||
meta: { total: number };
|
||||
};
|
||||
expect(list.meta.total).toBe(1);
|
||||
|
||||
// 6. PATCH /vault/items/:id
|
||||
const patchRes = await app.request(`/vault/items/${itemId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"X-Vault-Unlock": "MyVaultPwd-2026",
|
||||
"Content-Type": "application/json",
|
||||
Cookie: cookie.split(";")[0] ?? "",
|
||||
},
|
||||
body: JSON.stringify({ title: "Renamed" }),
|
||||
});
|
||||
expect(patchRes.status).toBe(200);
|
||||
|
||||
// 7. DELETE /vault/items/:id
|
||||
const delRes = await app.request(`/vault/items/${itemId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"X-Vault-Unlock": "MyVaultPwd-2026",
|
||||
Cookie: cookie.split(";")[0] ?? "",
|
||||
},
|
||||
});
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
// 8. Final list should be empty.
|
||||
const finalRes = await app.request("/vault/items", {
|
||||
headers: {
|
||||
"X-Vault-Unlock": "MyVaultPwd-2026",
|
||||
Cookie: cookie.split(";")[0] ?? "",
|
||||
},
|
||||
});
|
||||
const final = (await finalRes.json()) as { meta: { total: number } };
|
||||
expect(final.meta.total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createVaultPlugin } from "../../src/plugin/plugin.js";
|
||||
import { createMemoryDrive } from "../setup/memory-drive.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
describe("openapi-doc", () => {
|
||||
it("served doc lists 5 /vault/items routes", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "vault-test", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
const plugin = createVaultPlugin({
|
||||
storage: createMemoryDrive(),
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as never,
|
||||
services,
|
||||
})
|
||||
);
|
||||
const routes = registry.getRoutesByPlugin("vault");
|
||||
expect(routes.length).toBe(5);
|
||||
|
||||
registry.applyTo(app, "/openapi.json");
|
||||
const res = await app.request("/openapi.json");
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const vaultPaths = Object.keys(doc.paths).filter((p) =>
|
||||
p.startsWith("/vault/")
|
||||
);
|
||||
expect(vaultPaths.length).toBeGreaterThanOrEqual(2);
|
||||
expect(doc.paths["/vault/items"]).toBeDefined();
|
||||
expect(doc.paths["/vault/items/{id}"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("served /openapi.json includes 5 /vault/items routes", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "vault-test", version: "0.1.0" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
const plugin = createVaultPlugin({
|
||||
storage: createMemoryDrive(),
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
await plugin.onInit?.(
|
||||
createPluginContext({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as never,
|
||||
services,
|
||||
})
|
||||
);
|
||||
const methods = registry
|
||||
.getRoutesByPlugin("vault")
|
||||
.map((r) => `${r.method.toUpperCase()} ${r.path}`);
|
||||
expect(methods).toEqual([
|
||||
"GET /vault/items",
|
||||
"POST /vault/items",
|
||||
"GET /vault/items/{id}",
|
||||
"PATCH /vault/items/{id}",
|
||||
"DELETE /vault/items/{id}",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { CreateVaultItemBodySchema } from "../src/schemas/vault-item.js";
|
||||
|
||||
describe("CreateVaultItemBodySchema fast-check size invariant", () => {
|
||||
it("size invariant: payloads under 10 MiB always validate (200 runs)", {
|
||||
timeout: 60_000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
title: fc.string({ minLength: 1, maxLength: 64 }),
|
||||
kind: fc.string({ minLength: 1, maxLength: 32 }),
|
||||
body: fc.string({ maxLength: 4096 }),
|
||||
}),
|
||||
(sample) => {
|
||||
const result = CreateVaultItemBodySchema.safeParse(sample);
|
||||
// Payloads of arbitrary string body, title and kind
|
||||
// pass the schema — schema does not enforce body
|
||||
// size; that is enforced at the route layer.
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
VAULT_PLUGIN_CODES,
|
||||
VaultPluginError,
|
||||
} from "../../src/error-codes/error-codes.js";
|
||||
import type { VaultService } from "../../src/services/vault-service.js";
|
||||
import type {
|
||||
VaultHandle,
|
||||
VaultItem,
|
||||
VaultItemSummary,
|
||||
} from "../../src/types/types.js";
|
||||
|
||||
/**
|
||||
* Lightweight in-memory `VaultService` used by unit tests so route
|
||||
* coverage does not pay for a real Argon2id pipeline. Stores items in a
|
||||
* per-principal Map.
|
||||
*/
|
||||
export interface FakeVaultServiceOptions {
|
||||
/** Maps principalId → expected password. Wrong password rejects. */
|
||||
readonly passwords?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function createFakeVaultService(
|
||||
options?: FakeVaultServiceOptions
|
||||
): VaultService {
|
||||
const passwords = options?.passwords ?? {};
|
||||
const byPrincipal = new Map<string, Map<string, VaultItem>>();
|
||||
const handles = new WeakMap<VaultHandle, { principalId: string }>();
|
||||
let idCounter = 0;
|
||||
function bucket(principalId: string): Map<string, VaultItem> {
|
||||
let b = byPrincipal.get(principalId);
|
||||
if (b === undefined) {
|
||||
b = new Map();
|
||||
byPrincipal.set(principalId, b);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
function getState(handle: VaultHandle) {
|
||||
const s = handles.get(handle);
|
||||
if (s === undefined) {
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.FORBIDDEN,
|
||||
"fake handle invalid",
|
||||
{ context: { statusCode: 403 } }
|
||||
);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
return {
|
||||
async openForPrincipal(principal, password) {
|
||||
const expected = passwords[principal.id];
|
||||
if (expected !== undefined && expected !== password) {
|
||||
throw new VaultPluginError(
|
||||
VAULT_PLUGIN_CODES.UNLOCK_FAILED,
|
||||
"fake unlock failed",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
const handle: VaultHandle = Object.freeze({
|
||||
__brand: "VaultHandle" as const,
|
||||
principalId: principal.id,
|
||||
});
|
||||
handles.set(handle, { principalId: principal.id });
|
||||
return handle;
|
||||
},
|
||||
async listItemsForOwner(ownerId) {
|
||||
const b = byPrincipal.get(ownerId);
|
||||
if (b === undefined) return [];
|
||||
const out: VaultItemSummary[] = [];
|
||||
for (const i of b.values()) {
|
||||
const { body: _b, ...rest } = i;
|
||||
void _b;
|
||||
out.push(rest);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
async listItems(handle) {
|
||||
const { principalId } = getState(handle);
|
||||
return Array.from(bucket(principalId).values());
|
||||
},
|
||||
async readItem(handle, itemId) {
|
||||
const { principalId } = getState(handle);
|
||||
return bucket(principalId).get(itemId) ?? null;
|
||||
},
|
||||
async createItem(handle, input) {
|
||||
const { principalId } = getState(handle);
|
||||
idCounter += 1;
|
||||
const now = new Date().toISOString();
|
||||
const item: VaultItem = {
|
||||
id: `item-${idCounter}`,
|
||||
ownerId: principalId,
|
||||
title: input.title,
|
||||
kind: input.kind,
|
||||
body: input.body,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(input.metadata !== undefined
|
||||
? { metadata: input.metadata }
|
||||
: {}),
|
||||
};
|
||||
bucket(principalId).set(item.id, item);
|
||||
return item;
|
||||
},
|
||||
async updateItem(handle, itemId, patch) {
|
||||
const { principalId } = getState(handle);
|
||||
const existing = bucket(principalId).get(itemId);
|
||||
if (existing === undefined) return null;
|
||||
const updated: VaultItem = {
|
||||
...existing,
|
||||
...(patch.title !== undefined ? { title: patch.title } : {}),
|
||||
...(patch.kind !== undefined ? { kind: patch.kind } : {}),
|
||||
...(patch.body !== undefined ? { body: patch.body } : {}),
|
||||
...(patch.metadata !== undefined
|
||||
? { metadata: patch.metadata }
|
||||
: {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
bucket(principalId).set(itemId, updated);
|
||||
return updated;
|
||||
},
|
||||
async deleteItem(handle, itemId) {
|
||||
const { principalId } = getState(handle);
|
||||
return bucket(principalId).delete(itemId);
|
||||
},
|
||||
async close(handle) {
|
||||
handles.delete(handle);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { DriveLike } from "@suaveplan/vault";
|
||||
|
||||
/** Minimal in-memory `DriveLike` for tests. */
|
||||
export function createMemoryDrive(): DriveLike {
|
||||
const store = new Map<string, Uint8Array>();
|
||||
return {
|
||||
async read(path) {
|
||||
const bytes = store.get(path);
|
||||
if (bytes === undefined)
|
||||
throw new Error(`memory-drive: missing path ${path}`);
|
||||
return bytes;
|
||||
},
|
||||
async write(path, data) {
|
||||
store.set(path, data);
|
||||
},
|
||||
async delete(path) {
|
||||
return store.delete(path);
|
||||
},
|
||||
async exists(path) {
|
||||
return store.has(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-vault.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. Node 18+
|
||||
* + Bun expose `crypto.subtle` / `crypto.getRandomValues` globally so no
|
||||
* crypto polyfill is required.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user