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

804 lines
21 KiB
TypeScript

/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import {
applyBanToOwnedSkillsBatchInternal,
applyPublisherDeletionToOwnedSkillsBatchInternal,
hardDeleteInternal,
restoreOwnedSkillsForUnbanBatchInternal,
} from "./skills";
type WrappedHandler<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const restoreUnbanHandler = (
restoreOwnedSkillsForUnbanBatchInternal as unknown as WrappedHandler<
{ ownerUserId: string; bannedAt: number; cursor?: string },
{ restoredCount: number; scheduled: boolean; aborted?: boolean }
>
)._handler;
const applyBanHandler = (
applyBanToOwnedSkillsBatchInternal as unknown as WrappedHandler<
{ ownerUserId: string; bannedAt: number; hiddenBy?: string; cursor?: string },
{ hiddenCount: number; scheduled: boolean; aborted?: boolean }
>
)._handler;
const applyPublisherDeletionHandler = (
applyPublisherDeletionToOwnedSkillsBatchInternal as unknown as WrappedHandler<
{ ownerPublisherId: string; actorUserId: string; deletedAt: number; cursor?: string },
{ hiddenCount: number; scheduled: boolean; stale?: boolean }
>
)._handler;
const hardDeleteHandler = (
hardDeleteInternal as unknown as WrappedHandler<
{
skillId: string;
actorUserId: string;
phase?: string;
source?: "admin" | "account.delete" | "publisher.delete";
ownerPublisherId?: string;
},
void
>
)._handler;
function makeCtx({
user,
skills = [],
}: {
user: Record<string, unknown> | null;
skills?: Array<Record<string, unknown>>;
}) {
const patch = vi.fn();
const query = vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: () => ({
order: () => ({
paginate: async () => ({ page: skills, isDone: true, continueCursor: null }),
}),
}),
};
}
if (table === "skillVersions") {
return {
withIndex: () => ({
take: async () => [],
}),
};
}
if (table === "skillEmbeddings") {
return {
withIndex: () => ({
collect: async () => [],
take: async () => [],
}),
};
}
if (table === "skillReports") {
return {
withIndex: () => ({
take: async () => [],
}),
};
}
if (table === "skillVersions") {
return {
withIndex: () => ({
take: async () => [],
}),
};
}
throw new Error(`Unexpected table ${table}`);
});
const scheduler = { runAfter: vi.fn() };
return {
ctx: {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:owner") return user;
if (id === "publishers:org") return { _id: id, kind: "org", deletedAt: 3_000 };
const skill = skills.find((row) => row._id === id);
if (skill) return skill;
return null;
}),
insert: vi.fn(),
patch,
replace: vi.fn(),
delete: vi.fn(),
query,
normalizeId: vi.fn(),
},
scheduler,
} as never,
patch,
query,
scheduler,
};
}
describe("skills ban/unban batches", () => {
it("starts hard-delete cleanup for active skills owned by a deleted publisher", async () => {
const { ctx, patch, scheduler } = makeCtx({
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
skills: [
{
_id: "skills:org-skill",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:org",
softDeletedAt: undefined,
hiddenAt: undefined,
moderationStatus: "active",
moderationFlags: undefined,
stats: {
downloads: 0,
stars: 0,
comments: 0,
versions: 1,
installsCurrent: 0,
installsAllTime: 0,
},
},
],
});
await expect(
applyPublisherDeletionHandler(ctx, {
ownerPublisherId: "publishers:org",
actorUserId: "users:owner",
deletedAt: 3_000,
}),
).resolves.toEqual({
ok: true,
hiddenCount: 1,
scheduled: false,
});
expect(patch).toHaveBeenCalledWith(
"skills:org-skill",
expect.objectContaining({
softDeletedAt: expect.any(Number),
hiddenAt: expect.any(Number),
moderationStatus: "removed",
hiddenBy: "users:owner",
}),
);
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
skillId: "skills:org-skill",
actorUserId: "users:owner",
phase: "fingerprints",
source: "publisher.delete",
ownerPublisherId: "publishers:org",
}),
);
});
it("removes install telemetry dedupe rows during skill hard delete", async () => {
const skill = {
_id: "skills:deleted",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
hiddenAt: 1_000,
hiddenBy: "users:admin",
moderationStatus: "removed",
stats: {
downloads: 0,
stars: 0,
comments: 0,
versions: 1,
installsCurrent: 0,
installsAllTime: 0,
},
};
const dedupeRows = [
{ _id: "installTelemetryDedupes:one" },
{ _id: "installTelemetryDedupes:two" },
];
const delete_ = vi.fn();
const query = vi.fn((table: string) => {
if (table === "installTelemetryDedupes") {
return {
withIndex: (indexName: string) => {
expect(indexName).toBe("by_skill");
return { take: async () => dedupeRows };
},
};
}
throw new Error(`Unexpected table ${table}`);
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:admin") {
return {
_id: "users:admin",
role: "admin",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "skills:deleted") return skill;
return null;
}),
insert: vi.fn(),
patch: vi.fn(),
replace: vi.fn(),
delete: delete_,
query,
normalizeId: vi.fn(),
},
scheduler,
} as never;
await expect(
hardDeleteHandler(ctx, {
skillId: "skills:deleted",
actorUserId: "users:admin",
phase: "installTelemetryDedupes",
}),
).resolves.toBeUndefined();
expect(delete_).toHaveBeenCalledWith("installTelemetryDedupes:one");
expect(delete_).toHaveBeenCalledWith("installTelemetryDedupes:two");
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
skillId: "skills:deleted",
actorUserId: "users:admin",
phase: "leaderboards",
}),
);
});
it("forwards an already-scheduled legacy root install phase to dedupe cleanup", async () => {
const skill = {
_id: "skills:deleted",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
hiddenAt: 1_000,
hiddenBy: "users:admin",
moderationStatus: "removed",
stats: {
downloads: 0,
stars: 0,
comments: 0,
versions: 1,
installsCurrent: 0,
installsAllTime: 0,
},
};
const query = vi.fn((table: string) => {
expect(table).toBe("installTelemetryDedupes");
return {
withIndex: (indexName: string) => {
expect(indexName).toBe("by_skill");
return { take: async () => [] };
},
};
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:admin") {
return {
_id: "users:admin",
role: "admin",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "skills:deleted") return skill;
return null;
}),
insert: vi.fn(),
patch: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query,
normalizeId: vi.fn(),
},
scheduler,
} as never;
await expect(
hardDeleteHandler(ctx, {
skillId: "skills:deleted",
actorUserId: "users:admin",
phase: "rootInstalls",
}),
).resolves.toBeUndefined();
expect(query).toHaveBeenCalledOnce();
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
skillId: "skills:deleted",
actorUserId: "users:admin",
phase: "leaderboards",
}),
);
});
it("reschedules install telemetry dedupe cleanup when a hard-delete batch fills", async () => {
const skill = {
_id: "skills:deleted",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
hiddenAt: 1_000,
hiddenBy: "users:admin",
moderationStatus: "removed",
stats: {
downloads: 0,
stars: 0,
comments: 0,
versions: 1,
installsCurrent: 0,
installsAllTime: 0,
},
};
const dedupeRows = Array.from({ length: 100 }, (_, index) => ({
_id: `installTelemetryDedupes:${index}`,
}));
const delete_ = vi.fn();
const query = vi.fn((table: string) => {
if (table === "installTelemetryDedupes") {
return {
withIndex: (indexName: string) => {
expect(indexName).toBe("by_skill");
return { take: async () => dedupeRows };
},
};
}
throw new Error(`Unexpected table ${table}`);
});
const scheduler = { runAfter: vi.fn() };
const ctx = {
db: {
get: vi.fn(async (id: string) => {
if (id === "users:admin") {
return {
_id: "users:admin",
role: "admin",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "skills:deleted") return skill;
return null;
}),
insert: vi.fn(),
patch: vi.fn(),
replace: vi.fn(),
delete: delete_,
query,
normalizeId: vi.fn(),
},
scheduler,
} as never;
await expect(
hardDeleteHandler(ctx, {
skillId: "skills:deleted",
actorUserId: "users:admin",
phase: "installTelemetryDedupes",
}),
).resolves.toBeUndefined();
expect(delete_).toHaveBeenCalledTimes(100);
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
skillId: "skills:deleted",
actorUserId: "users:admin",
phase: "installTelemetryDedupes",
}),
);
expect(scheduler.runAfter).not.toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
skillId: "skills:deleted",
actorUserId: "users:admin",
phase: "leaderboards",
}),
);
});
it("retimestamps earlier ban-hidden skills during a later ban", async () => {
const { ctx, patch, scheduler } = makeCtx({
user: { _id: "users:owner", deletedAt: 2_000 },
skills: [
{
_id: "skills:hidden",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationStatus: "hidden",
moderationReason: "user.banned",
hiddenAt: 1_000,
hiddenBy: "users:first-moderator",
},
],
});
await expect(
applyBanHandler(ctx, {
ownerUserId: "users:owner",
bannedAt: 2_000,
hiddenBy: "users:second-moderator",
}),
).resolves.toEqual({
ok: true,
hiddenCount: 0,
scheduled: false,
});
expect(patch).toHaveBeenCalledWith(
"skills:hidden",
expect.objectContaining({
softDeletedAt: 2_000,
hiddenAt: 2_000,
hiddenBy: "users:second-moderator",
lastReviewedAt: 2_000,
updatedAt: 2_000,
}),
);
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("retimestamps legacy ban-hidden skills so a final unban restores them after a re-ban", async () => {
const legacySkill = {
_id: "skills:legacy-hidden",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationStatus: undefined,
moderationReason: "user.banned",
hiddenAt: 1_000,
hiddenBy: "users:first-moderator",
stats: {
downloads: 0,
stars: 0,
comments: 0,
versions: 1,
installsCurrent: 0,
installsAllTime: 0,
},
};
const { ctx: banCtx, patch: banPatch } = makeCtx({
user: { _id: "users:owner", deletedAt: 2_000 },
skills: [legacySkill],
});
await expect(
applyBanHandler(banCtx, {
ownerUserId: "users:owner",
bannedAt: 2_000,
hiddenBy: "users:second-moderator",
}),
).resolves.toEqual({
ok: true,
hiddenCount: 0,
scheduled: false,
});
expect(banPatch).toHaveBeenCalledWith(
"skills:legacy-hidden",
expect.objectContaining({
softDeletedAt: 2_000,
hiddenAt: 2_000,
hiddenBy: "users:second-moderator",
lastReviewedAt: 2_000,
updatedAt: 2_000,
}),
);
const retimestampPatch = banPatch.mock.calls[0]?.[1] ?? {};
const { ctx: unbanCtx, patch: unbanPatch } = makeCtx({
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
skills: [{ ...legacySkill, ...retimestampPatch }],
});
await expect(
restoreUnbanHandler(unbanCtx, { ownerUserId: "users:owner", bannedAt: 2_000 }),
).resolves.toMatchObject({
restoredCount: 1,
scheduled: false,
});
expect(unbanPatch).toHaveBeenCalledWith(
"skills:legacy-hidden",
expect.objectContaining({
softDeletedAt: undefined,
moderationStatus: "active",
moderationReason: "restored.unban",
}),
);
});
it("does not retimestamp removed ban-hidden skills during a later ban", async () => {
const { ctx, patch } = makeCtx({
user: { _id: "users:owner", deletedAt: 2_000 },
skills: [
{
_id: "skills:removed",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationStatus: "removed",
moderationReason: "user.banned",
hiddenAt: 1_000,
},
],
});
await expect(
applyBanHandler(ctx, {
ownerUserId: "users:owner",
bannedAt: 2_000,
hiddenBy: "users:second-moderator",
}),
).resolves.toMatchObject({
hiddenCount: 0,
scheduled: false,
});
expect(patch).not.toHaveBeenCalledWith("skills:removed", expect.anything());
});
it("does not roll newer ban markers back when stale ban pages run late", async () => {
const { ctx, patch } = makeCtx({
user: { _id: "users:owner", deletedAt: 1_000 },
skills: [
{
_id: "skills:hidden",
ownerUserId: "users:owner",
softDeletedAt: 2_000,
moderationStatus: "hidden",
moderationReason: "user.banned",
hiddenAt: 2_000,
hiddenBy: "users:second-moderator",
},
],
});
await expect(
applyBanHandler(ctx, {
ownerUserId: "users:owner",
bannedAt: 1_000,
hiddenBy: "users:first-moderator",
}),
).resolves.toEqual({
ok: true,
hiddenCount: 0,
scheduled: false,
});
expect(patch).not.toHaveBeenCalledWith("skills:hidden", expect.anything());
});
it("aborts stale scheduled ban pages after the owner is unbanned", async () => {
const { ctx, patch, query, scheduler } = makeCtx({
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
skills: [
{
_id: "skills:hidden",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationStatus: "hidden",
moderationReason: "user.banned",
hiddenAt: 1_000,
},
],
});
await expect(
applyBanHandler(ctx, {
ownerUserId: "users:owner",
bannedAt: 2_000,
hiddenBy: "users:second-moderator",
cursor: "next-page",
}),
).resolves.toEqual({
ok: true,
hiddenCount: 0,
scheduled: false,
aborted: true,
});
expect(query).not.toHaveBeenCalled();
expect(patch).not.toHaveBeenCalled();
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("aborts stale unban restore pages when the owner was banned again", async () => {
const { ctx, patch, query, scheduler } = makeCtx({
user: { _id: "users:owner", deletedAt: 2_000 },
skills: [
{
_id: "skills:hidden",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationReason: "user.banned",
},
],
});
await expect(
restoreUnbanHandler(ctx, {
ownerUserId: "users:owner",
bannedAt: 1_000,
cursor: "next-page",
}),
).resolves.toEqual({
ok: true,
restoredCount: 0,
scheduled: false,
aborted: true,
});
expect(query).not.toHaveBeenCalled();
expect(patch).not.toHaveBeenCalled();
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("continues unban restore pages while the owner is active", async () => {
const { ctx, query, scheduler } = makeCtx({
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
});
await expect(
restoreUnbanHandler(ctx, { ownerUserId: "users:owner", bannedAt: 1_000 }),
).resolves.toEqual({
ok: true,
restoredCount: 0,
scheduled: false,
});
expect(query).toHaveBeenCalledWith("skills");
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("restores legacy ban-hidden skills without moderationStatus", async () => {
const { ctx, patch } = makeCtx({
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
skills: [
{
_id: "skills:legacy-hidden",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationStatus: undefined,
moderationReason: "user.banned",
stats: {
downloads: 0,
stars: 0,
comments: 0,
versions: 1,
installsCurrent: 0,
installsAllTime: 0,
},
},
],
});
await expect(
restoreUnbanHandler(ctx, { ownerUserId: "users:owner", bannedAt: 1_000 }),
).resolves.toMatchObject({
restoredCount: 1,
scheduled: false,
});
expect(patch).toHaveBeenCalledWith(
"skills:legacy-hidden",
expect.objectContaining({
softDeletedAt: undefined,
moderationStatus: "active",
moderationReason: "restored.unban",
}),
);
});
it("does not restore removed ban-hidden skills", async () => {
const { ctx, patch } = makeCtx({
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
skills: [
{
_id: "skills:removed",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationStatus: "removed",
moderationReason: "user.banned",
},
],
});
await expect(
restoreUnbanHandler(ctx, { ownerUserId: "users:owner", bannedAt: 1_000 }),
).resolves.toMatchObject({
restoredCount: 0,
scheduled: false,
});
expect(patch).not.toHaveBeenCalledWith("skills:removed", expect.anything());
});
it("does not restore an independent exact-version revocation hold", async () => {
const { ctx, patch } = makeCtx({
user: { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined },
skills: [
{
_id: "skills:revoked",
ownerUserId: "users:owner",
softDeletedAt: 2_000,
moderationStatus: "hidden",
moderationReason: "manual.version_revoked",
latestVersionId: undefined,
tags: {},
},
],
});
await expect(
restoreUnbanHandler(ctx, { ownerUserId: "users:owner", bannedAt: 1_000 }),
).resolves.toMatchObject({
restoredCount: 0,
scheduled: false,
});
expect(patch).not.toHaveBeenCalledWith("skills:revoked", expect.anything());
});
it("continues stale retired comment cleanup phases at skill reports", async () => {
const { ctx, query, scheduler } = makeCtx({
user: { _id: "users:owner", role: "admin" },
skills: [
{
_id: "skills:legacy",
slug: "legacy",
ownerUserId: "users:owner",
softDeletedAt: 1_000,
moderationStatus: "removed",
hiddenAt: 1_000,
hiddenBy: "users:owner",
stats: {
downloads: 0,
stars: 0,
comments: 0,
versions: 1,
installsCurrent: 0,
installsAllTime: 0,
},
},
],
});
await hardDeleteHandler(ctx, {
skillId: "skills:legacy",
actorUserId: "users:owner",
phase: "comments",
});
expect(query).not.toHaveBeenCalledWith("comments");
expect(query).not.toHaveBeenCalledWith("commentReports");
expect(query).toHaveBeenCalledWith("skillReports");
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
skillId: "skills:legacy",
actorUserId: "users:owner",
phase: "stars",
source: "admin",
}),
);
});
});