Files
clawhub/convex/skills.pendingScanQueue.test.ts

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,
};
}