Files
clawhub/convex/githubImport.test.ts
Patrick Erichsen 3097319ef6 fix: publish complete skill artifacts (#3196)
* fix: preserve complete skill artifacts

* test: align artifact metadata expectations

* fix: harden complete skill artifact handling

* fix: close complete artifact review gaps

* fix: preserve legacy skill file metadata hints

* fix: close artifact presentation review gaps

* fix(cli): preserve legacy skill file collector export

* refactor: centralize artifact upload helpers

* fix: preserve artifact scan and publish bounds

* fix: scan complete published text artifacts

* fix: harden artifact download presentation

* test: avoid secret-like fixture text

* refactor: preview artifacts by content

* chore(deps): patch transitive audit advisories
2026-07-20 15:54:47 -07:00

732 lines
23 KiB
TypeScript

/* @vitest-environment node */
import { generateKeyPairSync } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { internal } from "./_generated/api";
import { __test } from "./githubImport";
import { buildGitHubZipForTests } from "./lib/githubImport";
vi.mock("./_generated/api", () => ({
internal: {
githubIdentity: {
getGitHubProviderAccountIdInternal: Symbol("getGitHubProviderAccountIdInternal"),
},
skills: {
getSkillBySlugInternal: Symbol("getSkillBySlugInternal"),
},
},
}));
const originalGitHubToken = process.env.GITHUB_TOKEN;
const originalGitHubAppEnv = {
appId: process.env.GITHUB_APP_ID,
installationId: process.env.GITHUB_APP_INSTALLATION_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
};
describe("githubImport", () => {
beforeEach(() => {
delete process.env.GITHUB_TOKEN;
delete process.env.GITHUB_APP_ID;
delete process.env.GITHUB_APP_INSTALLATION_ID;
delete process.env.GITHUB_APP_PRIVATE_KEY;
});
afterEach(() => {
if (originalGitHubToken) {
process.env.GITHUB_TOKEN = originalGitHubToken;
} else {
delete process.env.GITHUB_TOKEN;
}
for (const [key, value] of [
["GITHUB_APP_ID", originalGitHubAppEnv.appId],
["GITHUB_APP_INSTALLATION_ID", originalGitHubAppEnv.installationId],
["GITHUB_APP_PRIVATE_KEY", originalGitHubAppEnv.privateKey],
] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it("formats storage failure message with file context", () => {
const message = __test.buildStoreFailureMessage("skill/SKILL.md", 123, new Error("disk full"));
expect(message).toBe('Failed to store file "skill/SKILL.md" (123 bytes). disk full');
});
it("formats publish failure message with fallback text", () => {
expect(__test.buildPublishFailureMessage(new Error("slug exists"))).toBe(
"Import failed during publish: slug exists. Check skill format, slug availability, and try again.",
);
expect(
__test.buildPublishFailureMessage(
new Error(
'Uncaught ConvexError: Publisher handle "@local-owner" is already claimed at ensurePersonalPublisherForUser (../../convex/lib/publishers.ts:235:4)',
),
),
).toBe(
'Import failed during publish: Publisher handle "@local-owner" is already claimed. Check skill format, slug availability, and try again.',
);
expect(__test.buildPublishFailureMessage("unexpected")).toBe(
"Import failed during publish: unexpected. Check skill format, slug availability, and try again.",
);
});
it("filters mac junk files while unzipping archive entries", () => {
const zip = buildGitHubZipForTests({
"demo-repo/skill/SKILL.md": "# Demo",
"demo-repo/skill/notes.md": "notes",
"demo-repo/skill/.DS_Store": "junk",
"demo-repo/skill/._notes.md": "junk",
"demo-repo/__MACOSX/._SKILL.md": "junk",
});
const entries = __test.unzipToEntries(zip);
expect(Object.keys(entries).sort()).toEqual([
"demo-repo/skill/SKILL.md",
"demo-repo/skill/notes.md",
]);
});
it("rejects a public repo owned by another GitHub account before repo lookup", async () => {
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
});
await expect(
__test.requireOwnedPublicGitHubRepoForImport(
ctx as never,
"users:1" as never,
"someone-else",
"public-skill",
fetchMock as never,
),
).rejects.toThrow(/owned by your GitHub account/i);
expect(ctx.runQuery).toHaveBeenCalledWith(
internal.githubIdentity.getGitHubProviderAccountIdInternal,
{ userId: "users:1" },
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/user/123",
expect.objectContaining({
headers: expect.objectContaining({ "User-Agent": "clawhub/github-import" }),
}),
);
});
it("rejects a public repo when GitHub metadata owner id does not match the signed-in user", async () => {
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
name: "public-skill",
full_name: "vyctorbrzezowski/public-skill",
private: false,
visibility: "public",
owner: { id: 456, login: "vyctorbrzezowski" },
}),
});
await expect(
__test.requireOwnedPublicGitHubRepoForImport(
ctx as never,
"users:1" as never,
"vyctorbrzezowski",
"public-skill",
fetchMock as never,
),
).rejects.toThrow(/owned by your GitHub account/i);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/repos/vyctorbrzezowski/public-skill",
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it("rejects direct URL preview from another public GitHub owner before repo lookup", async () => {
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
});
await expect(
__test.previewGitHubImportForUser(
ctx as never,
"users:1" as never,
{ url: "https://github.com/someone-else/public-skill" },
fetchMock as never,
),
).rejects.toThrow(/owned by your GitHub account/i);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/user/123",
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it("rejects direct URL candidate preview from another public GitHub owner before repo lookup", async () => {
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
});
await expect(
__test.previewGitHubImportCandidateForUser(
ctx as never,
"users:1" as never,
{
url: "https://github.com/someone-else/public-skill",
candidatePath: "",
},
fetchMock as never,
),
).rejects.toThrow(/owned by your GitHub account/i);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/user/123",
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it("rejects direct URL publish from another public GitHub owner before repo lookup", async () => {
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
});
await expect(
__test.importGitHubSkillForUser(
ctx as never,
"users:1" as never,
{
url: "https://github.com/someone-else/public-skill",
commit: "a".repeat(40),
candidatePath: "",
selectedPaths: ["SKILL.md"],
slug: "public-skill",
displayName: "Public Skill",
version: "1.0.0",
tags: ["latest"],
acceptLicenseTerms: true,
},
fetchMock as never,
),
).rejects.toThrow(/owned by your GitHub account/i);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/user/123",
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it("lists only owned public skill file candidates", async () => {
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => [
{
name: "clawhub",
full_name: "vyctorbrzezowski/clawhub",
html_url: "https://github.com/vyctorbrzezowski/clawhub",
default_branch: "main",
pushed_at: "2026-05-27T00:00:00Z",
updated_at: "2026-05-27T00:00:00Z",
language: "TypeScript",
fork: false,
archived: false,
disabled: false,
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
},
{
name: "docs",
full_name: "vyctorbrzezowski/docs",
html_url: "https://github.com/vyctorbrzezowski/docs",
default_branch: "main",
pushed_at: "2026-05-26T00:00:00Z",
updated_at: "2026-05-26T00:00:00Z",
fork: false,
archived: false,
disabled: false,
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
},
{
name: "forked-skill",
full_name: "vyctorbrzezowski/forked-skill",
default_branch: "main",
fork: true,
archived: false,
disabled: false,
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
},
{
name: "archived-skill",
full_name: "vyctorbrzezowski/archived-skill",
default_branch: "main",
fork: false,
archived: true,
disabled: false,
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
},
{
name: "private-skill",
private: true,
visibility: "private",
owner: { id: 123, login: "vyctorbrzezowski" },
},
{
name: "org-skill",
private: false,
visibility: "public",
owner: { id: 456, login: "openclaw" },
},
],
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
truncated: false,
tree: [
{ path: "SKILL.md", type: "blob" },
{ path: "skills/copilot/SKILL.md", type: "blob" },
{ path: "legacy/skills.md", type: "blob" },
{ path: ".agents/skills/internal/SKILL.md", type: "blob" },
{ path: "README.md", type: "blob" },
{ path: "skill.md", type: "tree" },
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
truncated: false,
tree: [
{ path: "README.md", type: "blob" },
{ path: "guides/usage.md", type: "blob" },
],
}),
});
const result = await __test.listOwnedPublicGitHubReposForUser(
ctx as never,
"users:1" as never,
{ page: 1, perPage: 30 },
fetchMock as never,
);
expect(result.account.login).toBe("vyctorbrzezowski");
expect(result.account.avatarUrl).toBe("https://avatars.githubusercontent.com/u/123?v=4");
expect(result.repos).toEqual([
expect.objectContaining({
owner: "vyctorbrzezowski",
name: "clawhub",
repoName: "clawhub",
repoFullName: "vyctorbrzezowski/clawhub",
fullName: "vyctorbrzezowski/clawhub",
htmlUrl: "https://github.com/vyctorbrzezowski/clawhub",
candidatePath: "",
skillPath: "SKILL.md",
importable: true,
}),
expect.objectContaining({
owner: "vyctorbrzezowski",
name: "copilot",
repoName: "clawhub",
repoFullName: "vyctorbrzezowski/clawhub",
fullName: "vyctorbrzezowski/clawhub/skills/copilot",
htmlUrl: "https://github.com/vyctorbrzezowski/clawhub/tree/main/skills/copilot",
candidatePath: "skills/copilot",
skillPath: "skills/copilot/SKILL.md",
importable: true,
}),
expect.objectContaining({
owner: "vyctorbrzezowski",
name: "legacy",
repoName: "clawhub",
repoFullName: "vyctorbrzezowski/clawhub",
fullName: "vyctorbrzezowski/clawhub/legacy",
htmlUrl: "https://github.com/vyctorbrzezowski/clawhub/tree/main/legacy",
candidatePath: "legacy",
skillPath: "legacy/skills.md",
importable: true,
}),
]);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
"https://api.github.com/users/vyctorbrzezowski/repos?type=owner&sort=pushed&direction=desc&per_page=30&page=1",
expect.objectContaining({ headers: expect.any(Object) }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
"https://api.github.com/repos/vyctorbrzezowski/clawhub/git/trees/main?recursive=1",
expect.objectContaining({ headers: expect.any(Object) }),
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
"https://api.github.com/repos/vyctorbrzezowski/docs/git/trees/main?recursive=1",
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it("uses GitHub code search for owned skill file discovery when a token is configured", async () => {
process.env.GITHUB_TOKEN = "github-token";
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const ownedRepo = {
name: "skills",
full_name: "vyctorbrzezowski/skills",
html_url: "https://github.com/vyctorbrzezowski/skills",
default_branch: "main",
pushed_at: "2026-05-27T00:00:00Z",
updated_at: "2026-05-27T00:00:00Z",
fork: false,
archived: false,
disabled: false,
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [
{ path: "SKILL.md", repository: ownedRepo },
{ path: "tools/review/SKILL.md", repository: ownedRepo },
{ path: ".agents/skills/internal/SKILL.md", repository: ownedRepo },
{
path: "SKILL.md",
repository: {
...ownedRepo,
name: "forked",
full_name: "vyctorbrzezowski/forked",
fork: true,
},
},
{ path: "README.md", repository: ownedRepo },
],
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [{ path: "legacy/skills.md", repository: ownedRepo }],
}),
});
const result = await __test.listOwnedPublicGitHubReposForUser(
ctx as never,
"users:1" as never,
{ page: 1, perPage: 30 },
fetchMock as never,
);
expect(result.repos).toEqual([
expect.objectContaining({
name: "skills",
repoName: "skills",
candidatePath: "",
skillPath: "SKILL.md",
}),
expect.objectContaining({
name: "review",
repoName: "skills",
candidatePath: "tools/review",
skillPath: "tools/review/SKILL.md",
}),
expect.objectContaining({
name: "legacy",
repoName: "skills",
candidatePath: "legacy",
skillPath: "legacy/skills.md",
}),
]);
expect(fetchMock).toHaveBeenCalledTimes(3);
const searchUrl = new URL(fetchMock.mock.calls[1]?.[0] as string);
expect(searchUrl.pathname).toBe("/search/code");
expect(searchUrl.searchParams.get("q")).toBe("filename:SKILL.md user:vyctorbrzezowski");
const legacySearchUrl = new URL(fetchMock.mock.calls[2]?.[0] as string);
expect(legacySearchUrl.pathname).toBe("/search/code");
expect(legacySearchUrl.searchParams.get("q")).toBe("filename:skills.md user:vyctorbrzezowski");
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer github-token" }),
}),
);
});
it("falls back from an out-of-scope GitHub App token for public repo lookup", async () => {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
process.env.GITHUB_APP_ID = "3536245";
process.env.GITHUB_APP_INSTALLATION_ID = "987654";
process.env.GITHUB_APP_PRIVATE_KEY = privateKey;
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
Response.json({
token: "ghs_app_token",
expires_at: "2027-02-02T13:00:00Z",
}),
)
.mockResolvedValueOnce(
Response.json({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
)
.mockResolvedValueOnce(new Response(null, { status: 404 }))
.mockResolvedValueOnce(
Response.json({
name: "public-skill",
full_name: "vyctorbrzezowski/public-skill",
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
}),
);
await expect(
__test.requireOwnedPublicGitHubRepoForImport(
ctx as never,
"users:1" as never,
"vyctorbrzezowski",
"public-skill",
fetchMock as never,
),
).resolves.toEqual(
expect.objectContaining({
full_name: "vyctorbrzezowski/public-skill",
}),
);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(fetchMock.mock.calls[2]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer ghs_app_token" }),
}),
);
expect(fetchMock.mock.calls[3]?.[1]?.headers).not.toHaveProperty("Authorization");
});
it("uses the GitHub App token for bounded repo discovery", async () => {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
process.env.GITHUB_APP_ID = "3536245";
process.env.GITHUB_APP_INSTALLATION_ID = "987654";
process.env.GITHUB_APP_PRIVATE_KEY = privateKey;
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
Response.json({
token: "ghs_app_token",
expires_at: "2027-02-02T13:00:00Z",
}),
)
.mockResolvedValueOnce(
Response.json({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
)
.mockResolvedValueOnce(
Response.json([
{
name: "public-skill",
full_name: "vyctorbrzezowski/public-skill",
html_url: "https://github.com/vyctorbrzezowski/public-skill",
default_branch: "main",
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
},
]),
)
.mockResolvedValueOnce(
Response.json({
truncated: false,
tree: [{ path: "SKILL.md", type: "blob" }],
}),
);
const result = await __test.listOwnedPublicGitHubReposForUser(
ctx as never,
"users:1" as never,
{ page: 1, perPage: 30 },
fetchMock as never,
);
expect(result.repos).toEqual([
expect.objectContaining({
repoFullName: "vyctorbrzezowski/public-skill",
skillPath: "SKILL.md",
}),
]);
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(fetchMock.mock.calls[2]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({ Authorization: "Bearer ghs_app_token" }),
}),
);
});
it("falls back to the repo archive when GitHub truncates the discovery tree", async () => {
const ctx = {
runQuery: vi.fn().mockResolvedValue("123"),
};
const zip = buildGitHubZipForTests({
"large-repo/tools/review/SKILL.md": "# Review",
"large-repo/tools/review/notes.md": "notes",
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 123,
login: "vyctorbrzezowski",
avatar_url: "https://avatars.githubusercontent.com/u/123?v=4",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => [
{
name: "large-repo",
full_name: "vyctorbrzezowski/large-repo",
html_url: "https://github.com/vyctorbrzezowski/large-repo",
default_branch: "main",
fork: false,
archived: false,
disabled: false,
private: false,
visibility: "public",
owner: { id: 123, login: "vyctorbrzezowski" },
},
],
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
truncated: true,
tree: [{ path: "README.md", type: "blob" }],
}),
})
.mockResolvedValueOnce({
ok: true,
headers: { get: () => null },
arrayBuffer: async () => zip.buffer.slice(zip.byteOffset, zip.byteOffset + zip.byteLength),
});
const result = await __test.listOwnedPublicGitHubReposForUser(
ctx as never,
"users:1" as never,
{ page: 1, perPage: 30 },
fetchMock as never,
);
expect(result.repos).toEqual([
expect.objectContaining({
name: "review",
repoName: "large-repo",
candidatePath: "tools/review",
skillPath: "tools/review/SKILL.md",
}),
]);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
"https://codeload.github.com/vyctorbrzezowski/large-repo/zip/main",
expect.objectContaining({ headers: expect.any(Object) }),
);
});
});