SPB Git

Repository Public MIT

helpers.ts 6.8 KB 129 lines typescript

1import { mkdtempSync, rmSync } from "node:fs";2import { tmpdir } from "node:os";3import { join } from "node:path";4import { randomBytes } from "node:crypto";5import postgres from "postgres";6import { connect, type Sql } from "@skills/db";7import { migrate } from "@skills/db/migrate";8import { MANIFEST_FILE, PUBLISHER_SCOPES, type TarFile } from "@skills/registry-core";9import { packFiles as pack } from "@skills/registry-core/node";10import { bootstrapOwner, createToken, hashPassword } from "../apps/web/src/server/auth";11import { handleApi } from "../apps/web/src/server/api";12import { makeCtx, type Ctx, type UserRow } from "../apps/web/src/server/context";13import { setDb } from "../apps/web/src/server/db";14import { resetRateLimits } from "../apps/web/src/server/ratelimit";1516const ADMIN_URL = process.env.TEST_ADMIN_DATABASE_URL ?? "postgres://localhost/postgres?host=/tmp";1718export interface TestEnv {19  sql: Sql;20  dbName: string;21  dataDir: string;22  owner: UserRow;23  ownerPassword: string;24  local: Ctx;25  close: () => Promise<void>;26}2728/** A disposable database and data directory per test file. Nothing touches dev or production state. */29export async function setupTestEnv(): Promise<TestEnv> {30  const dbName = `skills_test_${randomBytes(6).toString("hex")}`;31  const admin = postgres(ADMIN_URL, { max: 1, host: "/tmp", onnotice: () => {} });32  await admin.unsafe(`CREATE DATABASE ${dbName}`);33  await admin.end();34  const dataDir = mkdtempSync(join(tmpdir(), "skills-test-"));35  process.env.DATABASE_URL = `postgres://localhost/${dbName}?host=/tmp`;36  process.env.TOKEN_PEPPER = randomBytes(32).toString("hex");37  process.env.DATA_DIR = dataDir;38  process.env.PUBLIC_URL = "http://registry.test";39  process.env.SKILLS_DISABLE_RATE_LIMIT = "1";40  const sql = connect(process.env.DATABASE_URL, { max: 5 });41  await migrate(sql);42  setDb(sql);43  resetRateLimits();44  const local = makeCtx({ sql, principal: { kind: "local", user: null } });45  const ownerPassword = "owner-initial-password-123";46  const { user } = await bootstrapOwner(local, { email: "owner@example.test", displayName: "Test Owner", password: ownerPassword, namespace: "research" });47  return {48    sql, dbName, dataDir, owner: user, ownerPassword, local,49    close: async () => {50      setDb(undefined);51      await sql.end();52      const a = postgres(ADMIN_URL, { max: 1, host: "/tmp", onnotice: () => {} });53      await a.unsafe(`DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`);54      await a.end();55      rmSync(dataDir, { recursive: true, force: true });56    },57  };58}5960export async function addUser(env: TestEnv, email: string, namespace: string): Promise<UserRow> {61  const [u] = await env.sql<UserRow[]>`62    INSERT INTO users (email, email_verified_at, display_name, password_hash) VALUES (${email}, now(), ${email.split("@")[0]!}, ${await hashPassword("another-password-456")})63    RETURNING id, email, display_name, platform_role, status, must_change_password, email_verified_at`;64  await env.sql`INSERT INTO namespaces (name, owner_user_id) VALUES (${namespace}, ${u!.id})`;65  return u!;66}6768export async function tokenFor(env: TestEnv, user: UserRow, namespaces: string[], scopes: string[] = PUBLISHER_SCOPES, expiresInHours?: number): Promise<{ token: string; id: string }> {69  const { token, row } = await createToken({ ...env.local, principal: { kind: "local", user } }, { name: `test-${randomBytes(3).toString("hex")}`, scopes, namespaces, expiresInHours });70  return { token, id: row.id };71}7273export interface CallOpts {74  token?: string;75  body?: unknown;76  raw?: Buffer;77  headers?: Record<string, string>;78}79export async function call(method: string, path: string, opts: CallOpts = {}): Promise<{ status: number; json: any; res: Response }> {80  const headers: Record<string, string> = { "user-agent": "vitest/1.0", "x-forwarded-for": "203.0.113.7", ...(opts.headers ?? {}) };81  if (opts.token) headers.authorization = `Bearer ${opts.token}`;82  let body: BodyInit | undefined;83  if (opts.raw) {84    body = new Uint8Array(opts.raw);85    headers["content-type"] = "application/gzip";86    headers["content-length"] = String(opts.raw.length);87  } else if (opts.body !== undefined) {88    body = JSON.stringify(opts.body);89    headers["content-type"] = "application/json";90  }91  const res = await handleApi(new Request(`http://registry.test/api/v1${path}`, { method, headers, body, ...(body ? { duplex: "half" } : {}) } as RequestInit));92  const ct = res.headers.get("content-type") ?? "";93  const json = ct.includes("json") ? await res.clone().json() : null;94  return { status: res.status, json, res };95}9697export function manifest(over: Record<string, unknown> = {}) {98  return { schemaVersion: "1.0", name: "@research/hello-skill", version: "1.0.0", type: "skill", description: "A harmless example skill used by the test suite.", license: "MIT", compatibility: ["claude-code", "codex"], ...over };99}100export function skillFiles(over: Record<string, unknown> = {}, extra: TarFile[] = []): TarFile[] {101  const m = manifest(over);102  const slug = String(m.name).split("/")[1];103  return [104    { path: MANIFEST_FILE, mode: 0o644, data: Buffer.from(JSON.stringify(m, null, 2)) },105    { path: "SKILL.md", mode: 0o644, data: Buffer.from(`---\nname: ${slug}\ndescription: Says hello. Use when the user asks for a greeting.\nlicense: MIT\n---\n\n# Hello\n\nReply with a friendly greeting.\n`) },106    { path: "README.md", mode: 0o644, data: Buffer.from(`# ${slug}\n\nExample skill.\n`) },107    { path: "LICENSE", mode: 0o644, data: Buffer.from("MIT License\n") },108    ...extra,109  ];110}111112/** Full publish flow through the HTTP API: create (if needed) → begin → PUT → finalize. */113export async function publish(token: string, files: TarFile[], opts: { create?: boolean; visibility?: string } = {}) {114  const m = JSON.parse(files.find((f) => f.path === MANIFEST_FILE)!.data.toString());115  const [namespace, slug] = String(m.name).slice(1).split("/") as [string, string];116  if (opts.create !== false) {117    const c = await call("POST", "/artifacts", { token, body: { namespace, slug, type: m.type, summary: m.description, visibility: opts.visibility ?? "public", confirmPublic: true } });118    if (c.status !== 201 && c.status !== 409) return { step: "create", ...c };119  }120  const art = await call("GET", `/artifacts/${namespace}/${slug}`, { token });121  const { archive, sha256 } = pack(files);122  const begin = await call("POST", `/artifacts/${art.json.id}/releases`, { token, body: { version: m.version, sha256, sizeBytes: archive.length } });123  if (begin.status !== 201) return { step: "begin", ...begin };124  const put = await call("PUT", `/uploads/${begin.json.uploadId}`, { token, raw: archive });125  if (put.status !== 200) return { step: "put", ...put };126  const fin = await call("POST", `/uploads/${begin.json.uploadId}/finalize`, { token, body: { sha256 } });127  return { step: "finalize", ...fin, archive, sha256, artifactId: art.json.id as string, uploadId: begin.json.uploadId as string };128}129
Files main
Menu

Theme