mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
4589 lines
134 KiB
TypeScript
4589 lines
134 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@convex-dev/auth/server", () => ({
|
|
getAuthUserId: vi.fn(),
|
|
authTables: {},
|
|
}));
|
|
|
|
vi.mock("./lib/access", async () => {
|
|
const actual = await vi.importActual<typeof import("./lib/access")>("./lib/access");
|
|
return { ...actual, requireUser: vi.fn() };
|
|
});
|
|
|
|
const { requireUser } = await import("./lib/access");
|
|
const { getAuthUserId } = await import("@convex-dev/auth/server");
|
|
const {
|
|
ensureHandler,
|
|
getByHandle,
|
|
getHoverStats,
|
|
getBanAppealContextByGitHubProviderAccountIdInternal,
|
|
list,
|
|
searchInternal,
|
|
banUserInternal,
|
|
autobanMalwareAuthorInternal,
|
|
autobanPublisherAbuseOwnerInternal,
|
|
recordMaliciousArtifactFindingInternal,
|
|
unbanUserForBanAppealServiceInternal,
|
|
reclassifyBanInternal,
|
|
me,
|
|
placeUserUnderModerationInternal,
|
|
liftModerationHoldInternal,
|
|
purgeSelfDeletedAccountRecoveryBatchInternal,
|
|
ensurePublisherHandleInternal,
|
|
reserveHandleInternal,
|
|
syncGitHubProfileInternal,
|
|
updateProfile,
|
|
deleteAccount,
|
|
upsertDevPersonaInternal,
|
|
} = await import("./users");
|
|
|
|
type WrappedHandler<TArgs, TResult> = {
|
|
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
|
};
|
|
|
|
const meHandler = (me as unknown as WrappedHandler<Record<string, never>, unknown>)._handler;
|
|
const getByHandleHandler = (getByHandle as unknown as WrappedHandler<{ handle: string }, unknown>)
|
|
._handler;
|
|
const getHoverStatsHandler = (
|
|
getHoverStats as unknown as WrappedHandler<{ userId: string }, unknown>
|
|
)._handler;
|
|
const updateProfileHandler = (
|
|
updateProfile as unknown as WrappedHandler<{ displayName: string; bio?: string }, void>
|
|
)._handler;
|
|
const deleteAccountHandler = (
|
|
deleteAccount as unknown as WrappedHandler<Record<string, never>, void>
|
|
)._handler;
|
|
const purgeSelfDeletedAccountRecoveryBatchInternalHandler = (
|
|
purgeSelfDeletedAccountRecoveryBatchInternal as unknown as WrappedHandler<
|
|
{ cursor?: string; limit?: number; dryRun?: boolean; mode?: "deactivated" | "legacyDeleted" },
|
|
unknown
|
|
>
|
|
)._handler;
|
|
const upsertDevPersonaInternalHandler = (
|
|
upsertDevPersonaInternal as unknown as WrappedHandler<
|
|
{ persona: "owner" | "user" | "admin" | "officialOrgMember" | "abusePublisher" },
|
|
unknown
|
|
>
|
|
)._handler;
|
|
function makeCtx() {
|
|
const patch = vi.fn();
|
|
const publisherRows = new Map<string, Record<string, unknown>>();
|
|
const publisherMembers: Array<Record<string, unknown>> = [];
|
|
const get = vi.fn(async (id: string) => publisherRows.get(id) ?? null);
|
|
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
if (table === "publishers") {
|
|
const handle = typeof value.handle === "string" ? value.handle : "user";
|
|
const id = `publishers:${handle}`;
|
|
publisherRows.set(id, { _id: id, _creationTime: 1, ...value });
|
|
return id;
|
|
}
|
|
if (table === "publisherMembers") {
|
|
const id = `publisherMembers:${publisherMembers.length + 1}`;
|
|
publisherMembers.push({ _id: id, ...value });
|
|
return id;
|
|
}
|
|
if (table === "auditLogs") return "auditLogs:1";
|
|
return `${table}:1`;
|
|
});
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return { order: () => ({ take: vi.fn(async () => []) }) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
|
return { unique: vi.fn(async () => null) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (name === "by_linked_user") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`Unexpected publishers index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`Unexpected publisherMembers index ${name}`);
|
|
}
|
|
return { unique: vi.fn(async () => null) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "packages" || table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_owner_publisher") {
|
|
throw new Error(`Unexpected ${table} index ${name}`);
|
|
}
|
|
return { collect: vi.fn(async () => []) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
});
|
|
return {
|
|
ctx: { db: { patch, get, insert, query, normalizeId: vi.fn() } } as never,
|
|
patch,
|
|
get,
|
|
insert,
|
|
query,
|
|
};
|
|
}
|
|
|
|
function makeDevPersonaCtx() {
|
|
const users = new Map<string, Record<string, unknown>>();
|
|
const publishers = new Map<string, Record<string, unknown>>();
|
|
const publisherMembers: Array<Record<string, unknown>> = [];
|
|
const officialPublishers: Array<Record<string, unknown>> = [];
|
|
const auditLogs: Array<Record<string, unknown>> = [];
|
|
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
|
const patches: Array<{ id: string; value: Record<string, unknown> }> = [];
|
|
|
|
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
const id = `${table}:${inserts.length + 1}`;
|
|
const row = { _id: id, _creationTime: 1, ...value };
|
|
inserts.push({ table, value: row });
|
|
if (table === "users") users.set(id, row);
|
|
if (table === "publishers") publishers.set(id, row);
|
|
if (table === "publisherMembers") publisherMembers.push(row);
|
|
if (table === "officialPublishers") officialPublishers.push(row);
|
|
return id;
|
|
});
|
|
|
|
const get = vi.fn(async (...args: string[]) => {
|
|
const id = args.length === 2 ? args[1] : args[0];
|
|
return users.get(id) ?? publishers.get(id) ?? null;
|
|
});
|
|
|
|
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
|
patches.push({ id, value });
|
|
const current = users.get(id) ?? publishers.get(id);
|
|
if (current) Object.assign(current, value);
|
|
});
|
|
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
|
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
|
let handle = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(
|
|
async () => [...users.values()].find((user) => user.handle === handle) ?? null,
|
|
),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
|
let handle = "";
|
|
let linkedUserId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
if (field === "linkedUserId") linkedUserId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
if (name === "by_handle") {
|
|
return {
|
|
unique: vi.fn(
|
|
async () =>
|
|
[...publishers.values()].find((publisher) => publisher.handle === handle) ?? null,
|
|
),
|
|
};
|
|
}
|
|
if (name === "by_linked_user") {
|
|
return {
|
|
unique: vi.fn(
|
|
async () =>
|
|
[...publishers.values()].find(
|
|
(publisher) => publisher.linkedUserId === linkedUserId,
|
|
) ?? null,
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected publishers index ${name}`);
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`Unexpected publisherMembers index ${name}`);
|
|
}
|
|
let publisherId = "";
|
|
let userId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "publisherId") publisherId = value;
|
|
if (field === "userId") userId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(
|
|
async () =>
|
|
publisherMembers.find(
|
|
(member) => member.publisherId === publisherId && member.userId === userId,
|
|
) ?? null,
|
|
),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: vi.fn((name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return { order: () => ({ take: vi.fn(async () => []) }) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return {
|
|
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
|
if (name !== "by_publisher")
|
|
throw new Error(`Unexpected officialPublishers index ${name}`);
|
|
let publisherId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "publisherId") publisherId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(
|
|
async () =>
|
|
officialPublishers.find((entry) => entry.publisherId === publisherId) ?? null,
|
|
),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "auditLogs") {
|
|
return {
|
|
withIndex: vi.fn((name: string, builder?: (q: unknown) => unknown) => {
|
|
if (name !== "by_target") throw new Error(`Unexpected auditLogs index ${name}`);
|
|
let targetType = "";
|
|
let targetId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "targetType") targetType = value;
|
|
if (field === "targetId") targetId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
collect: vi.fn(async () =>
|
|
auditLogs.filter(
|
|
(entry) => entry.targetType === targetType && entry.targetId === targetId,
|
|
),
|
|
),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "packages" || table === "skills") {
|
|
return {
|
|
withIndex: vi.fn((name: string) => {
|
|
if (name !== "by_owner") throw new Error(`Unexpected ${table} index ${name}`);
|
|
return {
|
|
collect: vi.fn(async () => []),
|
|
paginate: vi.fn(async () => ({
|
|
page: [],
|
|
continueCursor: null,
|
|
isDone: true,
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "githubOrgMemberships") {
|
|
return {
|
|
withIndex: vi.fn((name: string) => {
|
|
if (name !== "by_user") {
|
|
throw new Error(`Unexpected githubOrgMemberships index ${name}`);
|
|
}
|
|
return { collect: vi.fn(async () => []) };
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
});
|
|
|
|
return {
|
|
ctx: { db: { patch, get, insert, query, delete: vi.fn(), normalizeId: vi.fn() } } as never,
|
|
auditLogs,
|
|
inserts,
|
|
patches,
|
|
users,
|
|
};
|
|
}
|
|
|
|
function makeListCtx(
|
|
users: Array<Record<string, unknown>>,
|
|
options?: {
|
|
publishersByHandle?: Record<string, Record<string, unknown>>;
|
|
usersById?: Record<string, Record<string, unknown> | null>;
|
|
},
|
|
) {
|
|
const take = vi.fn(async (n: number) => users.slice(0, n));
|
|
const collect = vi.fn(async () => users);
|
|
const order = vi.fn(() => ({ take, collect }));
|
|
const publishersByHandle = options?.publishersByHandle ?? {};
|
|
const usersById = options?.usersById ?? {};
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
order,
|
|
withIndex: (
|
|
name: string,
|
|
cb?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
|
let handle = "";
|
|
cb?.({
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return {};
|
|
},
|
|
});
|
|
return {
|
|
unique: vi.fn(async () => users.find((user) => user.handle === handle) ?? null),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
cb?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
|
let handle = "";
|
|
cb?.({
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return {};
|
|
},
|
|
});
|
|
return { unique: vi.fn(async () => publishersByHandle[handle] ?? null) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
});
|
|
const get = vi.fn<(id: string) => Promise<Record<string, unknown> | null>>(
|
|
async (id: string) => usersById[id] ?? null,
|
|
);
|
|
return {
|
|
ctx: { db: { query, get, normalizeId: vi.fn() } } as never,
|
|
take,
|
|
collect,
|
|
order,
|
|
query,
|
|
get,
|
|
};
|
|
}
|
|
|
|
function makeBanCtx(
|
|
options: {
|
|
auditLogs?: Array<Record<string, unknown>>;
|
|
officialPublisherIds?: string[];
|
|
publisherMembers?: Array<Record<string, unknown>>;
|
|
} = {},
|
|
) {
|
|
const patch = vi.fn();
|
|
const auditLogs = options.auditLogs ?? [];
|
|
const officialPublisherIds = new Set(options.officialPublisherIds ?? []);
|
|
const publisherMembers = options.publisherMembers ?? [];
|
|
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
if (table === "auditLogs") auditLogs.unshift(value);
|
|
return `${table}:inserted`;
|
|
});
|
|
const get = vi.fn();
|
|
const runMutation = vi.fn();
|
|
const runAfter = vi.fn();
|
|
const apiTokens = [{ _id: "apiTokens:1", revokedAt: undefined }];
|
|
const query = vi.fn((table: string) => ({
|
|
withIndex: (
|
|
_index: string,
|
|
build?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
if (table === "auditLogs") {
|
|
return { order: vi.fn(() => ({ take: vi.fn().mockResolvedValue(auditLogs) })) };
|
|
}
|
|
if (table === "apiTokens") return { collect: vi.fn().mockResolvedValue(apiTokens) };
|
|
if (table === "officialPublishers") {
|
|
const constraints: Record<string, unknown> = {};
|
|
const q = {
|
|
eq(field: string, value: unknown) {
|
|
constraints[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
build?.(q);
|
|
const publisherId =
|
|
typeof constraints.publisherId === "string" ? constraints.publisherId : "";
|
|
return {
|
|
unique: vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
officialPublisherIds.has(publisherId)
|
|
? { _id: `officialPublishers:${publisherId}`, publisherId }
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
const constraints: Record<string, unknown> = {};
|
|
const q = {
|
|
eq(field: string, value: unknown) {
|
|
constraints[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
build?.(q);
|
|
const publisherId =
|
|
typeof constraints.publisherId === "string" ? constraints.publisherId : "";
|
|
const role = typeof constraints.role === "string" ? constraints.role : "";
|
|
return {
|
|
paginate: vi.fn(
|
|
async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
|
|
const members = publisherMembers.filter(
|
|
(member) => member.publisherId === publisherId && member.role === role,
|
|
);
|
|
const offset = cursor ? Number(cursor) : 0;
|
|
const page = members.slice(offset, offset + numItems);
|
|
const nextOffset = offset + page.length;
|
|
return {
|
|
page,
|
|
isDone: nextOffset >= members.length,
|
|
continueCursor: String(nextOffset),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
},
|
|
}));
|
|
|
|
const ctx = {
|
|
db: { patch, insert, get, query, normalizeId: vi.fn() },
|
|
runMutation,
|
|
scheduler: { runAfter },
|
|
} as never;
|
|
return { ctx, patch, insert, get, query, runMutation, runAfter };
|
|
}
|
|
|
|
function makeBanAppealContextCtx(options: {
|
|
accounts: Array<Record<string, unknown>>;
|
|
usersById: Record<string, Record<string, unknown>>;
|
|
auditLogs?: Array<Record<string, unknown>>;
|
|
}) {
|
|
const get = vi.fn(async (id: string) => options.usersById[id] ?? null);
|
|
const query = vi.fn((table: string) => ({
|
|
withIndex: (_index: string, _cb: unknown) => {
|
|
if (table === "authAccounts") {
|
|
return { take: vi.fn().mockResolvedValue(options.accounts) };
|
|
}
|
|
if (table === "auditLogs") {
|
|
return {
|
|
order: vi.fn(() => ({ take: vi.fn().mockResolvedValue(options.auditLogs ?? []) })),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
},
|
|
}));
|
|
return { ctx: { db: { query, get } } as never, query, get };
|
|
}
|
|
|
|
describe("users.getBanAppealContextByGitHubProviderAccountIdInternal", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("selects a currently banned user from duplicate GitHub auth accounts", async () => {
|
|
const { ctx } = makeBanAppealContextCtx({
|
|
accounts: [{ userId: "users:active" }, { userId: "users:banned" }],
|
|
usersById: {
|
|
"users:active": {
|
|
_id: "users:active",
|
|
handle: "active",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
},
|
|
"users:banned": {
|
|
_id: "users:banned",
|
|
handle: "banned",
|
|
displayName: "Banned User",
|
|
deletedAt: 1_700_000_000_000,
|
|
deactivatedAt: undefined,
|
|
banReason: "policy",
|
|
},
|
|
},
|
|
auditLogs: [
|
|
{
|
|
_id: "auditLogs:ban",
|
|
action: "user.ban",
|
|
actorUserId: "users:admin",
|
|
targetType: "user",
|
|
targetId: "users:banned",
|
|
metadata: { reason: "audit policy" },
|
|
createdAt: 1_700_000_000_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
const handler = (
|
|
getBanAppealContextByGitHubProviderAccountIdInternal as unknown as WrappedHandler<
|
|
{ providerAccountId: string },
|
|
unknown
|
|
>
|
|
)._handler;
|
|
|
|
const result = (await handler(ctx, { providerAccountId: "123456" })) as Record<string, unknown>;
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
action: "banned",
|
|
userId: "users:banned",
|
|
handle: "banned",
|
|
displayName: "Banned User",
|
|
banReason: "policy",
|
|
bannedAt: 1_700_000_000_000,
|
|
auditAction: "user.ban",
|
|
auditActorUserId: "users:admin",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ensureHandler", () => {
|
|
afterEach(() => {
|
|
vi.mocked(requireUser).mockReset();
|
|
vi.mocked(getAuthUserId).mockReset();
|
|
});
|
|
|
|
it("updates handle and display name when GitHub login changes", async () => {
|
|
const { ctx, patch } = makeCtx();
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:1",
|
|
user: {
|
|
_creationTime: 1,
|
|
handle: "old-handle",
|
|
displayName: "old-handle",
|
|
name: "new-handle",
|
|
email: "old@example.com",
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:1", {
|
|
handle: "new-handle",
|
|
displayName: "new-handle",
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("does not override a custom display name when syncing handle", async () => {
|
|
const { ctx, patch } = makeCtx();
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:2",
|
|
user: {
|
|
_creationTime: 1,
|
|
handle: "old-handle",
|
|
displayName: "Custom Name",
|
|
name: "new-handle",
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:2", {
|
|
handle: "new-handle",
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("fills display name from existing handle when missing", async () => {
|
|
const { ctx, patch } = makeCtx();
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:3",
|
|
user: {
|
|
_creationTime: 1,
|
|
handle: "steady-handle",
|
|
displayName: undefined,
|
|
name: undefined,
|
|
email: undefined,
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:3", {
|
|
displayName: "steady-handle",
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("does not patch when user metadata is already normalized", async () => {
|
|
const { ctx, patch, get } = makeCtx();
|
|
get.mockResolvedValue({
|
|
_id: "users:4",
|
|
handle: "steady",
|
|
displayName: "Steady Name",
|
|
name: "steady",
|
|
role: "user",
|
|
_creationTime: 1,
|
|
createdAt: 1,
|
|
});
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:4",
|
|
user: {
|
|
_creationTime: 1,
|
|
handle: "steady",
|
|
displayName: "Steady Name",
|
|
name: "steady",
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
const result = await ensureHandler(ctx);
|
|
|
|
expect(patch).not.toHaveBeenCalledWith(
|
|
"users:4",
|
|
expect.objectContaining({
|
|
handle: expect.anything(),
|
|
displayName: expect.anything(),
|
|
role: expect.anything(),
|
|
}),
|
|
);
|
|
expect(get).toHaveBeenCalledWith("users:4");
|
|
expect(result).toMatchObject({ _id: "users:4" });
|
|
});
|
|
|
|
it("sets admin role when normalized handle is steipete and role is missing", async () => {
|
|
const { ctx, patch } = makeCtx();
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: {
|
|
_creationTime: 1,
|
|
handle: "steipete",
|
|
displayName: "steipete",
|
|
name: "steipete",
|
|
role: undefined,
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:admin", {
|
|
displayName: "steipete",
|
|
role: "admin",
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("derives handle/display name from email when missing", async () => {
|
|
const { ctx, patch } = makeCtx();
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:email",
|
|
user: {
|
|
_creationTime: 1,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: undefined,
|
|
email: "owner@example.com",
|
|
role: undefined,
|
|
createdAt: undefined,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:email", {
|
|
handle: "owner",
|
|
displayName: "owner",
|
|
role: "user",
|
|
createdAt: 1,
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it.each(["docs", "skills"])(
|
|
"skips public route owner handle %s when deriving a handle",
|
|
async (handle) => {
|
|
const { ctx, patch } = makeCtx();
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: `users:${handle}`,
|
|
user: {
|
|
_creationTime: 1,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: handle,
|
|
email: undefined,
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith(`users:${handle}`, {
|
|
handle: `${handle}-2`,
|
|
displayName: `${handle}-2`,
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("falls back when a derived handle and email local part contain openclaw", async () => {
|
|
const { ctx, insert, patch } = makeCtx();
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:openclaw-china",
|
|
user: {
|
|
_id: "users:openclaw-china",
|
|
_creationTime: 1,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: "openclaw-china",
|
|
email: "openclaw-china@example.com",
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:openclaw-china", {
|
|
handle: "user",
|
|
displayName: "user",
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"publishers",
|
|
expect.objectContaining({
|
|
kind: "user",
|
|
handle: "user",
|
|
linkedUserId: "users:openclaw-china",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("repairs an existing handle that is no longer claimable", async () => {
|
|
const { ctx, patch, query } = makeCtx();
|
|
query.mockImplementation(((table: string) => {
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return { order: () => ({ take: async () => [] }) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let handle = "";
|
|
let linkedUserId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
if (field === "linkedUserId") linkedUserId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
if (name === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (handle === "acme-tools") {
|
|
return {
|
|
_id: "publishers:acme-tools",
|
|
kind: "org",
|
|
handle: "acme-tools",
|
|
displayName: "Acme Tools",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
}
|
|
if (name === "by_linked_user") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
linkedUserId === "users:owner"
|
|
? {
|
|
_id: "publishers:acme-tools-user",
|
|
kind: "user",
|
|
handle: "acme-tools-user",
|
|
linkedUserId: "users:owner",
|
|
displayName: "Acme Tools User",
|
|
}
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected publishers index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`Unexpected publisherMembers index ${name}`);
|
|
}
|
|
return { unique: vi.fn(async () => null) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "packages" || table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_owner_publisher") {
|
|
throw new Error(`Unexpected ${table} index ${name}`);
|
|
}
|
|
return { collect: vi.fn(async () => []) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}) as never);
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:owner",
|
|
user: {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "acme-tools",
|
|
displayName: "acme-tools",
|
|
name: "acme-tools",
|
|
email: "owner@example.com",
|
|
role: "user",
|
|
createdAt: 1,
|
|
personalPublisherId: "publishers:acme-tools-user",
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:owner", {
|
|
handle: "acme-tools-2",
|
|
displayName: "acme-tools-2",
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("does not auto-claim a reserved handle for another user", async () => {
|
|
const { ctx, patch, query } = makeCtx();
|
|
query.mockImplementation(((table: string) => {
|
|
if (table !== "reservedHandles") throw new Error(`Unexpected table ${table}`);
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return {
|
|
order: () => ({
|
|
take: async () => [
|
|
{
|
|
_id: "reservedHandles:1",
|
|
handle: "acme-tools",
|
|
rightfulOwnerUserId: "users:owner",
|
|
releasedAt: undefined,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}) as never);
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:other",
|
|
user: {
|
|
_id: "users:other",
|
|
_creationTime: 1,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: "acme-tools",
|
|
email: undefined,
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not auto-claim a handle already owned by an org publisher", async () => {
|
|
const { ctx, patch, query } = makeCtx();
|
|
query.mockImplementation(((table: string) => {
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return { order: () => ({ take: async () => [] }) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let handle = "";
|
|
let linkedUserId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
if (field === "linkedUserId") linkedUserId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
if (name === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
handle === "acme-tools"
|
|
? {
|
|
_id: "publishers:acme-tools",
|
|
kind: "org",
|
|
handle: "acme-tools",
|
|
displayName: "Acme Tools",
|
|
}
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
if (name === "by_linked_user") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
linkedUserId === "users:other"
|
|
? {
|
|
_id: "publishers:acme-tools-user",
|
|
kind: "user",
|
|
handle: "acme-tools-user",
|
|
linkedUserId: "users:other",
|
|
displayName: "Acme Tools User",
|
|
}
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected publishers index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`Unexpected publisherMembers index ${name}`);
|
|
}
|
|
return { unique: vi.fn(async () => null) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "packages" || table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_owner_publisher") {
|
|
throw new Error(`Unexpected ${table} index ${name}`);
|
|
}
|
|
return { collect: vi.fn(async () => []) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}) as never);
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:other",
|
|
user: {
|
|
_id: "users:other",
|
|
_creationTime: 1,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: "acme-tools",
|
|
email: undefined,
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await ensureHandler(ctx);
|
|
|
|
expect(patch).not.toHaveBeenCalledWith(
|
|
"users:other",
|
|
expect.objectContaining({ handle: "acme-tools" }),
|
|
);
|
|
});
|
|
|
|
it("does not fail page/session ensure when personal publisher handle sync conflicts", async () => {
|
|
const { ctx, insert, patch, query } = makeCtx();
|
|
query.mockImplementation(((table: string) => {
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return { order: () => ({ take: vi.fn(async () => []) }) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name === "by_linked_user") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (name === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
_id: "publishers:claimed",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "claimed",
|
|
displayName: "Claimed",
|
|
linkedUserId: "users:other",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected publishers index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}) as never);
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:self",
|
|
user: {
|
|
_id: "users:self",
|
|
_creationTime: 1,
|
|
handle: "claimed",
|
|
displayName: "Self",
|
|
name: undefined,
|
|
email: undefined,
|
|
role: "user",
|
|
createdAt: 1,
|
|
},
|
|
} as never);
|
|
|
|
await expect(ensureHandler(ctx)).resolves.toBeNull();
|
|
|
|
expect(patch).not.toHaveBeenCalledWith(
|
|
"users:self",
|
|
expect.objectContaining({ handle: expect.any(String) }),
|
|
);
|
|
expect(insert).not.toHaveBeenCalledWith("publishers", expect.anything());
|
|
});
|
|
});
|
|
|
|
describe("users.upsertDevPersonaInternal", () => {
|
|
it("rejects a dev persona sign-in when the persona is banned", async () => {
|
|
process.env.DEV_AUTH_ENABLED = "1";
|
|
process.env.CONVEX_DEPLOYMENT = "local:dev";
|
|
process.env.CONVEX_SITE_URL = "http://localhost:3210";
|
|
const { auditLogs, ctx, patches, users } = makeDevPersonaCtx();
|
|
users.set("users:banned", {
|
|
_id: "users:banned",
|
|
_creationTime: 1,
|
|
handle: "local-abuse",
|
|
displayName: "Local Abuse Test Publisher",
|
|
deletedAt: 1_700_000_000_000,
|
|
});
|
|
auditLogs.push({
|
|
action: "user.autoban.malware",
|
|
targetType: "user",
|
|
targetId: "users:banned",
|
|
});
|
|
|
|
await expect(
|
|
upsertDevPersonaInternalHandler(ctx, {
|
|
persona: "abusePublisher",
|
|
}),
|
|
).rejects.toThrow(/account has been banned/i);
|
|
expect(patches).toEqual([]);
|
|
});
|
|
|
|
it("keeps the abuse persona auth name aligned with its stable handle", async () => {
|
|
process.env.DEV_AUTH_ENABLED = "1";
|
|
process.env.CONVEX_DEPLOYMENT = "local:dev";
|
|
process.env.CONVEX_SITE_URL = "http://localhost:3210";
|
|
const { ctx, inserts } = makeDevPersonaCtx();
|
|
|
|
await upsertDevPersonaInternalHandler(ctx, {
|
|
persona: "abusePublisher",
|
|
});
|
|
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "users",
|
|
value: expect.objectContaining({
|
|
handle: "local-abuse",
|
|
name: "local-abuse",
|
|
displayName: "Local Abuse Test Publisher",
|
|
}),
|
|
}),
|
|
);
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "publishers",
|
|
value: expect.objectContaining({
|
|
handle: "local-abuse",
|
|
displayName: "Local Abuse Test Publisher",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("seeds a non-platform-admin user who manages an official org", async () => {
|
|
process.env.DEV_AUTH_ENABLED = "1";
|
|
process.env.CONVEX_DEPLOYMENT = "local:dev";
|
|
process.env.CONVEX_SITE_URL = "http://localhost:3210";
|
|
const { ctx, inserts } = makeDevPersonaCtx();
|
|
|
|
const userId = await upsertDevPersonaInternalHandler(ctx, {
|
|
persona: "officialOrgMember",
|
|
});
|
|
|
|
expect(userId).toBe("users:1");
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "users",
|
|
value: expect.objectContaining({
|
|
handle: "local-official-member",
|
|
displayName: "Local Official Org Member",
|
|
role: "user",
|
|
}),
|
|
}),
|
|
);
|
|
const orgInsert = inserts.find(
|
|
(entry) => entry.table === "publishers" && entry.value.handle === "local-official-org",
|
|
);
|
|
expect(orgInsert).toBeTruthy();
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "officialPublishers",
|
|
value: expect.objectContaining({
|
|
publisherId: orgInsert?.value._id,
|
|
reason: "dev-persona.official-org-member",
|
|
}),
|
|
}),
|
|
);
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "publisherMembers",
|
|
value: expect.objectContaining({
|
|
publisherId: orgInsert?.value._id,
|
|
userId,
|
|
role: "admin",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("me", () => {
|
|
afterEach(() => {
|
|
vi.mocked(getAuthUserId).mockReset();
|
|
});
|
|
|
|
it("returns null when auth resolution throws", async () => {
|
|
vi.mocked(getAuthUserId).mockRejectedValue(new Error("stale session"));
|
|
const get = vi.fn();
|
|
|
|
const result = await meHandler({ db: { get } } as never, {});
|
|
|
|
expect(result).toBeNull();
|
|
expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns null when auth resolves to an invalid user id", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:broken" as never);
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:broken") throw new Error("Table mismatch");
|
|
return null;
|
|
});
|
|
|
|
const result = await meHandler({ db: { get } } as never, {});
|
|
|
|
expect(result).toBeNull();
|
|
expect(get).toHaveBeenCalledWith("users:broken");
|
|
});
|
|
});
|
|
|
|
describe("users.getByHandle", () => {
|
|
it("normalizes the incoming handle before querying", async () => {
|
|
const unique = vi.fn(async () => ({
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "jaredforreal",
|
|
name: "jaredforreal",
|
|
displayName: "Jared",
|
|
image: undefined,
|
|
bio: undefined,
|
|
}));
|
|
|
|
const result = await getByHandleHandler(
|
|
{
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "users") throw new Error(`Unexpected table ${table}`);
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (name !== "handle") throw new Error(`Unexpected index ${name}`);
|
|
let handle = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
expect(handle).toBe("jaredforreal");
|
|
return { unique };
|
|
},
|
|
};
|
|
}),
|
|
get: vi.fn(),
|
|
},
|
|
} as never,
|
|
{ handle: " @JaredForReal " },
|
|
);
|
|
|
|
expect(unique).toHaveBeenCalledOnce();
|
|
expect(result).toMatchObject({
|
|
_id: "users:owner",
|
|
handle: "jaredforreal",
|
|
displayName: "Jared",
|
|
});
|
|
});
|
|
|
|
it("falls back to the linked user for a personal publisher handle", async () => {
|
|
const userUnique = vi.fn(async () => null);
|
|
const publisherUnique = vi.fn(async () => ({
|
|
_id: "publishers:jaredforreal",
|
|
kind: "user",
|
|
handle: "jaredforreal",
|
|
linkedUserId: "users:owner",
|
|
displayName: "Jared",
|
|
}));
|
|
const get = vi.fn(async (id: string) =>
|
|
id === "users:owner"
|
|
? {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "jared",
|
|
name: "jaredforreal",
|
|
displayName: "Jared",
|
|
image: undefined,
|
|
bio: "Profile",
|
|
}
|
|
: null,
|
|
);
|
|
|
|
const result = await getByHandleHandler(
|
|
{
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
|
return { unique: userUnique };
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
|
return { unique: publisherUnique };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}),
|
|
get,
|
|
},
|
|
} as never,
|
|
{ handle: "jaredforreal" },
|
|
);
|
|
|
|
expect(userUnique).toHaveBeenCalledOnce();
|
|
expect(publisherUnique).toHaveBeenCalledOnce();
|
|
expect(get).toHaveBeenCalledWith("users:owner");
|
|
expect(result).toMatchObject({
|
|
_id: "users:owner",
|
|
handle: "jared",
|
|
name: "jaredforreal",
|
|
displayName: "Jared",
|
|
bio: "Profile",
|
|
});
|
|
});
|
|
|
|
it("does not resolve a deleted personal publisher handle", async () => {
|
|
const userUnique = vi.fn(async () => null);
|
|
const publisherUnique = vi.fn(async () => ({
|
|
_id: "publishers:jaredforreal",
|
|
kind: "user",
|
|
handle: "jaredforreal",
|
|
linkedUserId: "users:owner",
|
|
deletedAt: 1_700_000_000_000,
|
|
displayName: "Jared",
|
|
}));
|
|
const get = vi.fn(async () => {
|
|
throw new Error("linked user should not be loaded for inactive publishers");
|
|
});
|
|
|
|
const result = await getByHandleHandler(
|
|
{
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
|
return { unique: userUnique };
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle") throw new Error(`Unexpected publishers index ${name}`);
|
|
return { unique: publisherUnique };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}),
|
|
get,
|
|
},
|
|
} as never,
|
|
{ handle: "jaredforreal" },
|
|
);
|
|
|
|
expect(userUnique).toHaveBeenCalledOnce();
|
|
expect(publisherUnique).toHaveBeenCalledOnce();
|
|
expect(get).not.toHaveBeenCalled();
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("users.getHoverStats", () => {
|
|
it("uses install aggregates from a legacy personal publisher link", async () => {
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:owner") {
|
|
return {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
name: "Owner",
|
|
email: "owner@example.com",
|
|
role: "user",
|
|
createdAt: 1,
|
|
personalPublisherId: "publishers:owner",
|
|
};
|
|
}
|
|
if (id === "publishers:owner") {
|
|
return {
|
|
_id: "publishers:owner",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
publishedSkills: 4,
|
|
totalStars: 5,
|
|
totalDownloads: 91,
|
|
totalInstalls: 37,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const result = await getHoverStatsHandler(
|
|
{
|
|
db: {
|
|
get,
|
|
query: vi.fn(() => {
|
|
throw new Error("publisher lookup should use personalPublisherId");
|
|
}),
|
|
},
|
|
},
|
|
{ userId: "users:owner" },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
publishedSkills: 4,
|
|
totalStars: 5,
|
|
totalDownloads: 91,
|
|
totalInstalls: 37,
|
|
});
|
|
});
|
|
|
|
it("uses bounded legacy owner rows when a personal publisher aggregate is not backfilled", async () => {
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:owner") {
|
|
return {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
name: "Owner",
|
|
email: "owner@example.com",
|
|
role: "user",
|
|
createdAt: 1,
|
|
personalPublisherId: "publishers:owner",
|
|
};
|
|
}
|
|
if (id === "publishers:owner") {
|
|
return {
|
|
_id: "publishers:owner",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
publishedSkills: 1,
|
|
totalStars: 2,
|
|
totalDownloads: 30,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
const takeLimits: number[] = [];
|
|
const skill = {
|
|
_id: "skills:demo",
|
|
_creationTime: 1,
|
|
ownerUserId: "users:owner",
|
|
slug: "demo",
|
|
stats: {
|
|
downloads: 0,
|
|
stars: 2,
|
|
installsCurrent: 7,
|
|
installsAllTime: 12,
|
|
comments: 0,
|
|
versions: 1,
|
|
},
|
|
statsInstallsAllTime: 12,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const pkg = {
|
|
_id: "packages:demo",
|
|
_creationTime: 1,
|
|
ownerUserId: "users:owner",
|
|
stats: { downloads: 0, installs: 8, stars: 0, versions: 1 },
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const query = vi.fn((table: string) => ({
|
|
withIndex: (indexName: string) => ({
|
|
collect: async () => [],
|
|
order: () => ({
|
|
take: async (limit: number) => {
|
|
takeLimits.push(limit);
|
|
if (table === "skills" && indexName === "by_owner_active_updated") return [skill];
|
|
if (table === "packages" && indexName === "by_owner") return [pkg];
|
|
return [];
|
|
},
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
const result = await getHoverStatsHandler({ db: { get, query } }, { userId: "users:owner" });
|
|
|
|
expect(result).toEqual({
|
|
publishedSkills: 1,
|
|
totalStars: 2,
|
|
totalDownloads: 30,
|
|
totalInstalls: 20,
|
|
});
|
|
expect(takeLimits).toHaveLength(4);
|
|
expect(takeLimits.every((limit) => limit > 0 && limit <= 200)).toBe(true);
|
|
});
|
|
|
|
it("uses installs as the download fallback for legacy hover aggregates", async () => {
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:owner") {
|
|
return {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
name: "Owner",
|
|
email: "owner@example.com",
|
|
role: "user",
|
|
createdAt: 1,
|
|
personalPublisherId: "publishers:owner",
|
|
};
|
|
}
|
|
if (id === "publishers:owner") {
|
|
return {
|
|
_id: "publishers:owner",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
publishedSkills: 4,
|
|
totalStars: 5,
|
|
totalInstalls: 37,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const result = await getHoverStatsHandler(
|
|
{
|
|
db: {
|
|
get,
|
|
query: vi.fn(() => {
|
|
throw new Error("publisher lookup should use personalPublisherId");
|
|
}),
|
|
},
|
|
},
|
|
{ userId: "users:owner" },
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
publishedSkills: 4,
|
|
totalStars: 5,
|
|
totalDownloads: 37,
|
|
totalInstalls: 37,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("users.ensurePublisherHandleInternal", () => {
|
|
it("rejects public route owner handles", async () => {
|
|
const { ctx, get, insert } = makeCtx();
|
|
get.mockResolvedValue({ _id: "users:admin", role: "admin" });
|
|
const handler = (
|
|
ensurePublisherHandleInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; handle: string; displayName?: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
await expect(
|
|
handler(ctx, {
|
|
actorUserId: "users:admin",
|
|
handle: "docs",
|
|
displayName: "Docs",
|
|
}),
|
|
).rejects.toThrow('Handle "@docs" is reserved for ClawHub routes');
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("users.syncGitHubProfileInternal", () => {
|
|
it("audits GitHub profile sync and resulting personal publisher creation", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, insert } = makeCtx();
|
|
get.mockResolvedValue({
|
|
_id: "users:other",
|
|
_creationTime: 1,
|
|
handle: "old-handle",
|
|
displayName: "old-handle",
|
|
name: "old-handle",
|
|
createdAt: 1,
|
|
});
|
|
|
|
const handler = (
|
|
syncGitHubProfileInternal as unknown as {
|
|
_handler: (ctx: unknown, args: unknown) => Promise<void>;
|
|
}
|
|
)._handler;
|
|
|
|
await handler(ctx, {
|
|
userId: "users:other",
|
|
name: "new-handle",
|
|
image: "https://avatars.githubusercontent.com/u/1",
|
|
syncedAt: 10,
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.profile.sync",
|
|
actorUserId: "users:other",
|
|
targetType: "user",
|
|
targetId: "users:other",
|
|
metadata: expect.objectContaining({
|
|
source: "github",
|
|
previous: expect.objectContaining({ handle: "old-handle" }),
|
|
next: expect.objectContaining({ handle: "new-handle" }),
|
|
}),
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.personal.create",
|
|
actorUserId: "users:other",
|
|
targetType: "publisher",
|
|
metadata: expect.objectContaining({
|
|
source: "user.profile.sync",
|
|
created: true,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps a derived handle unchanged when the new login is reserved", async () => {
|
|
const { ctx, get, patch, query } = makeCtx();
|
|
get.mockResolvedValue({
|
|
_id: "users:other",
|
|
handle: "old-handle",
|
|
displayName: "old-handle",
|
|
name: "old-handle",
|
|
});
|
|
query.mockImplementation(((table: string) => {
|
|
if (table !== "reservedHandles") throw new Error(`Unexpected table ${table}`);
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return {
|
|
order: () => ({
|
|
take: async () => [
|
|
{
|
|
_id: "reservedHandles:1",
|
|
handle: "openclaw",
|
|
rightfulOwnerUserId: "users:owner",
|
|
releasedAt: undefined,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
},
|
|
],
|
|
}),
|
|
};
|
|
},
|
|
};
|
|
}) as never);
|
|
|
|
const handler = (
|
|
syncGitHubProfileInternal as unknown as {
|
|
_handler: (ctx: unknown, args: unknown) => Promise<void>;
|
|
}
|
|
)._handler;
|
|
|
|
await handler(ctx, {
|
|
userId: "users:other",
|
|
name: "openclaw",
|
|
syncedAt: 10,
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"users:other",
|
|
expect.objectContaining({
|
|
githubProfileSyncedAt: 10,
|
|
name: "openclaw",
|
|
}),
|
|
);
|
|
expect(patch).not.toHaveBeenCalledWith(
|
|
"users:other",
|
|
expect.objectContaining({ handle: "openclaw" }),
|
|
);
|
|
});
|
|
|
|
it("keeps a derived handle unchanged when the new login belongs to an org publisher", async () => {
|
|
const { ctx, get, patch, query } = makeCtx();
|
|
get.mockResolvedValue({
|
|
_id: "users:other",
|
|
handle: "old-handle",
|
|
displayName: "old-handle",
|
|
name: "old-handle",
|
|
});
|
|
query.mockImplementation(((table: string) => {
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return { order: () => ({ take: async () => [] }) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (
|
|
name: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let handle = "";
|
|
let linkedUserId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
if (field === "linkedUserId") linkedUserId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
if (name === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
handle === "openclaw"
|
|
? {
|
|
_id: "publishers:openclaw",
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
}
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
if (name === "by_linked_user") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
linkedUserId === "users:other"
|
|
? {
|
|
_id: "publishers:old-handle",
|
|
kind: "user",
|
|
handle: "old-handle",
|
|
linkedUserId: "users:other",
|
|
displayName: "Old Handle",
|
|
}
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected publishers index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_publisher_user") {
|
|
throw new Error(`Unexpected publisherMembers index ${name}`);
|
|
}
|
|
return { unique: vi.fn(async () => null) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "packages" || table === "skills") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_owner_publisher") {
|
|
throw new Error(`Unexpected ${table} index ${name}`);
|
|
}
|
|
return { collect: vi.fn(async () => []) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}) as never);
|
|
|
|
const handler = (
|
|
syncGitHubProfileInternal as unknown as {
|
|
_handler: (ctx: unknown, args: unknown) => Promise<void>;
|
|
}
|
|
)._handler;
|
|
|
|
await handler(ctx, {
|
|
userId: "users:other",
|
|
name: "openclaw",
|
|
syncedAt: 10,
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"users:other",
|
|
expect.objectContaining({
|
|
githubProfileSyncedAt: 10,
|
|
name: "openclaw",
|
|
}),
|
|
);
|
|
expect(patch).not.toHaveBeenCalledWith(
|
|
"users:other",
|
|
expect.objectContaining({ handle: "openclaw" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("users profile audit logs", () => {
|
|
afterEach(() => {
|
|
vi.mocked(requireUser).mockReset();
|
|
});
|
|
|
|
it("audits self-service profile updates", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:self",
|
|
user: { _id: "users:self" },
|
|
} as never);
|
|
const { ctx, get, insert } = makeCtx();
|
|
get.mockResolvedValue({
|
|
_id: "users:self",
|
|
_creationTime: 1,
|
|
handle: "self",
|
|
displayName: "Old Name",
|
|
bio: "Old bio",
|
|
name: "self",
|
|
createdAt: 1,
|
|
});
|
|
|
|
await updateProfileHandler(ctx, {
|
|
displayName: "New Name",
|
|
bio: "New bio",
|
|
});
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.profile.update",
|
|
actorUserId: "users:self",
|
|
targetType: "user",
|
|
targetId: "users:self",
|
|
metadata: {
|
|
previous: { displayName: "Old Name", bio: "Old bio" },
|
|
next: { displayName: "New Name", bio: "New bio" },
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("saves profile changes when personal publisher handle sync conflicts", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:self",
|
|
user: { _id: "users:self" },
|
|
} as never);
|
|
const { ctx, get, insert, patch, query } = makeCtx();
|
|
get.mockResolvedValue({
|
|
_id: "users:self",
|
|
_creationTime: 1,
|
|
handle: "claimed",
|
|
displayName: "Old Name",
|
|
bio: "Old bio",
|
|
name: "claimed",
|
|
createdAt: 1,
|
|
});
|
|
query.mockImplementation(((table: string) => {
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name === "by_linked_user") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (name === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
_id: "publishers:claimed",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "claimed",
|
|
displayName: "Other User",
|
|
linkedUserId: "users:other",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected publishers index ${name}`);
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}) as never);
|
|
|
|
await updateProfileHandler(ctx, {
|
|
displayName: "New Name",
|
|
bio: "New bio",
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"users:self",
|
|
expect.objectContaining({
|
|
displayName: "New Name",
|
|
bio: "New bio",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({ action: "user.profile.update" }),
|
|
);
|
|
expect(insert).not.toHaveBeenCalledWith("publishers", expect.anything());
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
});
|
|
|
|
it("audits self-service account deletion", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:self",
|
|
user: { _id: "users:self" },
|
|
} as never);
|
|
const { ctx, get, insert, query } = makeCtx();
|
|
const runMutation = vi.fn();
|
|
(ctx as { runMutation?: typeof runMutation }).runMutation = runMutation;
|
|
get.mockResolvedValue({
|
|
_id: "users:self",
|
|
handle: "self",
|
|
displayName: "Self",
|
|
name: "self",
|
|
email: "self@example.com",
|
|
personalPublisherId: "publishers:self",
|
|
});
|
|
query.mockImplementation(((table: string) => {
|
|
if (table === "apiTokens") {
|
|
return {
|
|
withIndex: () => ({
|
|
collect: vi.fn(async () => []),
|
|
}),
|
|
};
|
|
}
|
|
if (table === "githubOrgMemberships") {
|
|
return {
|
|
withIndex: () => ({
|
|
collect: vi.fn(async () => []),
|
|
}),
|
|
};
|
|
}
|
|
if (table === "authAccounts" || table === "authSessions") {
|
|
return {
|
|
withIndex: () => ({
|
|
collect: vi.fn(async () => []),
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}) as never);
|
|
|
|
await deleteAccountHandler(ctx, {});
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.delete",
|
|
actorUserId: "users:self",
|
|
targetType: "user",
|
|
targetId: "users:self",
|
|
metadata: expect.objectContaining({
|
|
previous: expect.objectContaining({
|
|
handle: "self",
|
|
emailPresent: true,
|
|
personalPublisherId: "publishers:self",
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("users.purgeSelfDeletedAccountRecoveryBatchInternal", () => {
|
|
it("dry-runs eligible self-deleted account candidates with reviewable metadata", async () => {
|
|
const users = [
|
|
{
|
|
_id: "users:self-deleted",
|
|
_creationTime: 1,
|
|
deactivatedAt: 1_700_000_000_000,
|
|
purgedAt: 1_700_000_000_000,
|
|
deletedAt: undefined,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
email: undefined,
|
|
personalPublisherId: "publishers:self-deleted",
|
|
},
|
|
{
|
|
_id: "users:banned",
|
|
_creationTime: 2,
|
|
deactivatedAt: 1_700_000_000_001,
|
|
purgedAt: 1_700_000_000_001,
|
|
deletedAt: undefined,
|
|
banReason: "bulk publishing spam",
|
|
},
|
|
];
|
|
const logsByUser = new Map([
|
|
[
|
|
"users:self-deleted",
|
|
[
|
|
{
|
|
_id: "auditLogs:self-delete",
|
|
_creationTime: 3,
|
|
actorUserId: "users:self-deleted",
|
|
action: "user.delete",
|
|
targetType: "user",
|
|
targetId: "users:self-deleted",
|
|
createdAt: 1_700_000_000_000,
|
|
metadata: {
|
|
previous: {
|
|
handle: "octo",
|
|
displayName: "Octo User",
|
|
emailPresent: true,
|
|
personalPublisherId: "publishers:self-deleted",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
],
|
|
[
|
|
"users:banned",
|
|
[
|
|
{
|
|
_id: "auditLogs:ban",
|
|
actorUserId: "users:admin",
|
|
action: "user.ban",
|
|
targetType: "user",
|
|
targetId: "users:banned",
|
|
},
|
|
],
|
|
],
|
|
]);
|
|
const patch = vi.fn();
|
|
const insert = vi.fn();
|
|
const runMutation = vi.fn();
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_deactivated_purged_at") {
|
|
throw new Error(`Unexpected users index ${indexName}`);
|
|
}
|
|
return {
|
|
paginate: vi.fn(async () => ({
|
|
page: users,
|
|
isDone: true,
|
|
continueCursor: "",
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "auditLogs") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, string> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
return {
|
|
collect: vi.fn(async () => logsByUser.get(fields.targetId) ?? []),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
});
|
|
|
|
const result = (await purgeSelfDeletedAccountRecoveryBatchInternalHandler(
|
|
{
|
|
db: { query, patch, insert, delete: vi.fn(), get: vi.fn(), normalizeId: vi.fn() },
|
|
runMutation,
|
|
} as never,
|
|
{ dryRun: true, mode: "deactivated", limit: 10 },
|
|
)) as {
|
|
dryRun: boolean;
|
|
eligible: number;
|
|
purged: number;
|
|
candidates: Array<Record<string, unknown>>;
|
|
skipped: Array<Record<string, unknown>>;
|
|
};
|
|
|
|
expect(result).toMatchObject({
|
|
dryRun: true,
|
|
eligible: 1,
|
|
purged: 0,
|
|
candidates: [
|
|
{
|
|
userId: "users:self-deleted",
|
|
handle: "octo",
|
|
displayName: "Octo User",
|
|
emailPresent: true,
|
|
personalPublisherId: "publishers:self-deleted",
|
|
deactivatedAt: 1_700_000_000_000,
|
|
purgedAt: 1_700_000_000_000,
|
|
selfDeleteAuditLogId: "auditLogs:self-delete",
|
|
},
|
|
],
|
|
skipped: [{ userId: "users:banned", reason: "not_self_deleted_or_security_blocked" }],
|
|
});
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
expect(runMutation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips already-cleaned self-delete tombstones without auth locks", async () => {
|
|
const users = [
|
|
{
|
|
_id: "users:recovery-purged",
|
|
_creationTime: 1,
|
|
deactivatedAt: 1_700_000_000_000,
|
|
purgedAt: 1_700_000_000_000,
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: undefined,
|
|
email: undefined,
|
|
personalPublisherId: undefined,
|
|
},
|
|
{
|
|
_id: "users:modern-cleanup",
|
|
_creationTime: 2,
|
|
deactivatedAt: 1_700_000_000_001,
|
|
purgedAt: 1_700_000_000_001,
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: undefined,
|
|
email: undefined,
|
|
personalPublisherId: undefined,
|
|
},
|
|
];
|
|
const logsByUser = new Map([
|
|
[
|
|
"users:recovery-purged",
|
|
[
|
|
{
|
|
_id: "auditLogs:self-delete",
|
|
actorUserId: "users:recovery-purged",
|
|
action: "user.delete",
|
|
targetType: "user",
|
|
targetId: "users:recovery-purged",
|
|
metadata: { previous: { handle: "gone" } },
|
|
},
|
|
{
|
|
_id: "auditLogs:recovery-purge",
|
|
actorUserId: "users:recovery-purged",
|
|
action: "user.recovery_purge",
|
|
targetType: "user",
|
|
targetId: "users:recovery-purged",
|
|
metadata: { source: "backfill" },
|
|
},
|
|
],
|
|
],
|
|
[
|
|
"users:modern-cleanup",
|
|
[
|
|
{
|
|
_id: "auditLogs:modern-delete",
|
|
actorUserId: "users:modern-cleanup",
|
|
action: "user.delete",
|
|
targetType: "user",
|
|
targetId: "users:modern-cleanup",
|
|
metadata: { cleanup: { authAccounts: 1 } },
|
|
},
|
|
],
|
|
],
|
|
]);
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_deactivated_purged_at") {
|
|
throw new Error(`Unexpected users index ${indexName}`);
|
|
}
|
|
return {
|
|
paginate: vi.fn(async () => ({
|
|
page: users,
|
|
isDone: true,
|
|
continueCursor: "",
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "auditLogs") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, string> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
return {
|
|
collect: vi.fn(async () => logsByUser.get(fields.targetId) ?? []),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "authAccounts") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string) => ({
|
|
collect: vi.fn(async () => []),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
});
|
|
|
|
const result = (await purgeSelfDeletedAccountRecoveryBatchInternalHandler(
|
|
{
|
|
db: {
|
|
query,
|
|
patch: vi.fn(),
|
|
insert: vi.fn(),
|
|
delete: vi.fn(),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
runMutation: vi.fn(),
|
|
} as never,
|
|
{ dryRun: true, mode: "deactivated", limit: 10 },
|
|
)) as {
|
|
dryRun: boolean;
|
|
eligible: number;
|
|
candidates: Array<Record<string, unknown>>;
|
|
skipped: Array<Record<string, unknown>>;
|
|
};
|
|
|
|
expect(result).toMatchObject({
|
|
dryRun: true,
|
|
eligible: 0,
|
|
candidates: [],
|
|
skipped: [
|
|
{ userId: "users:recovery-purged", reason: "not_self_deleted_or_security_blocked" },
|
|
{ userId: "users:modern-cleanup", reason: "not_self_deleted_or_security_blocked" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("dry-runs auth-locked purged users without self-delete audit proof", async () => {
|
|
const users = [
|
|
{
|
|
_id: "users:auth-locked",
|
|
_creationTime: 1,
|
|
deactivatedAt: 1_700_000_000_000,
|
|
purgedAt: 1_700_000_000_000,
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: undefined,
|
|
email: undefined,
|
|
personalPublisherId: undefined,
|
|
},
|
|
{
|
|
_id: "users:active-publisher",
|
|
_creationTime: 2,
|
|
deactivatedAt: 1_700_000_000_001,
|
|
purgedAt: 1_700_000_000_001,
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
handle: undefined,
|
|
displayName: undefined,
|
|
name: undefined,
|
|
email: undefined,
|
|
personalPublisherId: "publishers:active",
|
|
},
|
|
];
|
|
const authAccountsByUser = new Map([
|
|
[
|
|
"users:auth-locked",
|
|
[
|
|
{
|
|
_id: "authAccounts:github",
|
|
userId: "users:auth-locked",
|
|
provider: "github",
|
|
providerAccountId: "550978",
|
|
},
|
|
],
|
|
],
|
|
[
|
|
"users:active-publisher",
|
|
[
|
|
{
|
|
_id: "authAccounts:active-publisher",
|
|
userId: "users:active-publisher",
|
|
provider: "github",
|
|
providerAccountId: "123",
|
|
},
|
|
],
|
|
],
|
|
]);
|
|
const patch = vi.fn();
|
|
const insert = vi.fn();
|
|
const runMutation = vi.fn();
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_deactivated_purged_at") {
|
|
throw new Error(`Unexpected users index ${indexName}`);
|
|
}
|
|
return {
|
|
paginate: vi.fn(async () => ({
|
|
page: users,
|
|
isDone: true,
|
|
continueCursor: "",
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "auditLogs") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string) => ({
|
|
collect: vi.fn(async () => []),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "authAccounts") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, string> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
return {
|
|
collect: vi.fn(async () => authAccountsByUser.get(fields.userId) ?? []),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string) => ({
|
|
unique: vi.fn(async () => null),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
});
|
|
|
|
const result = (await purgeSelfDeletedAccountRecoveryBatchInternalHandler(
|
|
{
|
|
db: {
|
|
query,
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
runMutation,
|
|
} as never,
|
|
{ dryRun: true, mode: "deactivated", limit: 10 },
|
|
)) as {
|
|
dryRun: boolean;
|
|
eligible: number;
|
|
purged: number;
|
|
candidates: Array<Record<string, unknown>>;
|
|
skipped: Array<Record<string, unknown>>;
|
|
};
|
|
|
|
expect(result).toMatchObject({
|
|
dryRun: true,
|
|
eligible: 2,
|
|
purged: 0,
|
|
candidates: [
|
|
{
|
|
userId: "users:auth-locked",
|
|
eligibilityReason: "auth_locked_purged_user",
|
|
authAccountCount: 1,
|
|
handle: null,
|
|
displayName: null,
|
|
emailPresent: false,
|
|
personalPublisherId: null,
|
|
deactivatedAt: 1_700_000_000_000,
|
|
purgedAt: 1_700_000_000_000,
|
|
selfDeleteAuditLogId: null,
|
|
},
|
|
{
|
|
userId: "users:active-publisher",
|
|
eligibilityReason: "auth_locked_purged_user",
|
|
authAccountCount: 1,
|
|
personalPublisherId: "publishers:active",
|
|
deactivatedAt: 1_700_000_000_001,
|
|
purgedAt: 1_700_000_000_001,
|
|
selfDeleteAuditLogId: null,
|
|
},
|
|
],
|
|
skipped: [],
|
|
});
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
expect(runMutation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("dry-runs auth-locked legacy deleted users without ban evidence", async () => {
|
|
const users = [
|
|
{
|
|
_id: "users:legacy-deleted",
|
|
_creationTime: 1,
|
|
deletedAt: 1_700_000_000_000,
|
|
deactivatedAt: undefined,
|
|
purgedAt: undefined,
|
|
banReason: undefined,
|
|
handle: "legacy-user",
|
|
displayName: "Legacy User",
|
|
name: "Legacy User",
|
|
email: "legacy@example.test",
|
|
personalPublisherId: undefined,
|
|
},
|
|
{
|
|
_id: "users:legacy-banned",
|
|
_creationTime: 2,
|
|
deletedAt: 1_700_000_000_001,
|
|
deactivatedAt: undefined,
|
|
purgedAt: undefined,
|
|
banReason: undefined,
|
|
handle: "legacy-banned",
|
|
displayName: "Legacy Banned",
|
|
name: "Legacy Banned",
|
|
email: "legacy-banned@example.test",
|
|
personalPublisherId: undefined,
|
|
},
|
|
];
|
|
const logsByUser = new Map([
|
|
[
|
|
"users:legacy-banned",
|
|
[
|
|
{
|
|
_id: "auditLogs:ban",
|
|
actorUserId: "users:admin",
|
|
action: "user.ban",
|
|
targetType: "user",
|
|
targetId: "users:legacy-banned",
|
|
},
|
|
],
|
|
],
|
|
]);
|
|
const authAccountsByUser = new Map([
|
|
[
|
|
"users:legacy-deleted",
|
|
[
|
|
{
|
|
_id: "authAccounts:legacy",
|
|
userId: "users:legacy-deleted",
|
|
provider: "github",
|
|
providerAccountId: "1175050",
|
|
},
|
|
],
|
|
],
|
|
[
|
|
"users:legacy-banned",
|
|
[
|
|
{
|
|
_id: "authAccounts:banned",
|
|
userId: "users:legacy-banned",
|
|
provider: "github",
|
|
providerAccountId: "2",
|
|
},
|
|
],
|
|
],
|
|
]);
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_ban_reason_deleted_at") {
|
|
throw new Error(`Unexpected users index ${indexName}`);
|
|
}
|
|
return {
|
|
paginate: vi.fn(async () => ({
|
|
page: users,
|
|
isDone: true,
|
|
continueCursor: "",
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "auditLogs") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, string> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
return {
|
|
collect: vi.fn(async () => logsByUser.get(fields.targetId) ?? []),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "authAccounts") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, string> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
return {
|
|
collect: vi.fn(async () => authAccountsByUser.get(fields.userId) ?? []),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
});
|
|
|
|
const result = (await purgeSelfDeletedAccountRecoveryBatchInternalHandler(
|
|
{
|
|
db: {
|
|
query,
|
|
patch: vi.fn(),
|
|
insert: vi.fn(),
|
|
delete: vi.fn(),
|
|
get: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
runMutation: vi.fn(),
|
|
} as never,
|
|
{ dryRun: true, mode: "legacyDeleted", limit: 10 },
|
|
)) as {
|
|
dryRun: boolean;
|
|
eligible: number;
|
|
purged: number;
|
|
candidates: Array<Record<string, unknown>>;
|
|
skipped: Array<Record<string, unknown>>;
|
|
};
|
|
|
|
expect(result).toMatchObject({
|
|
dryRun: true,
|
|
eligible: 1,
|
|
purged: 0,
|
|
candidates: [
|
|
{
|
|
userId: "users:legacy-deleted",
|
|
eligibilityReason: "auth_locked_legacy_deleted_user",
|
|
authAccountCount: 1,
|
|
handle: "legacy-user",
|
|
displayName: "Legacy User",
|
|
emailPresent: true,
|
|
deletedAt: 1_700_000_000_000,
|
|
selfDeleteAuditLogId: null,
|
|
},
|
|
],
|
|
skipped: [{ userId: "users:legacy-banned", reason: "not_self_deleted_or_security_blocked" }],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("users.list", () => {
|
|
afterEach(() => {
|
|
vi.mocked(requireUser).mockReset();
|
|
});
|
|
|
|
it("uses take(limit) without full collect when search is empty", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [
|
|
{ _id: "users:1", _creationTime: 3, handle: "alice", role: "user" },
|
|
{ _id: "users:2", _creationTime: 2, handle: "bob", role: "user" },
|
|
{ _id: "users:3", _creationTime: 1, handle: "carol", role: "user" },
|
|
];
|
|
const { ctx, take, collect } = makeListCtx(users);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
const result = (await listHandler(ctx, { limit: 2 })) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(take).toHaveBeenCalledWith(2);
|
|
expect(collect).not.toHaveBeenCalled();
|
|
expect(result.total).toBe(2);
|
|
expect(result.items).toHaveLength(2);
|
|
});
|
|
|
|
it("uses bounded scan for search instead of full collect", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [
|
|
{ _id: "users:1", _creationTime: 3, handle: "alice", role: "user" },
|
|
{ _id: "users:2", _creationTime: 2, handle: "bob", role: "user" },
|
|
{ _id: "users:3", _creationTime: 1, handle: "carol", role: "user" },
|
|
];
|
|
const { ctx, take, collect } = makeListCtx(users);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
const result = (await listHandler(ctx, { limit: 50, search: "ali" })) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(take).toHaveBeenCalledWith(500);
|
|
expect(collect).not.toHaveBeenCalled();
|
|
expect(result.total).toBe(1);
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.items[0]?.handle).toBe("alice");
|
|
});
|
|
|
|
it("includes an exact older handle match outside the bounded scan", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [
|
|
...Array.from({ length: 500 }, (_value, index) => ({
|
|
_id: `users:recent-${index}`,
|
|
_creationTime: 10_000 - index,
|
|
handle: `recent-${index}`,
|
|
role: "user",
|
|
})),
|
|
{ _id: "users:older", _creationTime: 1, handle: "alice", role: "user" },
|
|
];
|
|
const { ctx, take, collect } = makeListCtx(users);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
const result = (await listHandler(ctx, { limit: 50, search: "alice" })) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(take).toHaveBeenCalledWith(500);
|
|
expect(collect).not.toHaveBeenCalled();
|
|
expect(result.total).toBe(1);
|
|
expect(result.items[0]?._id).toBe("users:older");
|
|
});
|
|
|
|
it("includes an exact personal publisher handle match without a full collect", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" }];
|
|
const { ctx, take, collect } = makeListCtx(users, {
|
|
publishersByHandle: {
|
|
lmlukef: {
|
|
_id: "publishers:lmlukef",
|
|
kind: "user",
|
|
handle: "lmlukef",
|
|
linkedUserId: "users:owner",
|
|
},
|
|
},
|
|
usersById: {
|
|
"users:owner": {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "luke",
|
|
name: "different-gh-login",
|
|
displayName: "Luke",
|
|
role: "user",
|
|
},
|
|
},
|
|
});
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
const result = (await listHandler(ctx, { limit: 50, search: "lmLukeF" })) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(take).toHaveBeenCalledWith(500);
|
|
expect(collect).not.toHaveBeenCalled();
|
|
expect(result.total).toBe(1);
|
|
expect(result.items[0]).toMatchObject({
|
|
_id: "users:owner",
|
|
handle: "luke",
|
|
displayName: "Luke",
|
|
});
|
|
});
|
|
|
|
it("clamps large limit and search scan size", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = Array.from({ length: 8_000 }, (_value, index) => ({
|
|
_id: `users:${index}`,
|
|
_creationTime: 10_000 - index,
|
|
handle: `user-${index}`,
|
|
role: "user",
|
|
}));
|
|
const { ctx, take } = makeListCtx(users);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
await listHandler(ctx, { limit: 999, search: "user" });
|
|
|
|
expect(take).toHaveBeenCalledWith(2_000);
|
|
});
|
|
|
|
it("handles malformed legacy user fields without throwing", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [
|
|
{
|
|
_id: "users:legacy",
|
|
_creationTime: 99,
|
|
handle: 123,
|
|
name: { broken: true },
|
|
displayName: null,
|
|
email: ["legacy@example.com"],
|
|
role: "user",
|
|
},
|
|
{
|
|
_id: "users:2",
|
|
_creationTime: 98,
|
|
handle: "carol",
|
|
role: "user",
|
|
},
|
|
];
|
|
const { ctx } = makeListCtx(users);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
await expect(listHandler(ctx, { limit: 50, search: "car" })).resolves.toMatchObject({
|
|
total: 1,
|
|
items: [{ _id: "users:2" }],
|
|
});
|
|
});
|
|
|
|
it("includes an exact publisher-handle match even when the linked user is banned", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [
|
|
{
|
|
_id: "users:1",
|
|
_creationTime: 3,
|
|
handle: "different-login",
|
|
displayName: "ClawGrid",
|
|
deletedAt: 123,
|
|
role: "user",
|
|
},
|
|
{ _id: "users:2", _creationTime: 2, handle: "alice", role: "user" },
|
|
];
|
|
const { ctx } = makeListCtx(users, {
|
|
publishersByHandle: {
|
|
clawgrid: {
|
|
_id: "publishers:clawgrid",
|
|
handle: "clawgrid",
|
|
kind: "user",
|
|
linkedUserId: "users:1",
|
|
},
|
|
},
|
|
usersById: {
|
|
"users:1": users[0]!,
|
|
},
|
|
});
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
await expect(listHandler(ctx, { limit: 10, search: "clawgrid" })).resolves.toMatchObject({
|
|
total: 1,
|
|
items: [{ _id: "users:1", deletedAt: 123 }],
|
|
});
|
|
});
|
|
|
|
it("treats whitespace search as empty search", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [
|
|
{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" },
|
|
{ _id: "users:2", _creationTime: 1, handle: "bob", role: "user" },
|
|
];
|
|
const { ctx, take, collect } = makeListCtx(users);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
const result = (await listHandler(ctx, { limit: 50, search: " " })) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(take).toHaveBeenCalledWith(50);
|
|
expect(collect).not.toHaveBeenCalled();
|
|
expect(result.total).toBe(2);
|
|
});
|
|
|
|
it("clamps non-positive limit to one", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:admin",
|
|
user: { _id: "users:admin", role: "admin" },
|
|
} as never);
|
|
const users = [
|
|
{ _id: "users:1", _creationTime: 2, handle: "alice", role: "user" },
|
|
{ _id: "users:2", _creationTime: 1, handle: "bob", role: "user" },
|
|
];
|
|
const { ctx, take } = makeListCtx(users);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
const result = (await listHandler(ctx, { limit: 0 })) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(take).toHaveBeenCalledWith(1);
|
|
expect(result.total).toBe(1);
|
|
expect(result.items).toHaveLength(1);
|
|
});
|
|
|
|
it("rejects non-admin actors", async () => {
|
|
vi.mocked(requireUser).mockResolvedValue({
|
|
userId: "users:basic",
|
|
user: { _id: "users:basic", role: "user" },
|
|
} as never);
|
|
const { ctx } = makeListCtx([]);
|
|
const listHandler = (
|
|
list as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
|
|
await expect(listHandler(ctx, { limit: 10 })).rejects.toThrow("Forbidden");
|
|
});
|
|
});
|
|
|
|
describe("users.searchInternal", () => {
|
|
it("rejects missing actor", async () => {
|
|
const { ctx, get } = makeListCtx([]);
|
|
const handler = (
|
|
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
get.mockResolvedValue(null);
|
|
|
|
await expect(handler(ctx, { actorUserId: "users:missing" })).rejects.toThrow("Unauthorized");
|
|
});
|
|
|
|
it("uses bounded scan and returns mapped fields", async () => {
|
|
const users = [
|
|
{ _id: "users:1", _creationTime: 2, handle: "alice", name: "alice", role: "user" },
|
|
{ _id: "users:2", _creationTime: 1, handle: "bob", name: "bob", role: "moderator" },
|
|
];
|
|
const { ctx, take, collect, get } = makeListCtx(users);
|
|
const handler = (
|
|
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
get.mockResolvedValue({ _id: "users:admin", role: "admin" });
|
|
|
|
const result = (await handler(ctx, {
|
|
actorUserId: "users:admin",
|
|
query: "ali",
|
|
limit: 25,
|
|
})) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(take).toHaveBeenCalledWith(500);
|
|
expect(collect).not.toHaveBeenCalled();
|
|
expect(result.total).toBe(1);
|
|
expect(result.items).toEqual([
|
|
{
|
|
userId: "users:1",
|
|
handle: "alice",
|
|
displayName: null,
|
|
name: "alice",
|
|
role: "user",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("includes an exact personal publisher handle match in admin search", async () => {
|
|
const users = [
|
|
{ _id: "users:1", _creationTime: 2, handle: "alice", name: "alice", role: "user" },
|
|
];
|
|
const { ctx, get } = makeListCtx(users, {
|
|
publishersByHandle: {
|
|
lmlukef: {
|
|
_id: "publishers:lmlukef",
|
|
kind: "user",
|
|
handle: "lmlukef",
|
|
linkedUserId: "users:owner",
|
|
},
|
|
},
|
|
usersById: {
|
|
"users:owner": {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "luke",
|
|
name: "different-gh-login",
|
|
displayName: "Luke",
|
|
role: "user",
|
|
},
|
|
},
|
|
});
|
|
const handler = (
|
|
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:admin") return { _id: "users:admin", role: "admin" };
|
|
if (id === "users:owner") {
|
|
return {
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "luke",
|
|
name: "different-gh-login",
|
|
displayName: "Luke",
|
|
role: "user",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const result = (await handler(ctx, {
|
|
actorUserId: "users:admin",
|
|
query: "lmLukeF",
|
|
limit: 25,
|
|
})) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(result.total).toBe(1);
|
|
expect(result.items[0]).toEqual({
|
|
userId: "users:owner",
|
|
handle: "luke",
|
|
displayName: "Luke",
|
|
name: "different-gh-login",
|
|
role: "user",
|
|
});
|
|
});
|
|
|
|
it("does not double-count total when the fallback user already matched off-page", async () => {
|
|
const users = [
|
|
{
|
|
_id: "users:1",
|
|
_creationTime: 3,
|
|
handle: "lmquery-top",
|
|
name: "lmquery-top",
|
|
role: "user",
|
|
},
|
|
{
|
|
_id: "users:2",
|
|
_creationTime: 2,
|
|
handle: "lmquery-mid",
|
|
name: "lmquery-mid",
|
|
role: "user",
|
|
},
|
|
{
|
|
_id: "users:owner",
|
|
_creationTime: 1,
|
|
handle: "owner-lmquery",
|
|
name: "owner-lmquery",
|
|
displayName: "Owner Lmquery",
|
|
role: "user",
|
|
},
|
|
];
|
|
const { ctx, get } = makeListCtx(users, {
|
|
publishersByHandle: {
|
|
lmquery: {
|
|
_id: "publishers:lmquery",
|
|
kind: "user",
|
|
handle: "lmquery",
|
|
linkedUserId: "users:owner",
|
|
},
|
|
},
|
|
usersById: {
|
|
"users:owner": users[2] as Record<string, unknown>,
|
|
},
|
|
});
|
|
const handler = (
|
|
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:admin") return { _id: "users:admin", role: "admin" };
|
|
if (id === "users:owner") return users[2] as Record<string, unknown>;
|
|
return null;
|
|
});
|
|
|
|
const result = (await handler(ctx, {
|
|
actorUserId: "users:admin",
|
|
query: "lmquery",
|
|
limit: 2,
|
|
})) as {
|
|
items: Array<Record<string, unknown>>;
|
|
total: number;
|
|
};
|
|
|
|
expect(result.total).toBe(3);
|
|
expect(result.items.map((item) => item.userId)).toEqual(["users:owner", "users:1"]);
|
|
});
|
|
|
|
it("rejects deactivated actors", async () => {
|
|
const { ctx, get } = makeListCtx([]);
|
|
const handler = (
|
|
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
get.mockResolvedValue({ _id: "users:ghost", role: "admin", deactivatedAt: Date.now() });
|
|
|
|
await expect(handler(ctx, { actorUserId: "users:ghost" })).rejects.toThrow("Unauthorized");
|
|
});
|
|
|
|
it("rejects non-admin actors", async () => {
|
|
const { ctx, get } = makeListCtx([]);
|
|
const handler = (
|
|
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
get.mockResolvedValue({ _id: "users:mod", role: "moderator" });
|
|
|
|
await expect(handler(ctx, { actorUserId: "users:mod", query: "a" })).rejects.toThrow(
|
|
"Forbidden",
|
|
);
|
|
});
|
|
|
|
it("still caps empty-query listing and uses non-search path", async () => {
|
|
const users = Array.from({ length: 400 }, (_value, index) => ({
|
|
_id: `users:${index}`,
|
|
_creationTime: 1_000 - index,
|
|
handle: `user-${index}`,
|
|
role: "user",
|
|
}));
|
|
const { ctx, take, collect, get } = makeListCtx(users);
|
|
const handler = (
|
|
searchInternal as unknown as { _handler: (ctx: unknown, args: unknown) => Promise<unknown> }
|
|
)._handler;
|
|
get.mockResolvedValue({ _id: "users:admin", role: "admin" });
|
|
|
|
const result = (await handler(ctx, {
|
|
actorUserId: "users:admin",
|
|
limit: 999,
|
|
query: " ",
|
|
})) as { items: Array<Record<string, unknown>>; total: number };
|
|
|
|
expect(take).toHaveBeenCalledWith(200);
|
|
expect(collect).not.toHaveBeenCalled();
|
|
expect(result.total).toBe(200);
|
|
expect(result.items).toHaveLength(200);
|
|
});
|
|
});
|
|
|
|
describe("users.banUserInternal", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("does not query retired skill comments during ban", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, insert, runMutation } = makeBanCtx();
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "moderator" };
|
|
if (id === "users:target") return { _id: "users:target", role: "user" };
|
|
return null;
|
|
});
|
|
|
|
runMutation
|
|
.mockResolvedValueOnce({ hiddenCount: 2, scheduled: false })
|
|
.mockResolvedValueOnce({ deletedCount: 0, revokedTokenCount: 0, scheduled: false })
|
|
.mockResolvedValueOnce(undefined);
|
|
|
|
const handler = (
|
|
banUserInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; targetUserId: string; reason?: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = (await handler(ctx, {
|
|
actorUserId: "users:actor",
|
|
targetUserId: "users:target",
|
|
reason: "spam",
|
|
})) as {
|
|
ok: boolean;
|
|
alreadyBanned: boolean;
|
|
deletedSkillComments: number;
|
|
};
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
alreadyBanned: false,
|
|
deletedSkillComments: 0,
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.ban",
|
|
metadata: expect.objectContaining({
|
|
deletedSkillComments: 0,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("schedules a public-safe ban notification email when the target has email", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, runMutation, runAfter } = makeBanCtx();
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "moderator" };
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
runMutation
|
|
.mockResolvedValueOnce({ hiddenCount: 2, scheduled: false })
|
|
.mockResolvedValueOnce({ deletedCount: 0, revokedTokenCount: 0, scheduled: false })
|
|
.mockResolvedValueOnce(undefined);
|
|
|
|
const handler = (
|
|
banUserInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; targetUserId: string; reason?: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
await handler(ctx, {
|
|
actorUserId: "users:actor",
|
|
targetUserId: "users:target",
|
|
reason: "rate limit triggered by automated CLI publishing",
|
|
});
|
|
|
|
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
|
userId: "users:target",
|
|
bannedAt: 1_700_000_000_000,
|
|
to: "target@example.com",
|
|
handle: "target-user",
|
|
source: "manual",
|
|
reason: "rate limit triggered by automated CLI publishing",
|
|
hiddenArtifacts: 2,
|
|
});
|
|
});
|
|
|
|
it("does not query retired skill comments when re-banning a deleted user", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, query, runMutation } = makeBanCtx();
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "moderator" };
|
|
if (id === "users:target")
|
|
return { _id: "users:target", role: "user", deletedAt: 1_600_000_000_000 };
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
banUserInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; targetUserId: string; reason?: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = (await handler(ctx, {
|
|
actorUserId: "users:actor",
|
|
targetUserId: "users:target",
|
|
reason: "cleanup",
|
|
})) as {
|
|
ok: boolean;
|
|
alreadyBanned: boolean;
|
|
deletedSkillComments: number;
|
|
deletedSkills: number;
|
|
};
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
alreadyBanned: true,
|
|
deletedSkills: 0,
|
|
deletedSkillComments: 0,
|
|
});
|
|
expect(runMutation).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
ownerUserId: "users:target",
|
|
bannedAt: 1_600_000_000_000,
|
|
deletedBy: "users:actor",
|
|
deletedByRole: "moderator",
|
|
}),
|
|
);
|
|
expect(query).not.toHaveBeenCalledWith("comments");
|
|
});
|
|
});
|
|
|
|
describe("users.autobanMalwareAuthorInternal", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("schedules a malicious skill notification with trigger context", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, runMutation, runAfter } = makeBanCtx();
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
runMutation
|
|
.mockResolvedValueOnce({ hiddenCount: 1, scheduled: false })
|
|
.mockResolvedValueOnce({ deletedCount: 0, revokedTokenCount: 0, scheduled: false })
|
|
.mockResolvedValueOnce(undefined);
|
|
|
|
const handler = (
|
|
autobanMalwareAuthorInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
ownerUserId: string;
|
|
sha256hash?: string;
|
|
slug: string;
|
|
trigger?: string;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
await handler(ctx, {
|
|
ownerUserId: "users:target",
|
|
sha256hash: "abc123",
|
|
slug: "gingiris-launch",
|
|
trigger: "malicious.llm_malicious",
|
|
});
|
|
|
|
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
|
userId: "users:target",
|
|
bannedAt: 1_700_000_000_000,
|
|
to: "target@example.com",
|
|
handle: "target-user",
|
|
source: "autoban",
|
|
reason: "malicious.llm_malicious",
|
|
trigger: "malicious.llm_malicious",
|
|
artifact: { kind: "skill", name: "gingiris-launch" },
|
|
hiddenArtifacts: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("users.autobanPublisherAbuseOwnerInternal", () => {
|
|
function publisherAbuseAutobanNomination(fields: Record<string, unknown> = {}) {
|
|
return {
|
|
_id: "publisherAbuseReviewNominations:candidate",
|
|
ownerKey: "publisher:publishers:candidate",
|
|
ownerPublisherId: "publishers:candidate",
|
|
ownerUserId: "users:target",
|
|
latestScoreId: "publisherAbuseScores:candidate",
|
|
label: "potential_ban_candidate",
|
|
status: "pending",
|
|
...fields,
|
|
};
|
|
}
|
|
|
|
const publisherAbuseAutobanArgs = {
|
|
ownerUserId: "users:target",
|
|
nominationId: "publisherAbuseReviewNominations:candidate",
|
|
scoreId: "publisherAbuseScores:candidate",
|
|
reason: "publisher_abuse: potential ban candidate",
|
|
};
|
|
|
|
function publisherAbuseAutobanHandler() {
|
|
return (
|
|
autobanPublisherAbuseOwnerInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
ownerUserId: string;
|
|
nominationId: string;
|
|
scoreId: string;
|
|
reason: string;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
}
|
|
|
|
function expectNoPublisherAbuseBanSideEffects({
|
|
patch,
|
|
insert,
|
|
runMutation,
|
|
runAfter,
|
|
}: Pick<ReturnType<typeof makeBanCtx>, "patch" | "insert" | "runMutation" | "runAfter">) {
|
|
expect(runMutation).not.toHaveBeenCalled();
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
expect(runAfter).not.toHaveBeenCalled();
|
|
}
|
|
|
|
function mockPublisherAbuseAutobanDocs(
|
|
get: ReturnType<typeof makeBanCtx>["get"],
|
|
{
|
|
targetUser = {},
|
|
nomination = {},
|
|
publisher = {},
|
|
extra,
|
|
}: {
|
|
targetUser?: Record<string, unknown>;
|
|
nomination?: Record<string, unknown>;
|
|
publisher?: Record<string, unknown>;
|
|
extra?: (id: string) => Record<string, unknown> | null | undefined;
|
|
} = {},
|
|
) {
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
...targetUser,
|
|
};
|
|
}
|
|
if (id === "publisherAbuseReviewNominations:candidate") {
|
|
return publisherAbuseAutobanNomination(nomination);
|
|
}
|
|
if (id === "publishers:candidate") {
|
|
return {
|
|
_id: "publishers:candidate",
|
|
linkedUserId: "users:target",
|
|
...publisher,
|
|
};
|
|
}
|
|
return extra?.(id) ?? null;
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("bans publisher abuse candidates with appeal email and audit context", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, patch, insert, query, runMutation, runAfter } = makeBanCtx();
|
|
|
|
mockPublisherAbuseAutobanDocs(get);
|
|
runMutation
|
|
.mockResolvedValueOnce({ hiddenCount: 3, scheduled: false })
|
|
.mockResolvedValueOnce({ deletedCount: 2, revokedTokenCount: 1, scheduled: false })
|
|
.mockResolvedValueOnce(undefined);
|
|
|
|
await expect(
|
|
publisherAbuseAutobanHandler()(ctx, publisherAbuseAutobanArgs),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
alreadyBanned: false,
|
|
deletedSkills: 3,
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:target", {
|
|
deletedAt: 1_700_000_000_000,
|
|
role: "user",
|
|
updatedAt: 1_700_000_000_000,
|
|
banReason: "publisher_abuse: potential ban candidate",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherAbuseReviewNominations:candidate",
|
|
expect.objectContaining({
|
|
status: "banned",
|
|
reviewedAt: 1_700_000_000_000,
|
|
notes: "publisher_abuse: potential ban candidate",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
actorUserId: "users:target",
|
|
action: "user.autoban.publisher_abuse",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: expect.objectContaining({
|
|
nominationId: "publisherAbuseReviewNominations:candidate",
|
|
scoreId: "publisherAbuseScores:candidate",
|
|
reason: "publisher_abuse: potential ban candidate",
|
|
hiddenSkills: 3,
|
|
deletedPackages: 2,
|
|
}),
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"publisherAbuseReviewEvents",
|
|
expect.objectContaining({
|
|
nominationId: "publisherAbuseReviewNominations:candidate",
|
|
ownerKey: "publisher:publishers:candidate",
|
|
scoreId: "publisherAbuseScores:candidate",
|
|
eventType: "triage_status_changed",
|
|
previousStatus: "pending",
|
|
nextStatus: "banned",
|
|
notes: "publisher_abuse: potential ban candidate",
|
|
}),
|
|
);
|
|
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
|
userId: "users:target",
|
|
bannedAt: 1_700_000_000_000,
|
|
to: "target@example.com",
|
|
handle: "target-user",
|
|
source: "autoban",
|
|
reason: "publisher_abuse: potential ban candidate",
|
|
trigger: "publisher_abuse",
|
|
hiddenArtifacts: 5,
|
|
});
|
|
expect(query).toHaveBeenCalledWith("apiTokens");
|
|
expect(patch).toHaveBeenCalledWith("apiTokens:1", { revokedAt: 1_700_000_000_000 });
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "protected staff accounts",
|
|
ctxOptions: {},
|
|
docs: { targetUser: { role: "moderator" } },
|
|
expected: { ok: false, reason: "protected_role" },
|
|
},
|
|
{
|
|
name: "publisher abuse nominations relinked to another user",
|
|
ctxOptions: {},
|
|
docs: { publisher: { linkedUserId: "users:new-owner" } },
|
|
expected: { ok: false, reason: "nomination_not_actionable" },
|
|
},
|
|
{
|
|
name: "publishers that became official before final autoban",
|
|
ctxOptions: { officialPublisherIds: ["publishers:candidate"] },
|
|
docs: { publisher: { kind: "user" } },
|
|
expected: { ok: false, reason: "nomination_not_actionable" },
|
|
},
|
|
{
|
|
name: "org publishers that gained a staff manager before final autoban",
|
|
ctxOptions: {
|
|
publisherMembers: [
|
|
{
|
|
_id: "publisherMembers:staff-owner",
|
|
publisherId: "publishers:candidate",
|
|
userId: "users:staff-owner",
|
|
role: "owner",
|
|
},
|
|
],
|
|
},
|
|
docs: {
|
|
publisher: { kind: "org" },
|
|
extra: (id: string) =>
|
|
id === "users:staff-owner"
|
|
? {
|
|
_id: "users:staff-owner",
|
|
role: "admin",
|
|
handle: "staff-owner",
|
|
email: "staff@example.com",
|
|
}
|
|
: undefined,
|
|
},
|
|
expected: { ok: false, reason: "nomination_not_actionable" },
|
|
},
|
|
])("does not ban $name", async ({ ctxOptions, docs, expected }) => {
|
|
const mocks = makeBanCtx(ctxOptions);
|
|
mockPublisherAbuseAutobanDocs(mocks.get, docs);
|
|
|
|
await expect(
|
|
publisherAbuseAutobanHandler()(mocks.ctx, publisherAbuseAutobanArgs),
|
|
).resolves.toEqual(expected);
|
|
|
|
expectNoPublisherAbuseBanSideEffects(mocks);
|
|
});
|
|
});
|
|
|
|
describe("users.recordMaliciousArtifactFindingInternal", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("emails artifact-level remediation without banning on the first malicious finding", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, insert, runMutation, runAfter } = makeBanCtx();
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
recordMaliciousArtifactFindingInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
ownerUserId: string;
|
|
artifactKind: "skill" | "plugin";
|
|
artifactName: string;
|
|
version?: string;
|
|
trigger?: string;
|
|
sha256hash?: string;
|
|
findingSummary?: string;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
ownerUserId: "users:target",
|
|
artifactKind: "skill",
|
|
artifactName: "demo-skill",
|
|
version: "1.0.0",
|
|
trigger: "malicious.llm_malicious",
|
|
sha256hash: "abc123",
|
|
findingSummary: "Attempts to exfiltrate credentials.",
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, escalated: false });
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
actorUserId: "users:target",
|
|
action: "user.malicious_artifact.finding",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: expect.objectContaining({
|
|
artifactKind: "skill",
|
|
artifactName: "demo-skill",
|
|
version: "1.0.0",
|
|
trigger: "malicious.llm_malicious",
|
|
sha256hash: "abc123",
|
|
findingSummary: "Attempts to exfiltrate credentials.",
|
|
}),
|
|
}),
|
|
);
|
|
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
|
userId: "users:target",
|
|
findingAt: 1_700_000_000_000,
|
|
to: "target@example.com",
|
|
handle: "target-user",
|
|
artifact: { kind: "skill", name: "demo-skill" },
|
|
version: "1.0.0",
|
|
trigger: "malicious.llm_malicious",
|
|
findingSummary: "Attempts to exfiltrate credentials.",
|
|
});
|
|
expect(runMutation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("escalates to account ban on the third malicious attempt for one artifact", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, runMutation, runAfter } = makeBanCtx({
|
|
auditLogs: [
|
|
{
|
|
action: "user.malicious_artifact.finding",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
|
|
createdAt: 1_699_999_000_000,
|
|
},
|
|
{
|
|
action: "user.malicious_artifact.finding",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
|
|
createdAt: 1_699_998_000_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
runMutation.mockResolvedValue({ ok: true, alreadyBanned: false });
|
|
|
|
const handler = (
|
|
recordMaliciousArtifactFindingInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
ownerUserId: string;
|
|
artifactKind: "skill" | "plugin";
|
|
artifactName: string;
|
|
version?: string;
|
|
trigger?: string;
|
|
sha256hash?: string;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
ownerUserId: "users:target",
|
|
artifactKind: "skill",
|
|
artifactName: "demo-skill",
|
|
version: "1.0.2",
|
|
trigger: "malicious.llm_malicious",
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, escalated: true, reason: "attempt_threshold" });
|
|
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
|
ownerUserId: "users:target",
|
|
slug: "demo-skill",
|
|
trigger: "malicious.llm_malicious",
|
|
artifactKind: "skill",
|
|
artifactName: "demo-skill",
|
|
});
|
|
expect(runAfter).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not escalate on the second malicious attempt for one artifact", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, runMutation } = makeBanCtx({
|
|
auditLogs: [
|
|
{
|
|
action: "user.malicious_artifact.finding",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: { artifactKind: "skill", artifactName: "demo-skill" },
|
|
createdAt: 1_699_999_000_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
recordMaliciousArtifactFindingInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
ownerUserId: string;
|
|
artifactKind: "skill" | "plugin";
|
|
artifactName: string;
|
|
version?: string;
|
|
trigger?: string;
|
|
sha256hash?: string;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
ownerUserId: "users:target",
|
|
artifactKind: "skill",
|
|
artifactName: "demo-skill",
|
|
version: "1.0.1",
|
|
trigger: "malicious.llm_malicious",
|
|
});
|
|
|
|
expect(result).toMatchObject({ ok: true, escalated: false });
|
|
expect(runMutation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("escalates to account ban on the second distinct malicious artifact", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, runMutation, runAfter } = makeBanCtx({
|
|
auditLogs: [
|
|
{
|
|
action: "user.malicious_artifact.finding",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: { artifactKind: "skill", artifactName: "first-skill" },
|
|
createdAt: 1_699_999_000_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
runMutation.mockResolvedValue({ ok: true, alreadyBanned: false });
|
|
|
|
const handler = (
|
|
recordMaliciousArtifactFindingInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
ownerUserId: string;
|
|
artifactKind: "skill" | "plugin";
|
|
artifactName: string;
|
|
version?: string;
|
|
trigger?: string;
|
|
sha256hash?: string;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
ownerUserId: "users:target",
|
|
artifactKind: "plugin",
|
|
artifactName: "@scope/second-plugin",
|
|
version: "1.0.0",
|
|
trigger: "malicious.llm_malicious",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
escalated: true,
|
|
reason: "distinct_artifact_threshold",
|
|
});
|
|
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
|
ownerUserId: "users:target",
|
|
slug: "@scope/second-plugin",
|
|
trigger: "malicious.llm_malicious",
|
|
artifactKind: "plugin",
|
|
artifactName: "@scope/second-plugin",
|
|
});
|
|
expect(runAfter).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("users.unbanUserForBanAppealServiceInternal", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("restores ban-hidden skills and packages for accepted appeals", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_100_000);
|
|
const { ctx, get, patch, insert, runMutation, runAfter } = makeBanCtx({
|
|
auditLogs: [
|
|
{
|
|
_id: "auditLogs:ban",
|
|
action: "user.ban",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
createdAt: 1_700_000_000_000,
|
|
},
|
|
],
|
|
});
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
deletedAt: 1_700_000_000_000,
|
|
deactivatedAt: undefined,
|
|
banReason: "malware auto-ban",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
runMutation
|
|
.mockResolvedValueOnce({ restoredCount: 5, scheduled: false })
|
|
.mockResolvedValueOnce({ restoredCount: 2, scheduled: true });
|
|
|
|
const handler = (
|
|
unbanUserForBanAppealServiceInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { targetUserId: string; reason?: string; reviewerDiscordId: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
targetUserId: "users:target",
|
|
reason: "appeal accepted",
|
|
reviewerDiscordId: "discord-reviewer-1",
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith("users:target", {
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
role: "user",
|
|
updatedAt: 1_700_000_100_000,
|
|
});
|
|
expect(runMutation).toHaveBeenNthCalledWith(1, expect.anything(), {
|
|
ownerUserId: "users:target",
|
|
bannedAt: 1_700_000_000_000,
|
|
cursor: undefined,
|
|
});
|
|
expect(runMutation).toHaveBeenNthCalledWith(2, expect.anything(), {
|
|
ownerUserId: "users:target",
|
|
bannedAt: 1_700_000_000_000,
|
|
cursor: undefined,
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.unban",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: expect.objectContaining({
|
|
reason: "appeal accepted",
|
|
restoredSkills: 5,
|
|
restoredPackages: 2,
|
|
scheduledPackages: true,
|
|
source: "ban_appeal.service",
|
|
reviewerDiscordId: "discord-reviewer-1",
|
|
}),
|
|
}),
|
|
);
|
|
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
|
userId: "users:target",
|
|
restoredAt: 1_700_000_100_000,
|
|
to: "target@example.com",
|
|
handle: "target-user",
|
|
restoredListings: undefined,
|
|
skillsRestored: 5,
|
|
packagesRestored: undefined,
|
|
});
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
alreadyUnbanned: false,
|
|
restoredSkills: 5,
|
|
scheduledSkills: false,
|
|
restoredPackages: 2,
|
|
scheduledPackages: true,
|
|
});
|
|
});
|
|
|
|
it("restores publisher-abuse autobans for accepted appeals", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_100_000);
|
|
const { ctx, get, patch, insert, runMutation, runAfter } = makeBanCtx({
|
|
auditLogs: [
|
|
{
|
|
_id: "auditLogs:publisher-abuse-ban",
|
|
action: "user.autoban.publisher_abuse",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
createdAt: 1_700_000_000_000,
|
|
},
|
|
],
|
|
});
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "target-user",
|
|
email: "target@example.com",
|
|
deletedAt: 1_700_000_000_000,
|
|
deactivatedAt: undefined,
|
|
banReason: "publisher_abuse: potential ban candidate",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
runMutation
|
|
.mockResolvedValueOnce({ restoredCount: 3, scheduled: false })
|
|
.mockResolvedValueOnce({ restoredCount: 0, scheduled: false });
|
|
|
|
const handler = (
|
|
unbanUserForBanAppealServiceInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { targetUserId: string; reason?: string; reviewerDiscordId: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
await expect(
|
|
handler(ctx, {
|
|
targetUserId: "users:target",
|
|
reason: "publisher abuse appeal accepted",
|
|
reviewerDiscordId: "discord-reviewer-1",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
alreadyUnbanned: false,
|
|
restoredSkills: 3,
|
|
});
|
|
expect(patch).toHaveBeenCalledWith("users:target", {
|
|
deletedAt: undefined,
|
|
banReason: undefined,
|
|
role: "user",
|
|
updatedAt: 1_700_000_100_000,
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.unban",
|
|
targetId: "users:target",
|
|
metadata: expect.objectContaining({
|
|
source: "ban_appeal.service",
|
|
reason: "publisher abuse appeal accepted",
|
|
}),
|
|
}),
|
|
);
|
|
expect(runAfter).toHaveBeenCalledWith(0, expect.anything(), {
|
|
userId: "users:target",
|
|
restoredAt: 1_700_000_100_000,
|
|
to: "target@example.com",
|
|
handle: "target-user",
|
|
restoredListings: undefined,
|
|
skillsRestored: 3,
|
|
packagesRestored: 0,
|
|
});
|
|
});
|
|
|
|
it("rejects deleted accounts without a matching ban audit", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_100_000);
|
|
const { ctx, get, patch, insert, runMutation } = makeBanCtx();
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
deletedAt: 1_700_000_000_000,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
unbanUserForBanAppealServiceInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { targetUserId: string; reason?: string; reviewerDiscordId: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
await expect(
|
|
handler(ctx, {
|
|
targetUserId: "users:target",
|
|
reason: "appeal accepted",
|
|
reviewerDiscordId: "discord-reviewer-1",
|
|
}),
|
|
).rejects.toThrow("Cannot unban account without a matching ban record");
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(runMutation).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("users.reclassifyBanInternal", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("dry-runs a ban reason reclassification without patching or auditing", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, patch, insert } = makeBanCtx();
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "admin" };
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "hanxueyuan",
|
|
deletedAt: 1_600_000_000_000,
|
|
banReason: "malware auto-ban",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
reclassifyBanInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
actorUserId: string;
|
|
targetUserId: string;
|
|
reason: string;
|
|
dryRun?: boolean;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
actorUserId: "users:actor",
|
|
targetUserId: "users:target",
|
|
reason: "bulk publishing spam",
|
|
dryRun: true,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
dryRun: true,
|
|
userId: "users:target",
|
|
handle: "hanxueyuan",
|
|
previousReason: "malware auto-ban",
|
|
nextReason: "bulk publishing spam",
|
|
changed: true,
|
|
});
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies a ban reason reclassification with an audit log", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, patch, insert } = makeBanCtx();
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "admin" };
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "hanxueyuan",
|
|
deletedAt: 1_600_000_000_000,
|
|
banReason: "malware auto-ban",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
reclassifyBanInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
actorUserId: string;
|
|
targetUserId: string;
|
|
reason: string;
|
|
dryRun?: boolean;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
actorUserId: "users:actor",
|
|
targetUserId: "users:target",
|
|
reason: "bulk publishing spam",
|
|
dryRun: false,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
dryRun: false,
|
|
userId: "users:target",
|
|
previousReason: "malware auto-ban",
|
|
nextReason: "bulk publishing spam",
|
|
changed: true,
|
|
});
|
|
expect(patch).toHaveBeenCalledWith("users:target", {
|
|
banReason: "bulk publishing spam",
|
|
updatedAt: 1_700_000_000_000,
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.ban.reclassify",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: {
|
|
previousReason: "malware auto-ban",
|
|
nextReason: "bulk publishing spam",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects unbanned users", async () => {
|
|
const { ctx, get } = makeBanCtx();
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:actor") return { _id: "users:actor", role: "admin" };
|
|
if (id === "users:target") return { _id: "users:target", role: "user" };
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
reclassifyBanInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; targetUserId: string; reason: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
await expect(
|
|
handler(ctx, {
|
|
actorUserId: "users:actor",
|
|
targetUserId: "users:target",
|
|
reason: "bulk publishing spam",
|
|
}),
|
|
).rejects.toThrow(/not currently banned/i);
|
|
});
|
|
});
|
|
|
|
describe("users.placeUserUnderModerationInternal", () => {
|
|
it("marks the user and hides owned skills", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const patch = vi.fn();
|
|
const insert = vi.fn();
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "badguy",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
requiresModerationAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
const runMutation = vi.fn(async () => ({ hiddenCount: 3, scheduled: false }));
|
|
|
|
const handler = (
|
|
placeUserUnderModerationInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { ownerUserId: string; slug: string; reason: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = (await handler(
|
|
{
|
|
db: {
|
|
get,
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
query: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
runMutation,
|
|
} as never,
|
|
{
|
|
ownerUserId: "users:target",
|
|
slug: "bad-skill",
|
|
reason: "malicious.install_terminal_payload",
|
|
},
|
|
)) as {
|
|
ok: boolean;
|
|
alreadyModerated: boolean;
|
|
hiddenSkills: number;
|
|
};
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
alreadyModerated: false,
|
|
hiddenSkills: 3,
|
|
scheduledSkills: false,
|
|
});
|
|
expect(patch).toHaveBeenCalledWith("users:target", {
|
|
requiresModerationAt: 1_700_000_000_000,
|
|
requiresModerationReason:
|
|
"Auto-held for moderation after malicious upload (malicious.install_terminal_payload)",
|
|
updatedAt: 1_700_000_000_000,
|
|
});
|
|
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
|
ownerUserId: "users:target",
|
|
hiddenAt: 1_700_000_000_000,
|
|
cursor: undefined,
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "user.moderation.auto",
|
|
metadata: expect.objectContaining({
|
|
slug: "bad-skill",
|
|
reason: "malicious.install_terminal_payload",
|
|
hiddenSkills: 3,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("users.reserveHandleInternal", () => {
|
|
it("reserves a handle for the rightful owner", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const { ctx, get, insert, query } = makeCtx();
|
|
get.mockImplementation(async (id: string) => {
|
|
if (id === "users:admin") return { _id: "users:admin", role: "admin" };
|
|
if (id === "users:owner") return { _id: "users:owner", role: "user" };
|
|
return null;
|
|
});
|
|
query.mockImplementation(((table: string) => {
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "by_handle_active_updatedAt") {
|
|
throw new Error(`Unexpected reservedHandles index ${name}`);
|
|
}
|
|
return { order: () => ({ take: async () => [] }) };
|
|
},
|
|
};
|
|
}
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: (name: string) => {
|
|
if (name !== "handle") throw new Error(`Unexpected users index ${name}`);
|
|
return { unique: vi.fn(async () => null) };
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`Unexpected table ${table}`);
|
|
}) as never);
|
|
|
|
const handler = (
|
|
reserveHandleInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: {
|
|
actorUserId: string;
|
|
handle: string;
|
|
rightfulOwnerUserId: string;
|
|
reason?: string;
|
|
},
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = await handler(ctx, {
|
|
actorUserId: "users:admin",
|
|
handle: "OpenClaw",
|
|
rightfulOwnerUserId: "users:owner",
|
|
reason: "official org",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
handle: "openclaw",
|
|
rightfulOwnerUserId: "users:owner",
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"reservedHandles",
|
|
expect.objectContaining({
|
|
handle: "openclaw",
|
|
rightfulOwnerUserId: "users:owner",
|
|
reason: "official org",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "handle.reserve",
|
|
targetId: "openclaw",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("users.liftModerationHoldInternal", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("clears the moderation hold and restores skills", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_100_000);
|
|
const patch = vi.fn();
|
|
const insert = vi.fn();
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:admin") {
|
|
return {
|
|
_id: "users:admin",
|
|
role: "admin",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
handle: "security-researcher",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
requiresModerationAt: 1_700_000_000_000,
|
|
requiresModerationReason:
|
|
"Auto-held for moderation after malicious upload (malicious.install_terminal_payload)",
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
const runMutation = vi.fn(async () => ({ restoredCount: 5, scheduled: false }));
|
|
|
|
const handler = (
|
|
liftModerationHoldInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; targetUserId: string; reason?: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = (await handler(
|
|
{
|
|
db: {
|
|
get,
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
query: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
runMutation,
|
|
} as never,
|
|
{
|
|
actorUserId: "users:admin",
|
|
targetUserId: "users:target",
|
|
reason: "False positive from security tool scanning",
|
|
},
|
|
)) as {
|
|
ok: boolean;
|
|
alreadyCleared: boolean;
|
|
restoredSkills: number;
|
|
scheduledSkills: boolean;
|
|
};
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
alreadyCleared: false,
|
|
restoredSkills: 5,
|
|
scheduledSkills: false,
|
|
});
|
|
|
|
// Verify hold was cleared
|
|
expect(patch).toHaveBeenCalledWith("users:target", {
|
|
requiresModerationAt: undefined,
|
|
requiresModerationReason: undefined,
|
|
updatedAt: 1_700_000_100_000,
|
|
});
|
|
|
|
// Verify skill restoration was triggered with holdPlacedAt for race-condition safety
|
|
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
|
ownerUserId: "users:target",
|
|
holdPlacedAt: 1_700_000_000_000,
|
|
cursor: undefined,
|
|
});
|
|
|
|
// Verify audit log
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
actorUserId: "users:admin",
|
|
action: "user.moderation.lift",
|
|
targetType: "user",
|
|
targetId: "users:target",
|
|
metadata: expect.objectContaining({
|
|
reason: "False positive from security tool scanning",
|
|
holdPlacedAt: 1_700_000_000_000,
|
|
restoredSkills: 5,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns early when user has no moderation hold", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_100_000);
|
|
const patch = vi.fn();
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:admin") {
|
|
return {
|
|
_id: "users:admin",
|
|
role: "admin",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
if (id === "users:target") {
|
|
return {
|
|
_id: "users:target",
|
|
role: "user",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
requiresModerationAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
liftModerationHoldInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; targetUserId: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
const result = (await handler(
|
|
{
|
|
db: {
|
|
get,
|
|
patch,
|
|
insert: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
query: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
runMutation: vi.fn(),
|
|
} as never,
|
|
{
|
|
actorUserId: "users:admin",
|
|
targetUserId: "users:target",
|
|
},
|
|
)) as { ok: boolean; alreadyCleared: boolean };
|
|
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
alreadyCleared: true,
|
|
restoredSkills: 0,
|
|
scheduledSkills: false,
|
|
});
|
|
|
|
// Verify no patches were made
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when actor is not admin", async () => {
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:mod") {
|
|
return {
|
|
_id: "users:mod",
|
|
role: "moderator",
|
|
deletedAt: undefined,
|
|
deactivatedAt: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const handler = (
|
|
liftModerationHoldInternal as unknown as {
|
|
_handler: (
|
|
ctx: unknown,
|
|
args: { actorUserId: string; targetUserId: string },
|
|
) => Promise<unknown>;
|
|
}
|
|
)._handler;
|
|
|
|
await expect(
|
|
handler(
|
|
{
|
|
db: {
|
|
get,
|
|
patch: vi.fn(),
|
|
insert: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
query: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
runMutation: vi.fn(),
|
|
} as never,
|
|
{ actorUserId: "users:mod", targetUserId: "users:target" },
|
|
),
|
|
).rejects.toThrow();
|
|
});
|
|
});
|