Repository Public MIT
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