mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
211 lines
5.8 KiB
TypeScript
211 lines
5.8 KiB
TypeScript
/* @vitest-environment node */
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { buildSkillVersionRevocationPlan, revokeSkillVersionForUser } from "./skills";
|
|
|
|
function makeVersion(id: string, version: string, createdAt: number) {
|
|
return {
|
|
_id: id,
|
|
skillId: "skills:demo",
|
|
version,
|
|
changelog: `${version} changes`,
|
|
changelogSource: "user",
|
|
parsed: {
|
|
frontmatter: {
|
|
name: `Demo ${version}`,
|
|
description: `Description ${version}`,
|
|
},
|
|
clawdis: { version },
|
|
},
|
|
icon: `lucide:${version}`,
|
|
createdAt,
|
|
};
|
|
}
|
|
|
|
function makeSkill() {
|
|
return {
|
|
_id: "skills:demo",
|
|
slug: "demo",
|
|
displayName: "Demo 2.0.0",
|
|
summary: "Description 2.0.0",
|
|
icon: "lucide:2.0.0",
|
|
latestVersionId: "skillVersions:v2",
|
|
latestVersionSummary: {
|
|
version: "2.0.0",
|
|
createdAt: 20,
|
|
changelog: "2.0.0 changes",
|
|
},
|
|
tags: {
|
|
latest: "skillVersions:v2",
|
|
stable: "skillVersions:v2",
|
|
legacy: "skillVersions:v1",
|
|
},
|
|
moderationStatus: "hidden",
|
|
moderationReason: "user.banned",
|
|
softDeletedAt: 100,
|
|
};
|
|
}
|
|
|
|
describe("buildSkillVersionRevocationPlan", () => {
|
|
it("moves latest pointers to the highest available replacement without lifting a parent hold", () => {
|
|
const plan = buildSkillVersionRevocationPlan({
|
|
actorUserId: "users:moderator" as never,
|
|
skill: makeSkill() as never,
|
|
target: makeVersion("skillVersions:v2", "2.0.0", 20) as never,
|
|
replacement: makeVersion("skillVersions:v1", "1.0.0", 10) as never,
|
|
reason: "confirmed unsafe artifact",
|
|
now: 200,
|
|
});
|
|
|
|
expect(plan.versionPatch).toEqual({
|
|
softDeletedAt: 200,
|
|
manualRevocation: {
|
|
reason: "confirmed unsafe artifact",
|
|
reviewerUserId: "users:moderator",
|
|
revokedAt: 200,
|
|
},
|
|
});
|
|
expect(plan.skillPatch).toMatchObject({
|
|
latestVersionId: "skillVersions:v1",
|
|
displayName: "Demo 1.0.0",
|
|
summary: "Description 1.0.0",
|
|
icon: "lucide:1.0.0",
|
|
tags: {
|
|
latest: "skillVersions:v1",
|
|
legacy: "skillVersions:v1",
|
|
},
|
|
});
|
|
expect(plan.skillPatch).not.toHaveProperty("softDeletedAt");
|
|
expect(plan.skillPatch).not.toHaveProperty("moderationReason");
|
|
});
|
|
|
|
it("creates an independent hold when the revoked latest has no available replacement", () => {
|
|
const skill = {
|
|
...makeSkill(),
|
|
tags: {
|
|
latest: "skillVersions:v2",
|
|
stable: "skillVersions:v2",
|
|
},
|
|
};
|
|
const plan = buildSkillVersionRevocationPlan({
|
|
actorUserId: "users:moderator" as never,
|
|
skill: skill as never,
|
|
target: makeVersion("skillVersions:v2", "2.0.0", 20) as never,
|
|
replacement: null,
|
|
reason: "confirmed unsafe artifact",
|
|
now: 200,
|
|
});
|
|
|
|
expect(plan.skillPatch).toMatchObject({
|
|
latestVersionId: undefined,
|
|
latestVersionSummary: undefined,
|
|
tags: {},
|
|
softDeletedAt: 200,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "manual.version_revoked",
|
|
moderationNotes: "confirmed unsafe artifact",
|
|
hiddenAt: 200,
|
|
hiddenBy: "users:moderator",
|
|
manualOverride: undefined,
|
|
});
|
|
});
|
|
|
|
it("does not change skill pointers when a non-latest version is revoked", () => {
|
|
const plan = buildSkillVersionRevocationPlan({
|
|
actorUserId: "users:moderator" as never,
|
|
skill: makeSkill() as never,
|
|
target: makeVersion("skillVersions:v1", "1.0.0", 10) as never,
|
|
replacement: null,
|
|
reason: "confirmed unsafe artifact",
|
|
now: 200,
|
|
});
|
|
|
|
expect(plan.skillPatch).toEqual({
|
|
tags: {
|
|
latest: "skillVersions:v2",
|
|
stable: "skillVersions:v2",
|
|
},
|
|
updatedAt: 200,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("revokeSkillVersionForUser", () => {
|
|
it("rejects non-moderator actors before resolving the skill", async () => {
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn().mockResolvedValue({
|
|
_id: "users:publisher",
|
|
role: "user",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
revokeSkillVersionForUser(ctx as never, {
|
|
actorUserId: "users:publisher" as never,
|
|
slug: "demo",
|
|
version: "1.0.0",
|
|
reason: "confirmed unsafe artifact",
|
|
}),
|
|
).rejects.toThrow("Forbidden");
|
|
expect(ctx.db.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("resolves already-hidden skills so their exact versions can be revoked before unban", async () => {
|
|
const hiddenSkill = {
|
|
_id: "skills:hidden",
|
|
slug: "hidden-demo",
|
|
softDeletedAt: 100,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "user.banned",
|
|
tags: {},
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn().mockResolvedValue({
|
|
_id: "users:moderator",
|
|
role: "moderator",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
take: vi.fn().mockResolvedValue([hiddenSkill]),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "skillSlugAliases") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
take: vi.fn().mockResolvedValue([]),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "skillVersions") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue(null),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
revokeSkillVersionForUser(ctx as never, {
|
|
actorUserId: "users:moderator" as never,
|
|
slug: "hidden-demo",
|
|
version: "1.0.0",
|
|
reason: "confirmed unsafe artifact",
|
|
}),
|
|
).rejects.toThrow("Skill version not found");
|
|
});
|
|
});
|