mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@convex-dev/auth/server", () => ({
|
|
getAuthUserId: vi.fn(),
|
|
authTables: {},
|
|
}));
|
|
|
|
import { MODERATION_ENGINE_VERSION } from "./lib/moderationReasonCodes";
|
|
import {
|
|
getActiveSkillBatchForStaticScanBackfillInternal,
|
|
getPendingScanSkillsInternal,
|
|
getPendingVTSkillsInternal,
|
|
} from "./skills";
|
|
|
|
type PendingScanResult = Array<{
|
|
skillId: string;
|
|
versionId: string | null;
|
|
sha256hash: string | null;
|
|
checkCount: number;
|
|
}>;
|
|
|
|
type PendingVtRepairResult = {
|
|
skills: Array<{
|
|
skillId: string;
|
|
versionId: string;
|
|
sha256hash: string;
|
|
slug: string;
|
|
isLatest: boolean;
|
|
}>;
|
|
cursor: string | null;
|
|
done: boolean;
|
|
};
|
|
|
|
type WrappedHandler<TArgs, TResult> = {
|
|
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
|
};
|
|
|
|
const getPendingScanSkillsHandler = (
|
|
getPendingScanSkillsInternal as unknown as WrappedHandler<
|
|
Record<string, unknown>,
|
|
PendingScanResult
|
|
>
|
|
)._handler;
|
|
|
|
const getStaticScanBackfillBatchHandler = (
|
|
getActiveSkillBatchForStaticScanBackfillInternal as unknown as WrappedHandler<
|
|
Record<string, unknown>,
|
|
{
|
|
skills: Array<{ skillId: string; versionId: string; slug: string }>;
|
|
nextCursor: number;
|
|
done: boolean;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const getPendingVTSkillsHandler = (
|
|
getPendingVTSkillsInternal as unknown as WrappedHandler<
|
|
{ limit?: number; cursor?: string | null },
|
|
PendingVtRepairResult
|
|
>
|
|
)._handler;
|
|
|
|
describe("skills.getPendingScanSkillsInternal", () => {
|
|
it("includes unresolved VT records from the oldest slice and skips finalized ones", async () => {
|
|
const recentSkills = [
|
|
makeSkill("skills:recent-clean", "skillVersions:recent-clean", "scanner.llm.clean"),
|
|
makeSkill("skills:recent-malicious", "skillVersions:recent-malicious", "scanner.vt.pending"),
|
|
];
|
|
const oldestSkills = [
|
|
makeSkill("skills:old-pending", "skillVersions:old-pending", "scanner.vt.pending"),
|
|
makeSkill("skills:old-stale", "skillVersions:old-stale", "scanner.llm.clean"),
|
|
makeSkill("skills:old-no-hash", "skillVersions:old-no-hash", "scanner.vt.pending"),
|
|
];
|
|
|
|
const versions = new Map<string, unknown>([
|
|
[
|
|
"skillVersions:recent-clean",
|
|
{
|
|
_id: "skillVersions:recent-clean",
|
|
sha256hash: "a".repeat(64),
|
|
vtAnalysis: { status: "clean" },
|
|
},
|
|
],
|
|
[
|
|
"skillVersions:recent-malicious",
|
|
{
|
|
_id: "skillVersions:recent-malicious",
|
|
sha256hash: "b".repeat(64),
|
|
vtAnalysis: { status: "malicious" },
|
|
},
|
|
],
|
|
[
|
|
"skillVersions:old-pending",
|
|
{
|
|
_id: "skillVersions:old-pending",
|
|
sha256hash: "c".repeat(64),
|
|
vtAnalysis: { status: "pending" },
|
|
},
|
|
],
|
|
[
|
|
"skillVersions:old-stale",
|
|
{
|
|
_id: "skillVersions:old-stale",
|
|
sha256hash: "d".repeat(64),
|
|
vtAnalysis: { status: "stale" },
|
|
},
|
|
],
|
|
["skillVersions:old-no-hash", { _id: "skillVersions:old-no-hash" }],
|
|
]);
|
|
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: (
|
|
indexName: string,
|
|
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
builder({ eq: () => ({}) });
|
|
if (indexName === "by_active_updated") {
|
|
return {
|
|
order: () => ({
|
|
take: async () => recentSkills,
|
|
}),
|
|
};
|
|
}
|
|
if (indexName === "by_active_created") {
|
|
return {
|
|
order: () => ({
|
|
take: async () => oldestSkills,
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
},
|
|
};
|
|
}),
|
|
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
|
},
|
|
};
|
|
|
|
const result = await getPendingScanSkillsHandler(ctx, {
|
|
limit: 25,
|
|
skipRecentMinutes: 0,
|
|
});
|
|
|
|
const ids = new Set(result.map((entry) => entry.skillId));
|
|
expect(ids.has("skills:old-pending")).toBe(true);
|
|
expect(ids.has("skills:old-stale")).toBe(true);
|
|
expect(ids.has("skills:recent-clean")).toBe(false);
|
|
expect(ids.has("skills:recent-malicious")).toBe(false);
|
|
expect(ids.has("skills:old-no-hash")).toBe(false);
|
|
});
|
|
|
|
it("exhaustive mode ignores recent-check suppression for manual backfills", async () => {
|
|
const now = Date.now();
|
|
const allSkills = [
|
|
makeSkill(
|
|
"skills:recently-checked",
|
|
"skillVersions:recently-checked",
|
|
"scanner.vt.pending",
|
|
now,
|
|
),
|
|
];
|
|
const versions = new Map<string, unknown>([
|
|
[
|
|
"skillVersions:recently-checked",
|
|
{ _id: "skillVersions:recently-checked", sha256hash: "e".repeat(64) },
|
|
],
|
|
]);
|
|
|
|
const withIndex = vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
builder({ eq: () => ({}) });
|
|
if (indexName !== "by_active_updated") throw new Error(`unexpected index ${indexName}`);
|
|
return {
|
|
collect: async () => allSkills,
|
|
};
|
|
},
|
|
);
|
|
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
|
return { withIndex };
|
|
}),
|
|
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
|
},
|
|
};
|
|
|
|
const result = await getPendingScanSkillsHandler(ctx, {
|
|
limit: 25,
|
|
skipRecentMinutes: 60,
|
|
exhaustive: true,
|
|
});
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]?.skillId).toBe("skills:recently-checked");
|
|
});
|
|
|
|
it("does not clamp exhaustive mode to 100 records", async () => {
|
|
const allSkills = Array.from({ length: 150 }, (_, i) =>
|
|
makeSkill(`skills:bulk-${i}`, `skillVersions:bulk-${i}`, "scanner.vt.pending"),
|
|
);
|
|
const versions = new Map<string, unknown>(
|
|
allSkills.map((skill) => {
|
|
const versionId = skill.latestVersionId as string;
|
|
return [
|
|
versionId,
|
|
{ _id: versionId, sha256hash: `${versionId.slice(-8)}${"f".repeat(56)}` },
|
|
];
|
|
}),
|
|
);
|
|
|
|
const withIndex = vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
builder({ eq: () => ({}) });
|
|
if (indexName !== "by_active_updated") throw new Error(`unexpected index ${indexName}`);
|
|
return {
|
|
collect: async () => allSkills,
|
|
};
|
|
},
|
|
);
|
|
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
|
return { withIndex };
|
|
}),
|
|
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
|
},
|
|
};
|
|
|
|
const result = await getPendingScanSkillsHandler(ctx, {
|
|
limit: 10000,
|
|
exhaustive: true,
|
|
skipRecentMinutes: 0,
|
|
});
|
|
|
|
expect(result).toHaveLength(150);
|
|
});
|
|
});
|
|
|
|
describe("skills.getPendingVTSkillsInternal", () => {
|
|
it("selects pending VT cache rows from skill versions, not the skill moderation queue", async () => {
|
|
const page = [
|
|
{
|
|
_id: "skillVersions:historical",
|
|
skillId: "skills:demo",
|
|
sha256hash: "a".repeat(64),
|
|
vtAnalysis: { status: "pending" },
|
|
},
|
|
{
|
|
_id: "skillVersions:no-hash",
|
|
skillId: "skills:no-hash",
|
|
vtAnalysis: { status: "pending" },
|
|
},
|
|
{
|
|
_id: "skillVersions:deleted-skill",
|
|
skillId: "skills:deleted",
|
|
sha256hash: "b".repeat(64),
|
|
vtAnalysis: { status: "pending" },
|
|
},
|
|
];
|
|
const skills = new Map<string, unknown>([
|
|
[
|
|
"skills:demo",
|
|
{
|
|
_id: "skills:demo",
|
|
slug: "demo",
|
|
latestVersionId: "skillVersions:latest",
|
|
},
|
|
],
|
|
[
|
|
"skills:no-hash",
|
|
{
|
|
_id: "skills:no-hash",
|
|
slug: "no-hash",
|
|
latestVersionId: "skillVersions:no-hash",
|
|
},
|
|
],
|
|
[
|
|
"skills:deleted",
|
|
{
|
|
_id: "skills:deleted",
|
|
slug: "deleted",
|
|
latestVersionId: "skillVersions:deleted-skill",
|
|
softDeletedAt: 123,
|
|
},
|
|
],
|
|
]);
|
|
const eqCalls: Array<[string, unknown]> = [];
|
|
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "skillVersions") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: (
|
|
indexName: string,
|
|
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
if (indexName !== "by_active_vt_status_created") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
type EqBuilder = { eq: (field: string, value: unknown) => EqBuilder };
|
|
const q: EqBuilder = {
|
|
eq: (field, value) => {
|
|
eqCalls.push([field, value]);
|
|
return q;
|
|
},
|
|
};
|
|
builder(q);
|
|
return {
|
|
paginate: async (paginationOpts: { cursor: string | null; numItems: number }) => {
|
|
expect(paginationOpts).toEqual({ cursor: "cursor-1", numItems: 25 });
|
|
return {
|
|
page,
|
|
continueCursor: "cursor-2",
|
|
isDone: false,
|
|
};
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}),
|
|
get: vi.fn(async (id: string) => skills.get(id) ?? null),
|
|
},
|
|
};
|
|
|
|
const result = await getPendingVTSkillsHandler(ctx, {
|
|
limit: 25,
|
|
cursor: "cursor-1",
|
|
});
|
|
|
|
expect(eqCalls).toEqual([
|
|
["softDeletedAt", undefined],
|
|
["vtAnalysis.status", "pending"],
|
|
]);
|
|
expect(result).toEqual({
|
|
skills: [
|
|
{
|
|
skillId: "skills:demo",
|
|
versionId: "skillVersions:historical",
|
|
slug: "demo",
|
|
sha256hash: "a".repeat(64),
|
|
isLatest: false,
|
|
},
|
|
],
|
|
cursor: "cursor-2",
|
|
done: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("skills.getActiveSkillBatchForStaticScanBackfillInternal", () => {
|
|
it("includes latest active skills with missing or stale static scan engine versions", async () => {
|
|
const skills = [
|
|
{
|
|
_id: "skills:missing-static",
|
|
_creationTime: 10,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
latestVersionId: "skillVersions:missing-static",
|
|
slug: "missing-static",
|
|
},
|
|
{
|
|
_id: "skills:stale-static",
|
|
_creationTime: 20,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
latestVersionId: "skillVersions:stale-static",
|
|
slug: "stale-static",
|
|
},
|
|
{
|
|
_id: "skills:current-static",
|
|
_creationTime: 30,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
latestVersionId: "skillVersions:current-static",
|
|
slug: "current-static",
|
|
},
|
|
{
|
|
_id: "skills:hidden-static",
|
|
_creationTime: 40,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
latestVersionId: "skillVersions:hidden-static",
|
|
slug: "hidden-static",
|
|
},
|
|
];
|
|
|
|
const versions = new Map<string, unknown>([
|
|
["skillVersions:missing-static", { _id: "skillVersions:missing-static" }],
|
|
[
|
|
"skillVersions:stale-static",
|
|
{
|
|
_id: "skillVersions:stale-static",
|
|
staticScan: { engineVersion: "v2.2.0" },
|
|
},
|
|
],
|
|
[
|
|
"skillVersions:current-static",
|
|
{
|
|
_id: "skillVersions:current-static",
|
|
staticScan: { engineVersion: MODERATION_ENGINE_VERSION },
|
|
},
|
|
],
|
|
[
|
|
"skillVersions:hidden-static",
|
|
{
|
|
_id: "skillVersions:hidden-static",
|
|
staticScan: { engineVersion: "v2.2.0" },
|
|
},
|
|
],
|
|
]);
|
|
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "skills") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: (
|
|
indexName: string,
|
|
builder: (q: { gt: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
builder({ gt: () => ({}) });
|
|
if (indexName !== "by_creation_time") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return {
|
|
order: () => ({
|
|
take: async () => skills,
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}),
|
|
get: vi.fn(async (id: string) => versions.get(id) ?? null),
|
|
},
|
|
};
|
|
|
|
const result = await getStaticScanBackfillBatchHandler(ctx, {
|
|
batchSize: 10,
|
|
cursor: 0,
|
|
});
|
|
|
|
expect(result.skills).toEqual([
|
|
{
|
|
skillId: "skills:missing-static",
|
|
versionId: "skillVersions:missing-static",
|
|
slug: "missing-static",
|
|
},
|
|
{
|
|
skillId: "skills:stale-static",
|
|
versionId: "skillVersions:stale-static",
|
|
slug: "stale-static",
|
|
},
|
|
]);
|
|
expect(result.done).toBe(true);
|
|
});
|
|
});
|
|
|
|
function makeSkill(
|
|
id: string,
|
|
versionId: string,
|
|
moderationReason: string,
|
|
scanLastCheckedAt?: number,
|
|
) {
|
|
return {
|
|
_id: id,
|
|
moderationStatus: "active",
|
|
moderationReason,
|
|
latestVersionId: versionId,
|
|
scanLastCheckedAt,
|
|
};
|
|
}
|