mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
* 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
525 lines
16 KiB
TypeScript
525 lines
16 KiB
TypeScript
/* @vitest-environment node */
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@convex-dev/auth/server", () => ({
|
|
getAuthUserId: vi.fn(),
|
|
authTables: {},
|
|
}));
|
|
|
|
vi.mock("./lib/skillPublish", () => ({
|
|
fetchText: vi.fn().mockResolvedValue("# skill"),
|
|
}));
|
|
|
|
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
|
const {
|
|
getReadme: getSkillReadme,
|
|
getFilePreview: getSkillFilePreview,
|
|
getFileText: getSkillFileText,
|
|
getGitHubSkillContent,
|
|
} = await import("./skills");
|
|
const getSkillReadmeHandler = getSkillReadme as unknown as { _handler: Function };
|
|
const getSkillFilePreviewHandler = getSkillFilePreview as unknown as { _handler: Function };
|
|
const getSkillFileTextHandler = getSkillFileText as unknown as { _handler: Function };
|
|
const getGitHubSkillContentHandler = getGitHubSkillContent as unknown as { _handler: Function };
|
|
|
|
function makeSkillVersion() {
|
|
return {
|
|
_id: "skillVersions:1",
|
|
_creationTime: 1,
|
|
skillId: "skills:1",
|
|
version: "1.0.0",
|
|
changelog: "init",
|
|
files: [
|
|
{
|
|
path: "SKILL.md",
|
|
size: 10,
|
|
storageId: "_storage:1",
|
|
sha256: "abc",
|
|
contentType: "text/markdown",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function makeActionCtx(args: {
|
|
skill?: Record<string, unknown> | null;
|
|
version?: Record<string, unknown> | null;
|
|
actor?: Record<string, unknown> | null;
|
|
publisherMemberRole?: "owner" | "admin" | "publisher" | null;
|
|
publisherAccess?: boolean;
|
|
storedBlob?: Blob | null;
|
|
}) {
|
|
return {
|
|
runQuery: vi.fn(async (_endpoint: unknown, payload: Record<string, unknown>) => {
|
|
if (payload.versionId && args.version) return args.version ?? null;
|
|
if (payload.skillId && args.skill) return args.skill ?? null;
|
|
if (payload.publisherId && payload.userId === args.actor?._id) {
|
|
if (Array.isArray(payload.allowedPublisherRoles)) {
|
|
if (args.publisherAccess !== undefined) return args.publisherAccess;
|
|
if (payload.legacyOwnerUserId) return payload.legacyOwnerUserId === args.actor?._id;
|
|
return Boolean(args.publisherMemberRole);
|
|
}
|
|
return args.publisherMemberRole ?? null;
|
|
}
|
|
if (payload.userId === args.actor?._id) {
|
|
return args.actor ?? null;
|
|
}
|
|
throw new Error("Unexpected endpoint");
|
|
}),
|
|
storage: {
|
|
get: vi.fn().mockResolvedValue(args.storedBlob ?? new Blob(["# skill"])),
|
|
},
|
|
} as never;
|
|
}
|
|
|
|
describe("version file access actions", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(getAuthUserId).mockReset();
|
|
vi.mocked(getAuthUserId).mockResolvedValue(null as never);
|
|
});
|
|
|
|
it("blocks unauthenticated access to hidden skill versions", async () => {
|
|
const ctx = makeActionCtx({
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).rejects.toThrow("Version not available");
|
|
});
|
|
|
|
it("blocks unauthenticated access to pending-publication versions on public skills", async () => {
|
|
const ctx = makeActionCtx({
|
|
version: { ...makeSkillVersion(), publicationStatus: "pending" },
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
moderationFlags: [],
|
|
stats: {
|
|
downloads: 0,
|
|
stars: 0,
|
|
installsAllTime: 0,
|
|
versions: 1,
|
|
comments: 0,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).rejects.toThrow("Version not available");
|
|
});
|
|
|
|
it("allows owners to read pending-publication versions", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:owner", role: "user" },
|
|
version: { ...makeSkillVersion(), publicationStatus: "pending" },
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
moderationFlags: [],
|
|
stats: {
|
|
downloads: 0,
|
|
stars: 0,
|
|
installsAllTime: 0,
|
|
versions: 1,
|
|
comments: 0,
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
|
});
|
|
|
|
it("allows owners to read hidden skill versions", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:owner", role: "user" },
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
|
});
|
|
|
|
it("does not let stale ownerUserId read publisher-owned hidden skill versions", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:owner", role: "user" },
|
|
publisherMemberRole: null,
|
|
publisherAccess: false,
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
ownerPublisherId: "publishers:org",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).rejects.toThrow("Version not available");
|
|
});
|
|
|
|
it("allows org collaborators to read hidden skill versions", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:member" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:member", role: "user" },
|
|
publisherMemberRole: "publisher",
|
|
publisherAccess: true,
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
ownerPublisherId: "publishers:org",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
|
});
|
|
|
|
it("allows linked personal publisher users to read hidden skill versions", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:owner", role: "user" },
|
|
publisherMemberRole: null,
|
|
publisherAccess: true,
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:legacy-owner",
|
|
ownerPublisherId: "publishers:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
|
});
|
|
|
|
it("allows legacy no-link personal publisher owners to read hidden skill versions", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:owner", role: "user" },
|
|
publisherMemberRole: null,
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
ownerPublisherId: "publishers:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).resolves.toEqual({ path: "SKILL.md", text: "# skill" });
|
|
});
|
|
|
|
it("does not honor stale personal publisher memberships for hidden skill versions", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:friend" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:friend", role: "user" },
|
|
publisherMemberRole: "owner",
|
|
publisherAccess: false,
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
ownerPublisherId: "publishers:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillReadmeHandler._handler(ctx, { versionId: "skillVersions:1" } as never),
|
|
).rejects.toThrow("Version not available");
|
|
});
|
|
|
|
it("allows owners to read hidden skill files", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = makeActionCtx({
|
|
actor: { _id: "users:owner", role: "user" },
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillFileTextHandler._handler(ctx, {
|
|
versionId: "skillVersions:1",
|
|
path: "SKILL.md",
|
|
} as never),
|
|
).resolves.toMatchObject({ path: "SKILL.md", text: "# skill" });
|
|
});
|
|
|
|
it("returns opaque files as download-only previews with exact metadata", async () => {
|
|
const version = {
|
|
...makeSkillVersion(),
|
|
files: [
|
|
{
|
|
path: "assets/payload.bin",
|
|
size: 4,
|
|
storageId: "_storage:opaque",
|
|
sha256: "d".repeat(64),
|
|
contentType: "application/octet-stream",
|
|
},
|
|
],
|
|
};
|
|
const ctx = makeActionCtx({
|
|
version,
|
|
storedBlob: new Blob([Uint8Array.from([0, 1, 2, 255])], {
|
|
type: "application/octet-stream",
|
|
}),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
stats: {},
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillFilePreviewHandler._handler(ctx, {
|
|
versionId: "skillVersions:1",
|
|
path: "assets/payload.bin",
|
|
} as never),
|
|
).resolves.toEqual({
|
|
path: "assets/payload.bin",
|
|
text: null,
|
|
size: 4,
|
|
sha256: "d".repeat(64),
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["report.pdf", "application/pdf"],
|
|
["page.html", "text/html"],
|
|
["diagram.svg", "image/svg+xml"],
|
|
["config.xml", "application/xml"],
|
|
])(
|
|
"previews valid UTF-8 document %s as escaped text regardless of extension",
|
|
async (path, contentType) => {
|
|
const text = "<root>valid UTF-8</root>";
|
|
const version = {
|
|
...makeSkillVersion(),
|
|
files: [
|
|
{
|
|
path,
|
|
size: text.length,
|
|
storageId: "_storage:rich",
|
|
sha256: "e".repeat(64),
|
|
contentType,
|
|
},
|
|
],
|
|
};
|
|
const ctx = makeActionCtx({
|
|
version,
|
|
storedBlob: new Blob([text], { type: contentType }),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
stats: {},
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillFilePreviewHandler._handler(ctx, {
|
|
versionId: "skillVersions:1",
|
|
path,
|
|
} as never),
|
|
).resolves.toEqual({
|
|
path,
|
|
text,
|
|
size: text.length,
|
|
sha256: "e".repeat(64),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("blocks unauthenticated file reads from hidden skill versions", async () => {
|
|
const ctx = makeActionCtx({
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
moderationFlags: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillFileTextHandler._handler(ctx, {
|
|
versionId: "skillVersions:1",
|
|
path: "SKILL.md",
|
|
} as never),
|
|
).rejects.toThrow("Version not available");
|
|
});
|
|
|
|
it("blocks public reads from malware-blocked skill files", async () => {
|
|
const ctx = makeActionCtx({
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "scanner.vt.malicious",
|
|
moderationFlags: ["blocked.malware"],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillFileTextHandler._handler(ctx, {
|
|
versionId: "skillVersions:1",
|
|
path: "SKILL.md",
|
|
} as never),
|
|
).rejects.toThrow("Version not available");
|
|
});
|
|
|
|
it("returns null instead of throwing for public reads from malware-blocked GitHub skill content", async () => {
|
|
const skill = {
|
|
_id: "skills:github",
|
|
_creationTime: 1,
|
|
slug: "github-demo",
|
|
displayName: "GitHub Demo",
|
|
summary: "Summary",
|
|
ownerUserId: "users:owner",
|
|
canonicalSkillId: undefined,
|
|
forkOf: undefined,
|
|
latestVersionId: undefined,
|
|
installKind: "github",
|
|
githubCurrentStatus: "present",
|
|
githubCurrentContentHash: "hash-a",
|
|
tags: {},
|
|
badges: undefined,
|
|
stats: {
|
|
downloads: 1,
|
|
installsCurrent: 1,
|
|
installsAllTime: 1,
|
|
stars: 1,
|
|
versions: 0,
|
|
comments: 0,
|
|
},
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
moderationFlags: ["blocked.malware"],
|
|
moderationReason: "scanner.vt.malicious",
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === "skills:github" ? skill : null)),
|
|
query: vi.fn(() => {
|
|
throw new Error("Content should not be read when the skill is not publicly readable");
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
getGitHubSkillContentHandler._handler(ctx, {
|
|
skillId: "skills:github",
|
|
kind: "readme",
|
|
} as never),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("still allows public access to visible skill files", async () => {
|
|
const ctx = makeActionCtx({
|
|
version: makeSkillVersion(),
|
|
skill: {
|
|
_id: "skills:1",
|
|
_creationTime: 1,
|
|
slug: "demo",
|
|
displayName: "Demo",
|
|
summary: "Summary",
|
|
ownerUserId: "users:owner",
|
|
canonicalSkillId: undefined,
|
|
forkOf: undefined,
|
|
latestVersionId: "skillVersions:1",
|
|
tags: {},
|
|
badges: undefined,
|
|
stats: {
|
|
downloads: 1,
|
|
installsCurrent: 1,
|
|
installsAllTime: 1,
|
|
stars: 1,
|
|
versions: 1,
|
|
comments: 0,
|
|
},
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
moderationFlags: [],
|
|
moderationReason: undefined,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
getSkillFileTextHandler._handler(ctx, {
|
|
versionId: "skillVersions:1",
|
|
path: "SKILL.md",
|
|
} as never),
|
|
).resolves.toMatchObject({ path: "SKILL.md", text: "# skill" });
|
|
});
|
|
});
|