202 lines
7.5 KiB
TypeScript
202 lines
7.5 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { writeContext } from "../client/context.js";
|
|
import { setStoredBoardCredential } from "../client/board-auth.js";
|
|
import { apiPath, inferContentTypeFromPath, resolveApiBase, resolveCommandContext } from "../commands/client/common.js";
|
|
|
|
const ORIGINAL_ENV = { ...process.env };
|
|
|
|
function createTempPath(name: string): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
|
|
return path.join(dir, name);
|
|
}
|
|
|
|
describe("resolveCommandContext", () => {
|
|
beforeEach(() => {
|
|
process.env = { ...ORIGINAL_ENV };
|
|
delete process.env.PAPERCLIP_API_URL;
|
|
delete process.env.PAPERCLIP_API_KEY;
|
|
delete process.env.PAPERCLIP_COMPANY_ID;
|
|
delete process.env.PAPERCLIP_AUTH_STORE;
|
|
delete process.env.PAPERCLIP_SERVER_PORT;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = { ...ORIGINAL_ENV };
|
|
});
|
|
|
|
it("uses profile defaults when options/env are not provided", () => {
|
|
const contextPath = createTempPath("context.json");
|
|
|
|
writeContext(
|
|
{
|
|
version: 2,
|
|
currentProfile: "ops",
|
|
profiles: {
|
|
ops: {
|
|
apiBase: "http://127.0.0.1:9999",
|
|
companyId: "company-profile",
|
|
apiKeyEnvVarName: "AGENT_KEY",
|
|
},
|
|
},
|
|
},
|
|
contextPath,
|
|
);
|
|
process.env.AGENT_KEY = "key-from-env";
|
|
|
|
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
|
|
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
|
|
expect(resolved.companyId).toBe("company-profile");
|
|
expect(resolved.api.apiKey).toBe("key-from-env");
|
|
});
|
|
|
|
it("prefers explicit options over profile values", () => {
|
|
const contextPath = createTempPath("context.json");
|
|
writeContext(
|
|
{
|
|
version: 2,
|
|
currentProfile: "default",
|
|
profiles: {
|
|
default: {
|
|
apiBase: "http://profile:3100",
|
|
companyId: "company-profile",
|
|
},
|
|
},
|
|
},
|
|
contextPath,
|
|
);
|
|
|
|
const resolved = resolveCommandContext(
|
|
{
|
|
context: contextPath,
|
|
apiBase: "http://override:3200",
|
|
apiKey: "direct-token",
|
|
companyId: "company-override",
|
|
},
|
|
{ requireCompany: true },
|
|
);
|
|
|
|
expect(resolved.api.apiBase).toBe("http://override:3200");
|
|
expect(resolved.companyId).toBe("company-override");
|
|
expect(resolved.api.apiKey).toBe("direct-token");
|
|
});
|
|
|
|
it("throws when company is required but unresolved", () => {
|
|
const contextPath = createTempPath("context.json");
|
|
writeContext(
|
|
{
|
|
version: 2,
|
|
currentProfile: "default",
|
|
profiles: { default: {} },
|
|
},
|
|
contextPath,
|
|
);
|
|
|
|
expect(() =>
|
|
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
|
|
).toThrow(/Company ID is required/);
|
|
});
|
|
|
|
it("resolves api base by explicit, env, profile, then config/default precedence", () => {
|
|
const configPath = createTempPath("config.json");
|
|
fs.writeFileSync(configPath, JSON.stringify({
|
|
$meta: { version: 1, updatedAt: "2026-05-23T00:00:00.000Z", source: "onboard" },
|
|
database: { mode: "embedded-postgres" },
|
|
logging: { mode: "file" },
|
|
server: { deploymentMode: "local_trusted", exposure: "private", host: "127.0.0.1", port: 4111 },
|
|
}));
|
|
|
|
expect(resolveApiBase({ apiBase: "http://explicit:1", config: configPath }, { apiBase: "http://profile:2" }))
|
|
.toBe("http://explicit:1");
|
|
|
|
process.env.PAPERCLIP_API_URL = "http://env:3/";
|
|
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2" })).toBe("http://env:3");
|
|
|
|
delete process.env.PAPERCLIP_API_URL;
|
|
expect(resolveApiBase({ config: configPath }, { apiBase: "http://profile:2/" })).toBe("http://profile:2");
|
|
expect(resolveApiBase({ config: configPath }, {})).toBe("http://localhost:4111");
|
|
});
|
|
|
|
it("prefers explicit and env tokens over profile env and stored board auth", () => {
|
|
const contextPath = createTempPath("context.json");
|
|
const authStorePath = createTempPath("auth.json");
|
|
process.env.PAPERCLIP_AUTH_STORE = authStorePath;
|
|
process.env.PROFILE_KEY = "profile-token";
|
|
setStoredBoardCredential({
|
|
apiBase: "http://localhost:3100",
|
|
token: "stored-board-token",
|
|
userId: "user-1",
|
|
storePath: authStorePath,
|
|
});
|
|
writeContext(
|
|
{
|
|
version: 2,
|
|
currentProfile: "default",
|
|
profiles: {
|
|
default: {
|
|
apiBase: "http://localhost:3100",
|
|
apiKeyEnvVarName: "PROFILE_KEY",
|
|
},
|
|
},
|
|
},
|
|
contextPath,
|
|
);
|
|
|
|
const profileResolved = resolveCommandContext({ context: contextPath });
|
|
expect(profileResolved.api.apiKey).toBe("profile-token");
|
|
expect(profileResolved.authSource).toBe("profile_env");
|
|
|
|
process.env.PAPERCLIP_API_KEY = "env-token";
|
|
const envResolved = resolveCommandContext({ context: contextPath });
|
|
expect(envResolved.api.apiKey).toBe("env-token");
|
|
expect(envResolved.authSource).toBe("env");
|
|
|
|
const explicitResolved = resolveCommandContext({ context: contextPath, apiKey: "explicit-token" });
|
|
expect(explicitResolved.api.apiKey).toBe("explicit-token");
|
|
expect(explicitResolved.authSource).toBe("explicit");
|
|
});
|
|
});
|
|
|
|
describe("inferContentTypeFromPath", () => {
|
|
it("maps the issue-attachment file types the server allows", () => {
|
|
// Must match server/src/attachment-types.ts DEFAULT_ALLOWED_TYPES exactly.
|
|
expect(inferContentTypeFromPath("newsletter.html")).toBe("text/html");
|
|
expect(inferContentTypeFromPath("page.htm")).toBe("text/html");
|
|
expect(inferContentTypeFromPath("data.csv")).toBe("text/csv");
|
|
expect(inferContentTypeFromPath("bundle.zip")).toBe("application/zip");
|
|
expect(inferContentTypeFromPath("demo.mp4")).toBe("video/mp4");
|
|
expect(inferContentTypeFromPath("clip.webm")).toBe("video/webm");
|
|
expect(inferContentTypeFromPath("teaser.m4v")).toBe("video/x-m4v");
|
|
expect(inferContentTypeFromPath("walkthrough.mov")).toBe("video/quicktime");
|
|
expect(inferContentTypeFromPath("report.pdf")).toBe("application/pdf");
|
|
expect(inferContentTypeFromPath("chart.png")).toBe("image/png");
|
|
});
|
|
|
|
it("emits text types with no charset parameter so they match the exact allowlist", () => {
|
|
expect(inferContentTypeFromPath("notes.md")).toBe("text/markdown");
|
|
expect(inferContentTypeFromPath("log.txt")).toBe("text/plain");
|
|
});
|
|
|
|
it("is case-insensitive and returns undefined for unknown extensions", () => {
|
|
expect(inferContentTypeFromPath("/abs/Path/IMAGE.PNG")).toBe("image/png");
|
|
expect(inferContentTypeFromPath("archive.unknownext")).toBeUndefined();
|
|
expect(inferContentTypeFromPath("noextension")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("apiPath", () => {
|
|
it("encodes dynamic path segments", () => {
|
|
expect(apiPath`/api/issues/${"PAP-1/child"}/comments/${"needs review?"}`)
|
|
.toBe("/api/issues/PAP-1%2Fchild/comments/needs%20review%3F");
|
|
});
|
|
|
|
it("rejects empty dynamic path segments", () => {
|
|
expect(() => apiPath`/api/issues/${""}`).toThrow("Cannot build API path with an empty path segment.");
|
|
expect(() => apiPath`/api/issues/${undefined}`).toThrow("Cannot build API path with an empty path segment.");
|
|
expect(() => apiPath`/api/issues/${null}`).toThrow("Cannot build API path with an empty path segment.");
|
|
expect(() => apiPath`/api/issues/${" "}`).toThrow("Cannot build API path with an empty path segment.");
|
|
});
|
|
});
|