mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
9910 lines
314 KiB
TypeScript
9910 lines
314 KiB
TypeScript
import { getAuthUserId } from "@convex-dev/auth/server";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
assertCanManageOwnedResource,
|
|
ensurePersonalPublisherForUser,
|
|
requirePublisherRole,
|
|
} from "./lib/publishers";
|
|
import {
|
|
addMember,
|
|
acceptMemberInvite,
|
|
listInvitesForPublisher,
|
|
listMyInvites,
|
|
listPublicPage,
|
|
listPublic,
|
|
listMine,
|
|
getDeletionInventory,
|
|
getMyProfileHandle,
|
|
getHomeOfficialCreatorSummaries,
|
|
getHomeOfficialCreatorSummariesPageInternal,
|
|
getHomePublisherSummaries,
|
|
getProfileByHandle,
|
|
createMemberInvite,
|
|
declineMemberInvite,
|
|
getOgMetaByHandle,
|
|
listMembers,
|
|
listPublishedPage,
|
|
listStarredPage,
|
|
revokeMemberInvite,
|
|
getPublishedDisplayManifest,
|
|
migrateLegacyPublisherHandleToOrgInternal,
|
|
ensureOrgPublisherHandleInternal,
|
|
removeOrgPublisherMemberInternal,
|
|
recoverPersonalPublisherInternal,
|
|
createOrg,
|
|
createImageUpload,
|
|
deleteOrg,
|
|
reclaimDeletedOrgHandleInternal,
|
|
removeMember,
|
|
addOfficialPublisherInternal,
|
|
createOrgPublisherForUserInternal,
|
|
deleteSoleOwnerOrgsForAccountDeletionInternal,
|
|
resolvePublishTargetForUserInternal,
|
|
setTrustedPublisherInternal,
|
|
updateProfile,
|
|
updateOrgPublisherProfileInternal,
|
|
} from "./publishers";
|
|
|
|
vi.mock("@convex-dev/auth/server", () => ({
|
|
getAuthUserId: vi.fn(),
|
|
authTables: {},
|
|
}));
|
|
|
|
type WrappedHandler<TArgs, TResult = unknown> = {
|
|
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
|
|
};
|
|
|
|
const addMemberHandler = (
|
|
addMember as unknown as WrappedHandler<{
|
|
publisherId: string;
|
|
userHandle: string;
|
|
role: "owner" | "admin" | "publisher";
|
|
}>
|
|
)._handler;
|
|
|
|
const createMemberInviteHandler = (
|
|
createMemberInvite as unknown as WrappedHandler<
|
|
{
|
|
publisherId: string;
|
|
userHandle: string;
|
|
role: "owner" | "admin" | "publisher";
|
|
},
|
|
{ ok: true; inviteId: string }
|
|
>
|
|
)._handler;
|
|
|
|
const revokeMemberInviteHandler = (
|
|
revokeMemberInvite as unknown as WrappedHandler<{ inviteId: string }, { ok: true }>
|
|
)._handler;
|
|
|
|
const declineMemberInviteHandler = (
|
|
declineMemberInvite as unknown as WrappedHandler<{ inviteId: string }, { ok: true }>
|
|
)._handler;
|
|
|
|
const acceptMemberInviteHandler = (
|
|
acceptMemberInvite as unknown as WrappedHandler<{ inviteId: string }, { ok: true }>
|
|
)._handler;
|
|
|
|
const listInvitesForPublisherHandler = (
|
|
listInvitesForPublisher as unknown as WrappedHandler<
|
|
{ publisherId: string },
|
|
Array<{ _id: string; targetHandle: string }>
|
|
>
|
|
)._handler;
|
|
|
|
const listMyInvitesHandler = (
|
|
listMyInvites as unknown as WrappedHandler<Record<string, never>, Array<{ _id: string }>>
|
|
)._handler;
|
|
|
|
const removeMemberHandler = (
|
|
removeMember as unknown as WrappedHandler<{ publisherId: string; userId: string }>
|
|
)._handler;
|
|
|
|
const migrateLegacyPublisherHandleToOrgInternalHandler = (
|
|
migrateLegacyPublisherHandleToOrgInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
handle: string;
|
|
fallbackUserHandle?: string;
|
|
displayName?: string;
|
|
},
|
|
{
|
|
ok: true;
|
|
handle: string;
|
|
orgPublisherId: string;
|
|
legacyUserId: string;
|
|
fallbackUserHandle: string;
|
|
personalPublisherId: string | null;
|
|
convertedExistingPublisher: boolean;
|
|
packagesMigrated: number;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const ensureOrgPublisherHandleInternalHandler = (
|
|
ensureOrgPublisherHandleInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
handle: string;
|
|
displayName?: string;
|
|
memberHandle?: string;
|
|
memberRole?: "owner" | "admin" | "publisher";
|
|
},
|
|
{
|
|
ok: true;
|
|
publisherId: string;
|
|
handle: string;
|
|
created: boolean;
|
|
member?: { userId: string; handle: string; role: "owner" | "admin" | "publisher" };
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const updateOrgPublisherProfileInternalHandler = (
|
|
updateOrgPublisherProfileInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
handle: string;
|
|
bio?: string;
|
|
imageStorageId?: string;
|
|
reason: string;
|
|
},
|
|
{
|
|
ok: true;
|
|
publisherId: string;
|
|
handle: string;
|
|
bio: string | null;
|
|
image: string | null;
|
|
bioUpdated: boolean;
|
|
logoUpdated: boolean;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const removeOrgPublisherMemberInternalHandler = (
|
|
removeOrgPublisherMemberInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
handle: string;
|
|
memberHandle: string;
|
|
},
|
|
{
|
|
ok: true;
|
|
publisherId: string;
|
|
handle: string;
|
|
removed: boolean;
|
|
member: { userId: string; handle: string; role: "owner" | "admin" | "publisher" };
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const addOfficialPublisherInternalHandler = (
|
|
addOfficialPublisherInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
handle: string;
|
|
reason: string;
|
|
},
|
|
{
|
|
ok: true;
|
|
added: boolean;
|
|
publisherId: string;
|
|
handle: string;
|
|
officialPublisherId: string;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const recoverPersonalPublisherInternalHandler = (
|
|
recoverPersonalPublisherInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
publisherHandle: string;
|
|
previousGitHubProviderAccountId: string;
|
|
nextGitHubProviderAccountId: string;
|
|
nextUserHandle?: string;
|
|
retiredUserHandle?: string;
|
|
reason: string;
|
|
confirmIdentityVerified: boolean;
|
|
dryRun?: boolean;
|
|
},
|
|
{
|
|
ok: true;
|
|
dryRun: boolean;
|
|
recovered: boolean;
|
|
publisherId: string;
|
|
handle: string;
|
|
previousUser: { userId: string; handle: string | null; nextHandle: string | null };
|
|
nextUser: { userId: string; handle: string | null; nextHandle: string };
|
|
retiredPersonalPublisher: {
|
|
publisherId: string;
|
|
handle: string;
|
|
skills: number;
|
|
packages: number;
|
|
githubSources: number;
|
|
} | null;
|
|
resourceOwnerMigration: {
|
|
limitPerTable: number;
|
|
skills: number;
|
|
skillSlugAliases: number;
|
|
packages: number;
|
|
packageInspectorWarnings: number;
|
|
githubSourcesChecked: number;
|
|
handleReservations: number;
|
|
};
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const listMineHandler = (
|
|
listMine as unknown as WrappedHandler<{ includePublishedItems?: boolean }, Array<unknown>>
|
|
)._handler;
|
|
|
|
const getDeletionInventoryHandler = (
|
|
getDeletionInventory as unknown as WrappedHandler<
|
|
{ publisherId?: string },
|
|
Array<{
|
|
handle: string;
|
|
publishedItems: Array<{ displayName: string }>;
|
|
}>
|
|
>
|
|
)._handler;
|
|
|
|
const getMyProfileHandleHandler = (
|
|
getMyProfileHandle as unknown as WrappedHandler<Record<string, never>, string | null>
|
|
)._handler;
|
|
|
|
const listPublicHandler = (
|
|
listPublic as unknown as WrappedHandler<
|
|
{ limit?: number; kind?: "user" | "org" },
|
|
{
|
|
items: Array<{
|
|
handle: string;
|
|
kind: "user" | "org";
|
|
stats: { downloads: number; installs: number };
|
|
publishedItems?: Array<{ displayName: string }>;
|
|
}>;
|
|
total: number;
|
|
counts: { all: number; individuals: number; organizations: number };
|
|
limit: number;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const listPublicPageHandler = (
|
|
listPublicPage as unknown as WrappedHandler<
|
|
{
|
|
kind?: "user" | "org";
|
|
official?: boolean;
|
|
query?: string;
|
|
paginationOpts: { cursor: string | null; numItems: number };
|
|
},
|
|
{
|
|
page: Array<{
|
|
handle: string;
|
|
displayName?: string;
|
|
kind: "user" | "org";
|
|
official?: boolean;
|
|
stats: { downloads: number; installs: number };
|
|
publishedItems: Array<{ displayName: string; installs: number; downloads: number }>;
|
|
}>;
|
|
counts: { all: number; individuals: number; organizations: number };
|
|
globalCounts: { all: number; individuals: number; organizations: number };
|
|
continueCursor: string;
|
|
isDone: boolean;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const listPublishedPageHandler = (
|
|
listPublishedPage as unknown as WrappedHandler<
|
|
{
|
|
handle: string;
|
|
kind?: "skill" | "plugin";
|
|
sort?: "installs" | "recent" | "downloads";
|
|
paginationOpts: { cursor: string | null; numItems: number };
|
|
},
|
|
{
|
|
page: Array<{
|
|
displayName: string;
|
|
href: string;
|
|
kind: "skill" | "plugin";
|
|
downloads: number;
|
|
}>;
|
|
continueCursor: string;
|
|
isDone: boolean;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const listStarredPageHandler = (
|
|
listStarredPage as unknown as WrappedHandler<
|
|
{
|
|
handle: string;
|
|
paginationOpts: { cursor: string | null; numItems: number };
|
|
},
|
|
{
|
|
page: Array<{ displayName: string; href: string }>;
|
|
continueCursor: string;
|
|
isDone: boolean;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const listMembersHandler = (
|
|
listMembers as unknown as WrappedHandler<
|
|
{ publisherHandle: string },
|
|
{
|
|
publisher: unknown;
|
|
members: Array<unknown>;
|
|
} | null
|
|
>
|
|
)._handler;
|
|
|
|
const getProfileByHandleHandler = (
|
|
getProfileByHandle as unknown as WrappedHandler<{ handle: string }>
|
|
)._handler;
|
|
|
|
const getHomePublisherSummariesHandler = (
|
|
getHomePublisherSummaries as unknown as WrappedHandler<{ handles: string[] }>
|
|
)._handler;
|
|
|
|
const getHomeOfficialCreatorSummariesHandler = (
|
|
getHomeOfficialCreatorSummaries as unknown as WrappedHandler<{ limit?: number }>
|
|
)._handler;
|
|
|
|
const getHomeOfficialCreatorSummariesPageInternalHandler = (
|
|
getHomeOfficialCreatorSummariesPageInternal as unknown as WrappedHandler<{
|
|
cursor: string | null;
|
|
}>
|
|
)._handler;
|
|
|
|
const getOgMetaByHandleHandler = (
|
|
getOgMetaByHandle as unknown as WrappedHandler<
|
|
{ handle: string },
|
|
{
|
|
displayName?: string | null;
|
|
affiliations?: Array<{
|
|
publisher?: {
|
|
handle?: string | null;
|
|
displayName?: string | null;
|
|
image?: string | null;
|
|
} | null;
|
|
role?: string;
|
|
}>;
|
|
stats: { downloads: number; installs: number; stars: number };
|
|
} | null
|
|
>
|
|
)._handler;
|
|
|
|
const getPublishedDisplayManifestHandler = (
|
|
getPublishedDisplayManifest as unknown as WrappedHandler<
|
|
{
|
|
handle: string;
|
|
kind?: "skill" | "plugin";
|
|
sort?: "installs" | "recent" | "downloads";
|
|
},
|
|
{
|
|
mode: "grouped";
|
|
sourceRepos: string[];
|
|
sections: Array<{
|
|
title: string;
|
|
sourceRepo: string | null;
|
|
items: Array<{ displayName: string }>;
|
|
}>;
|
|
} | null
|
|
>
|
|
)._handler;
|
|
|
|
const updateProfileHandler = (
|
|
updateProfile as unknown as WrappedHandler<{
|
|
publisherId: string;
|
|
displayName: string;
|
|
bio?: string;
|
|
image?: string;
|
|
imageStorageId?: string;
|
|
imageUploadTicket?: string;
|
|
githubOrgId?: string | null;
|
|
}>
|
|
)._handler;
|
|
|
|
const createImageUploadHandler = (
|
|
createImageUpload as unknown as WrappedHandler<{ publisherId: string }>
|
|
)._handler;
|
|
|
|
const setTrustedPublisherInternalHandler = (
|
|
setTrustedPublisherInternal as unknown as WrappedHandler<{
|
|
actorUserId: string;
|
|
publisherId: string;
|
|
trustedPublisher: boolean;
|
|
}>
|
|
)._handler;
|
|
|
|
const createOrgPublisherForUserInternalHandler = (
|
|
createOrgPublisherForUserInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
handle: string;
|
|
displayName?: string;
|
|
},
|
|
{
|
|
ok: true;
|
|
publisherId: string;
|
|
handle: string;
|
|
created: true;
|
|
trusted: false;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const createOrgHandler = (
|
|
createOrg as unknown as WrappedHandler<
|
|
{
|
|
handle: string;
|
|
displayName: string;
|
|
bio?: string;
|
|
},
|
|
{
|
|
publisher: { handle: string; bio?: string };
|
|
role: "owner";
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const deleteOrgHandler = (
|
|
deleteOrg as unknown as WrappedHandler<
|
|
{
|
|
publisherId: string;
|
|
},
|
|
{
|
|
ok: true;
|
|
publisherId: string;
|
|
handle: string;
|
|
hiddenSkills: number;
|
|
deletedPackages: number;
|
|
revokedPackageTokens: number;
|
|
scheduled: boolean;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const deleteSoleOwnerOrgsForAccountDeletionInternalHandler = (
|
|
deleteSoleOwnerOrgsForAccountDeletionInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
deletedAt: number;
|
|
},
|
|
{
|
|
ok: true;
|
|
deletedOrgs: number;
|
|
hiddenSkills: number;
|
|
deletedPackages: number;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const reclaimDeletedOrgHandleInternalHandler = (
|
|
reclaimDeletedOrgHandleInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
handle: string;
|
|
reason: string;
|
|
dryRun?: boolean;
|
|
confirmationToken?: string;
|
|
},
|
|
{
|
|
ok: true;
|
|
publisherId: string;
|
|
handle: string;
|
|
dryRun: boolean;
|
|
hardDeleted: boolean;
|
|
activeSkills: number;
|
|
activePackages: number;
|
|
memberCount: number;
|
|
githubSources: number;
|
|
githubSourceContents: number;
|
|
officialPublisher: boolean;
|
|
confirmationToken: string;
|
|
}
|
|
>
|
|
)._handler;
|
|
|
|
const resolvePublishTargetForUserInternalHandler = (
|
|
resolvePublishTargetForUserInternal as unknown as WrappedHandler<
|
|
{
|
|
actorUserId: string;
|
|
ownerHandle?: string;
|
|
minimumRole?: "owner" | "admin" | "publisher";
|
|
},
|
|
{
|
|
publisherId: string;
|
|
handle: string;
|
|
kind: "user" | "org";
|
|
linkedUserId?: string;
|
|
} | null
|
|
>
|
|
)._handler;
|
|
|
|
function indexedRows(rows: unknown[]) {
|
|
const paginate = vi.fn(
|
|
async ({ cursor, numItems }: { cursor: string | null; numItems: number }) => {
|
|
const offset = cursor ? Number(cursor) : 0;
|
|
const page = rows.slice(offset, offset + numItems);
|
|
const nextOffset = offset + page.length;
|
|
const isDone = nextOffset >= rows.length;
|
|
return {
|
|
page,
|
|
isDone,
|
|
continueCursor: isDone ? "" : String(nextOffset),
|
|
};
|
|
},
|
|
);
|
|
return {
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const row of rows) yield row;
|
|
},
|
|
collect: vi.fn(async () => rows),
|
|
first: vi.fn(async (): Promise<unknown> => null),
|
|
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
|
paginate,
|
|
order: vi.fn(() => ({
|
|
collect: vi.fn(async () => rows),
|
|
take: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
|
paginate,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function makePublicPublisherVisibilityCtx(options?: {
|
|
linkedUser?: Record<string, unknown> | null;
|
|
legacyPersonalPublisher?: boolean;
|
|
}) {
|
|
const legacyPersonalPublisher = options?.legacyPersonalPublisher ?? false;
|
|
const publisher = {
|
|
_id: "publishers:proof-banned-builder",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "proof-banned-builder",
|
|
displayName: "Proof Banned Builder",
|
|
linkedUserId: legacyPersonalPublisher ? undefined : "users:proof-banned-builder",
|
|
trustedPublisher: false,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 1,
|
|
totalDownloads: 4,
|
|
totalStars: 2,
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
};
|
|
const linkedUser =
|
|
options && "linkedUser" in options
|
|
? options.linkedUser
|
|
: {
|
|
_id: "users:proof-banned-builder",
|
|
_creationTime: 1,
|
|
handle: "proof-banned-builder",
|
|
displayName: "Proof Banned Builder",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
};
|
|
const githubSource = {
|
|
_id: "githubSkillSources:proof-banned-builder",
|
|
repo: "proof-banned-builder/skills",
|
|
ownerPublisherId: "publishers:proof-banned-builder",
|
|
displayManifestStatus: "ok",
|
|
displayManifest: {
|
|
groupings: [{ title: "Skills", skills: ["demo"] }],
|
|
},
|
|
};
|
|
const skill = {
|
|
_id: "skills:demo",
|
|
ownerPublisherId: "publishers:proof-banned-builder",
|
|
softDeletedAt: undefined,
|
|
slug: "demo",
|
|
displayName: "Demo Skill",
|
|
summary: "Demo summary",
|
|
icon: null,
|
|
installKind: "github",
|
|
githubSourceId: "githubSkillSources:proof-banned-builder",
|
|
githubPath: "skills/demo",
|
|
stats: {
|
|
downloads: 4,
|
|
downloadsAllTime: 4,
|
|
installs: 1,
|
|
installsAllTime: 1,
|
|
stars: 2,
|
|
},
|
|
updatedAt: 2,
|
|
};
|
|
const memberships = [
|
|
{
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:proof-banned-builder",
|
|
userId: "users:proof-banned-builder",
|
|
role: "owner",
|
|
},
|
|
];
|
|
const query = vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "proof-banned-builder" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_linked_user") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
fields.linkedUserId === "users:proof-banned-builder" ? publisher : null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows(fields.ownerPublisherId === publisher._id ? [skill] : []);
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows(fields.ownerPublisherId === publisher._id ? [skill] : []);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "stars" && indexName === "by_user") {
|
|
return indexedRows(
|
|
fields.userId === "users:proof-banned-builder"
|
|
? [{ _id: "stars:demo", userId: "users:proof-banned-builder", skillId: "skills:demo" }]
|
|
: [],
|
|
);
|
|
}
|
|
if (table === "publisherMembers" && indexName === "by_publisher") {
|
|
return indexedRows(fields.publisherId === publisher._id ? memberships : []);
|
|
}
|
|
if (table === "publisherMembers" && indexName === "by_user") {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "githubSkillSources" && indexName === "by_owner_publisher") {
|
|
return indexedRows(fields.ownerPublisherId === publisher._id ? [githubSource] : []);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
}));
|
|
|
|
return {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:proof-banned-builder") return linkedUser;
|
|
if (id === "publishers:proof-banned-builder") return publisher;
|
|
if (id === "skills:demo") return skill;
|
|
return null;
|
|
}),
|
|
query,
|
|
},
|
|
};
|
|
}
|
|
|
|
function emptyOfficialPublishersQuery() {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_publisher") {
|
|
throw new Error(`unexpected officialPublishers index ${indexName}`);
|
|
}
|
|
return { unique: vi.fn(async () => null) };
|
|
}),
|
|
};
|
|
}
|
|
|
|
function emptyOwnedResourcesQuery() {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
collect: vi.fn(async () => []),
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => []),
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function emptyPublisherInvitesQuery() {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_publisher_status_expires") {
|
|
throw new Error(`unexpected publisherInvites index ${indexName}`);
|
|
}
|
|
return { collect: vi.fn(async () => []) };
|
|
}),
|
|
};
|
|
}
|
|
|
|
function makeResolvePublishTargetCtx(options: {
|
|
targetPublisher: Record<string, unknown>;
|
|
targetMembership?: Record<string, unknown> | null;
|
|
}) {
|
|
const actor = {
|
|
_id: "users:vincent",
|
|
handle: "vincent",
|
|
name: "Vincent",
|
|
displayName: "Vincent",
|
|
image: null,
|
|
trustedPublisher: false,
|
|
personalPublisherId: "publishers:vincent",
|
|
};
|
|
const actorPersonalPublisher = {
|
|
_id: "publishers:vincent",
|
|
kind: "user",
|
|
handle: "vincent",
|
|
displayName: "Vincent",
|
|
linkedUserId: "users:vincent",
|
|
};
|
|
const actorOwnerMembership = {
|
|
_id: "publisherMembers:vincent-owner",
|
|
publisherId: "publishers:vincent",
|
|
userId: "users:vincent",
|
|
role: "owner",
|
|
};
|
|
const queryValues = (
|
|
builder: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
const values = new Map<string, unknown>();
|
|
const q = {
|
|
eq(field: string, value: unknown) {
|
|
values.set(field, value);
|
|
return q;
|
|
},
|
|
};
|
|
builder(q);
|
|
return values;
|
|
};
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string, builder) => {
|
|
const values = queryValues(builder);
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (values.get("linkedUserId") === "users:vincent") return actorPersonalPublisher;
|
|
if (values.get("handle") === "vincent") return actorPersonalPublisher;
|
|
if (values.get("handle") === options.targetPublisher.handle) {
|
|
return options.targetPublisher;
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((_indexName: string, builder) => {
|
|
const values = queryValues(builder);
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (
|
|
values.get("publisherId") === "publishers:vincent" &&
|
|
values.get("userId") === "users:vincent"
|
|
) {
|
|
return actorOwnerMembership;
|
|
}
|
|
if (
|
|
values.get("publisherId") === options.targetPublisher._id &&
|
|
values.get("userId") === "users:vincent"
|
|
) {
|
|
return options.targetMembership ?? null;
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
return {
|
|
db: {
|
|
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
|
|
const id = maybeId ?? tableOrId;
|
|
if (id === "users:vincent") return actor;
|
|
if (id === "publishers:vincent") return actorPersonalPublisher;
|
|
if (id === options.targetPublisher._id) return options.targetPublisher;
|
|
return null;
|
|
}),
|
|
query,
|
|
patch: vi.fn(),
|
|
insert: vi.fn(async () => "auditLogs:resolve"),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)),
|
|
system: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeHomeSummaryPublisher(handle: string, overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
_id: `publishers:${handle}`,
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle,
|
|
displayName: handle,
|
|
publishedSkills: 2,
|
|
publishedPackages: 1,
|
|
totalInstalls: 3,
|
|
totalDownloads: 4,
|
|
totalStars: 5,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeHomePublisherSummariesCtx(
|
|
publishers: Array<ReturnType<typeof makeHomeSummaryPublisher>>,
|
|
users: Record<string, Record<string, unknown> | null> = {},
|
|
) {
|
|
const publishersByHandle = new Map(publishers.map((publisher) => [publisher.handle, publisher]));
|
|
const publisherReads: string[] = [];
|
|
const query = vi.fn((table: string) => {
|
|
if (table !== "publishers") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
buildQuery: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
if (indexName !== "by_handle") throw new Error(`unexpected index ${indexName}`);
|
|
let handle: string | undefined;
|
|
const q = {
|
|
eq(field: string, value: unknown) {
|
|
if (field === "handle") handle = String(value);
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (!handle) return null;
|
|
publisherReads.push(handle);
|
|
return publishersByHandle.get(handle) ?? null;
|
|
}),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
});
|
|
const get = vi.fn(async (id: string) => users[id] ?? null);
|
|
|
|
return { ctx: { db: { query, get } }, publisherReads, query, get };
|
|
}
|
|
|
|
describe("home publisher summaries", () => {
|
|
it("rejects more than ten input handles before reading the database", async () => {
|
|
const query = vi.fn();
|
|
const get = vi.fn();
|
|
|
|
await expect(
|
|
getHomePublisherSummariesHandler({ db: { query, get } } as never, {
|
|
handles: Array.from({ length: 11 }, () => "duplicate"),
|
|
}),
|
|
).rejects.toThrow("at most 10");
|
|
expect(query).not.toHaveBeenCalled();
|
|
expect(get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("normalizes and deduplicates handles while preserving first-seen order", async () => {
|
|
const bravo = makeHomeSummaryPublisher("bravo", {
|
|
kind: "user",
|
|
linkedUserId: "users:bravo",
|
|
displayName: "Publisher fallback",
|
|
});
|
|
const alpha = makeHomeSummaryPublisher("alpha");
|
|
const { ctx, publisherReads, get } = makeHomePublisherSummariesCtx([bravo, alpha], {
|
|
"users:bravo": {
|
|
_id: "users:bravo",
|
|
displayName: "Bravo Builder",
|
|
image: "https://example.test/bravo.png",
|
|
bio: "Builds useful tools.",
|
|
},
|
|
});
|
|
|
|
const summaries = await getHomePublisherSummariesHandler(ctx as never, {
|
|
handles: [" @Bravo ", "alpha", "BRAVO"],
|
|
});
|
|
|
|
expect(summaries).toEqual([
|
|
expect.objectContaining({
|
|
handle: "bravo",
|
|
displayName: "Bravo Builder",
|
|
image: "https://example.test/bravo.png",
|
|
bio: "Builds useful tools.",
|
|
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
|
|
}),
|
|
expect.objectContaining({
|
|
handle: "alpha",
|
|
displayName: "alpha",
|
|
stats: { skills: 2, packages: 1, installs: 3, downloads: 4, stars: 5 },
|
|
}),
|
|
]);
|
|
expect(publisherReads).toEqual(["bravo", "alpha"]);
|
|
expect(get).toHaveBeenCalledTimes(1);
|
|
expect(get).toHaveBeenCalledWith("users:bravo");
|
|
});
|
|
|
|
it("omits incomplete or invisible summaries without reading catalog or membership tables", async () => {
|
|
const publishers = [
|
|
makeHomeSummaryPublisher("incomplete", { totalStars: undefined }),
|
|
makeHomeSummaryPublisher("deactivated", { deactivatedAt: 2 }),
|
|
makeHomeSummaryPublisher("deleted", { deletedAt: 2 }),
|
|
makeHomeSummaryPublisher("missing-user", {
|
|
kind: "user",
|
|
linkedUserId: "users:missing",
|
|
}),
|
|
makeHomeSummaryPublisher("deleted-user", {
|
|
kind: "user",
|
|
linkedUserId: "users:deleted",
|
|
}),
|
|
makeHomeSummaryPublisher("deactivated-user", {
|
|
kind: "user",
|
|
linkedUserId: "users:deactivated",
|
|
}),
|
|
makeHomeSummaryPublisher("legacy-unlinked", { kind: "user" }),
|
|
];
|
|
const { ctx, query, get } = makeHomePublisherSummariesCtx(publishers, {
|
|
"users:deleted": { _id: "users:deleted", deletedAt: 2 },
|
|
"users:deactivated": { _id: "users:deactivated", deactivatedAt: 2 },
|
|
});
|
|
|
|
await expect(
|
|
getHomePublisherSummariesHandler(ctx as never, {
|
|
handles: [...publishers.map((publisher) => publisher.handle), "not-found"],
|
|
}),
|
|
).resolves.toEqual([]);
|
|
expect(query).toHaveBeenCalledTimes(8);
|
|
expect(get).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe("home official creator summaries", () => {
|
|
it("pages the official publisher index and returns only active organizations with content", async () => {
|
|
const publishers = [
|
|
makeHomeSummaryPublisher("high", { totalInstalls: 100 }),
|
|
makeHomeSummaryPublisher("person", {
|
|
kind: "user",
|
|
linkedUserId: "users:person",
|
|
totalInstalls: 80,
|
|
}),
|
|
makeHomeSummaryPublisher("empty", {
|
|
publishedSkills: 0,
|
|
publishedPackages: 0,
|
|
totalInstalls: 70,
|
|
}),
|
|
makeHomeSummaryPublisher("inactive", { deactivatedAt: 2, totalInstalls: 50 }),
|
|
];
|
|
const officialRows = publishers.map((publisher, index) => ({
|
|
_id: `officialPublishers:${index}`,
|
|
publisherId: publisher._id,
|
|
createdAt: index,
|
|
updatedAt: index,
|
|
}));
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "officialPublishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
expect(indexName).toBe("by_created");
|
|
return indexedRows(officialRows);
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:person") return { _id: id, displayName: "Person" };
|
|
return publishers.find((publisher) => publisher._id === id) ?? null;
|
|
});
|
|
|
|
const result = (await getHomeOfficialCreatorSummariesPageInternalHandler(
|
|
{ db: { query, get } } as never,
|
|
{ cursor: null },
|
|
)) as {
|
|
summaries: Array<{ handle: string; kind: string }>;
|
|
isDone: boolean;
|
|
continueCursor: string;
|
|
};
|
|
|
|
expect(result.summaries.map((summary) => summary.handle)).toEqual(["high"]);
|
|
expect(result.summaries.every((summary) => summary.kind === "org")).toBe(true);
|
|
expect(result.isDone).toBe(true);
|
|
});
|
|
|
|
it("collects every official page, orders by installs, and clamps the result to sixteen", async () => {
|
|
const makeSummary = (index: number, installs: number) => ({
|
|
...makeHomeSummaryPublisher(`org-${index}`, { totalInstalls: installs }),
|
|
stats: { skills: 2, packages: 1, installs, downloads: installs, stars: 5 },
|
|
});
|
|
const firstPage = Array.from({ length: 12 }, (_, index) => makeSummary(index, 20 - index));
|
|
const secondPage = Array.from({ length: 8 }, (_, index) =>
|
|
makeSummary(index + 12, 100 - index),
|
|
);
|
|
const runQuery = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
summaries: firstPage,
|
|
continueCursor: "page-2",
|
|
isDone: false,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
summaries: secondPage,
|
|
continueCursor: "",
|
|
isDone: true,
|
|
});
|
|
|
|
const summaries = (await getHomeOfficialCreatorSummariesHandler({ runQuery } as never, {
|
|
limit: 99,
|
|
})) as Array<{ handle: string; stats: { installs: number } }>;
|
|
|
|
expect(summaries).toHaveLength(16);
|
|
expect(summaries.map((summary) => summary.stats.installs)).toEqual([
|
|
100, 99, 98, 97, 96, 95, 94, 93, 20, 19, 18, 17, 16, 15, 14, 13,
|
|
]);
|
|
expect(runQuery).toHaveBeenNthCalledWith(1, expect.anything(), { cursor: null });
|
|
expect(runQuery).toHaveBeenNthCalledWith(2, expect.anything(), { cursor: "page-2" });
|
|
});
|
|
|
|
it("fails closed when legacy official rows exceed the bounded curation limit", async () => {
|
|
const runQuery = vi.fn();
|
|
for (let page = 0; page < 4; page += 1) {
|
|
runQuery.mockResolvedValueOnce({
|
|
summaries: [],
|
|
continueCursor: `page-${page + 1}`,
|
|
isDone: false,
|
|
});
|
|
}
|
|
|
|
await expect(getHomeOfficialCreatorSummariesHandler({ runQuery } as never, {})).rejects.toThrow(
|
|
"Official publisher limit exceeded",
|
|
);
|
|
expect(runQuery).toHaveBeenCalledTimes(4);
|
|
});
|
|
});
|
|
|
|
describe("publishers membership controls", () => {
|
|
it("lets an org owner delete an org and cascade owned resources", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const patch = vi.fn();
|
|
const insert = vi.fn();
|
|
const runMutation = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ hiddenCount: 2, scheduled: false })
|
|
.mockResolvedValueOnce({ deletedCount: 1, revokedTokenCount: 1, scheduled: false });
|
|
const ctx = {
|
|
runMutation,
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "publishers:gladia") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "gladia",
|
|
displayName: "Gladia",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "githubSkillSources" || table === "githubSkillContents") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
if (table === "publisherInvites") {
|
|
return emptyPublisherInvitesQuery();
|
|
}
|
|
if (table !== "publisherMembers") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => ({
|
|
unique: vi.fn(async () =>
|
|
indexName === "by_publisher_user"
|
|
? {
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:gladia",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
}
|
|
: null,
|
|
),
|
|
collect: vi.fn(async () =>
|
|
indexName === "by_publisher"
|
|
? [
|
|
{
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:gladia",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
})),
|
|
};
|
|
}),
|
|
patch,
|
|
insert,
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
normalizeId: vi.fn(() => null),
|
|
},
|
|
};
|
|
|
|
const result = await deleteOrgHandler(ctx as never, { publisherId: "publishers:gladia" });
|
|
|
|
expect(result).toMatchObject({
|
|
handle: "gladia",
|
|
hiddenSkills: 2,
|
|
deletedPackages: 1,
|
|
revokedPackageTokens: 1,
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:gladia",
|
|
expect.objectContaining({
|
|
deletedAt: expect.any(Number),
|
|
deactivatedAt: expect.any(Number),
|
|
}),
|
|
);
|
|
expect(runMutation).toHaveBeenCalledTimes(2);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.org.delete",
|
|
targetId: "publishers:gladia",
|
|
metadata: expect.objectContaining({
|
|
handle: "gladia",
|
|
source: "settings",
|
|
hiddenSkills: 2,
|
|
deletedPackages: 1,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects org deletion by non-owner members", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:publisher" as never);
|
|
const runMutation = vi.fn();
|
|
const patch = vi.fn();
|
|
const ctx = {
|
|
runMutation,
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:publisher") return { _id: id };
|
|
if (id === "publishers:gladia") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "gladia",
|
|
displayName: "Gladia",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "githubSkillSources" || table === "githubSkillContents") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
if (table === "publisherInvites") {
|
|
return emptyPublisherInvitesQuery();
|
|
}
|
|
if (table !== "publisherMembers") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn(async () => ({
|
|
_id: "publisherMembers:publisher",
|
|
publisherId: "publishers:gladia",
|
|
userId: "users:publisher",
|
|
role: "publisher",
|
|
})),
|
|
})),
|
|
};
|
|
}),
|
|
patch,
|
|
insert: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
normalizeId: vi.fn(() => null),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
deleteOrgHandler(ctx as never, { publisherId: "publishers:gladia" }),
|
|
).rejects.toThrow("Only org owners can delete an organization");
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(runMutation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
function makeReclaimDeletedOrgCtx(
|
|
options: {
|
|
publisher?: Record<string, unknown> | null;
|
|
activeSkills?: Array<Record<string, unknown>>;
|
|
activePackages?: Array<Record<string, unknown>>;
|
|
invites?: Array<Record<string, unknown>>;
|
|
} = {},
|
|
) {
|
|
const publisher = options.publisher ?? {
|
|
_id: "publishers:tencent",
|
|
kind: "org",
|
|
handle: "tencent",
|
|
displayName: "TENCENT",
|
|
deletedAt: 2_000,
|
|
deactivatedAt: 2_000,
|
|
createdAt: 1,
|
|
updatedAt: 2_000,
|
|
};
|
|
const members = [
|
|
{
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:tencent",
|
|
userId: "users:spammer",
|
|
role: "owner",
|
|
},
|
|
];
|
|
const deleted = vi.fn();
|
|
const insert = vi.fn();
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_handle") throw new Error(`unexpected index ${indexName}`);
|
|
return { unique: vi.fn(async () => publisher) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_owner_publisher_active_updated") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return { take: vi.fn(async () => options.activeSkills ?? []) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "packages") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_owner_publisher_active_updated") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return { take: vi.fn(async () => options.activePackages ?? []) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_publisher") throw new Error(`unexpected index ${indexName}`);
|
|
return { collect: vi.fn(async () => members) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publisherInvites") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (indexName !== "by_publisher_status_expires") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
const fields: Record<string, string> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
collect: vi.fn(async () =>
|
|
(options.invites ?? []).filter(
|
|
(invite) =>
|
|
invite.publisherId === fields.publisherId && invite.status === fields.status,
|
|
),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "githubSkillSources" || table === "githubSkillContents") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") return emptyOfficialPublishersQuery();
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
return {
|
|
ctx: {
|
|
scheduler: { runAfter: vi.fn() },
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id, role: "admin" };
|
|
return null;
|
|
}),
|
|
query,
|
|
insert,
|
|
delete: deleted,
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(() => null),
|
|
},
|
|
},
|
|
deleted,
|
|
insert,
|
|
};
|
|
}
|
|
|
|
it("dry-runs hard deletion for a deleted empty org handle", async () => {
|
|
const { ctx, deleted, insert } = makeReclaimDeletedOrgCtx();
|
|
|
|
const result = await reclaimDeletedOrgHandleInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
handle: "Tencent",
|
|
reason: "Free spam org handle",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
publisherId: "publishers:tencent",
|
|
handle: "tencent",
|
|
dryRun: true,
|
|
hardDeleted: false,
|
|
activeSkills: 0,
|
|
activePackages: 0,
|
|
memberCount: 1,
|
|
confirmationToken: "reclaim-deleted-org:tencent",
|
|
});
|
|
expect(deleted).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("requires the confirmation token before hard deleting", async () => {
|
|
const { ctx, deleted } = makeReclaimDeletedOrgCtx();
|
|
|
|
await expect(
|
|
reclaimDeletedOrgHandleInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
handle: "tencent",
|
|
reason: "Free spam org handle",
|
|
dryRun: false,
|
|
}),
|
|
).rejects.toThrow('Confirmation token must be "reclaim-deleted-org:tencent"');
|
|
expect(deleted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hard deletes the deleted org publisher row and records an audit log", async () => {
|
|
const { ctx, deleted, insert } = makeReclaimDeletedOrgCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:pending",
|
|
publisherId: "publishers:tencent",
|
|
status: "pending",
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await reclaimDeletedOrgHandleInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
handle: "tencent",
|
|
reason: "Free spam org handle",
|
|
dryRun: false,
|
|
confirmationToken: "reclaim-deleted-org:tencent",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
dryRun: false,
|
|
hardDeleted: true,
|
|
memberCount: 1,
|
|
inviteCount: 1,
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
actorUserId: "users:admin",
|
|
action: "publisher.org.reclaim_deleted_handle",
|
|
targetType: "publisher",
|
|
targetId: "publishers:tencent",
|
|
metadata: expect.objectContaining({
|
|
handle: "tencent",
|
|
reason: "Free spam org handle",
|
|
}),
|
|
}),
|
|
);
|
|
expect(deleted).toHaveBeenCalledWith("publisherMembers:owner");
|
|
expect(deleted).toHaveBeenCalledWith("publisherInvites:pending");
|
|
expect(deleted).toHaveBeenCalledWith("publishers:tencent");
|
|
});
|
|
|
|
it("refuses to reclaim an active org handle", async () => {
|
|
const { ctx } = makeReclaimDeletedOrgCtx({
|
|
publisher: {
|
|
_id: "publishers:tencent",
|
|
kind: "org",
|
|
handle: "tencent",
|
|
displayName: "TENCENT",
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
reclaimDeletedOrgHandleInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
handle: "tencent",
|
|
reason: "Free spam org handle",
|
|
}),
|
|
).rejects.toThrow("Publisher is active; use org delete before reclaiming the handle");
|
|
});
|
|
|
|
it("deletes sole-owner account orgs when other owner memberships are inactive", async () => {
|
|
const patch = vi.fn();
|
|
const insert = vi.fn();
|
|
const runMutation = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ hiddenCount: 2, scheduled: false })
|
|
.mockResolvedValueOnce({ deletedCount: 1, revokedTokenCount: 1, scheduled: false });
|
|
const actorMembership = {
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:gladia",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
};
|
|
const inactiveOwnerMembership = {
|
|
_id: "publisherMembers:inactive-owner",
|
|
publisherId: "publishers:gladia",
|
|
userId: "users:inactive-owner",
|
|
role: "owner",
|
|
};
|
|
const ctx = {
|
|
runMutation,
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "users:inactive-owner") return { _id: id, deactivatedAt: 2_000 };
|
|
if (id === "publishers:gladia") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "gladia",
|
|
displayName: "Gladia",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "githubSkillSources" || table === "githubSkillContents") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
if (table === "publisherInvites") {
|
|
return emptyPublisherInvitesQuery();
|
|
}
|
|
if (table !== "publisherMembers") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_user") {
|
|
return { collect: vi.fn(async () => [actorMembership]) };
|
|
}
|
|
if (indexName === "by_publisher") {
|
|
return {
|
|
collect: vi.fn(async () => [actorMembership, inactiveOwnerMembership]),
|
|
};
|
|
}
|
|
if (indexName === "by_publisher_user") {
|
|
return { unique: vi.fn(async () => actorMembership) };
|
|
}
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}),
|
|
};
|
|
}),
|
|
patch,
|
|
insert,
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
normalizeId: vi.fn(() => null),
|
|
},
|
|
};
|
|
|
|
const result = await deleteSoleOwnerOrgsForAccountDeletionInternalHandler(ctx as never, {
|
|
actorUserId: "users:owner",
|
|
deletedAt: 3_000,
|
|
});
|
|
|
|
expect(result).toMatchObject({ deletedOrgs: 1, hiddenSkills: 2, deletedPackages: 1 });
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:gladia",
|
|
expect.objectContaining({
|
|
deletedAt: 3_000,
|
|
deactivatedAt: 3_000,
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.org.delete",
|
|
metadata: expect.objectContaining({ source: "account.delete" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not resolve another personal publisher through a stale membership", async () => {
|
|
const ctx = makeResolvePublishTargetCtx({
|
|
targetPublisher: {
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
linkedUserId: "users:owner",
|
|
},
|
|
targetMembership: {
|
|
_id: "publisherMembers:stale",
|
|
publisherId: "publishers:owner",
|
|
userId: "users:vincent",
|
|
role: "publisher",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
resolvePublishTargetForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
ownerHandle: "owner",
|
|
minimumRole: "publisher",
|
|
}),
|
|
).rejects.toThrow('publish access for "@owner"');
|
|
});
|
|
|
|
it("keeps org publisher memberships valid for publish target resolution", async () => {
|
|
const ctx = makeResolvePublishTargetCtx({
|
|
targetPublisher: {
|
|
_id: "publishers:openclaw",
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
},
|
|
targetMembership: {
|
|
_id: "publisherMembers:openclaw",
|
|
publisherId: "publishers:openclaw",
|
|
userId: "users:vincent",
|
|
role: "publisher",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
resolvePublishTargetForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
ownerHandle: "openclaw",
|
|
minimumRole: "publisher",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
publisherId: "publishers:openclaw",
|
|
handle: "openclaw",
|
|
kind: "org",
|
|
});
|
|
});
|
|
|
|
it.each(["admin", "docs", "skills"])(
|
|
"rejects org handle %s reserved for public routes",
|
|
async (handle) => {
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) =>
|
|
id === "users:admin" ? { _id: id, role: "admin" } : null,
|
|
),
|
|
query: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
migrateLegacyPublisherHandleToOrgInternalHandler(ctx, {
|
|
actorUserId: "users:admin",
|
|
handle,
|
|
}),
|
|
).rejects.toThrow(`Handle "@${handle}" is reserved for ClawHub routes`);
|
|
},
|
|
);
|
|
|
|
it("lists individual and org publishers ranked by aggregate downloads", async () => {
|
|
const publisherRows = [
|
|
{
|
|
_id: "publishers:alice",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "alice",
|
|
displayName: "Alice",
|
|
linkedUserId: "users:alice",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const skillRows = [
|
|
{
|
|
_id: "skills:alice",
|
|
ownerPublisherId: "publishers:alice",
|
|
softDeletedAt: undefined,
|
|
statsDownloads: 4,
|
|
statsStars: 1,
|
|
statsInstallsAllTime: 3,
|
|
stats: { downloads: 4, stars: 1, installsCurrent: 1, installsAllTime: 3 },
|
|
},
|
|
{
|
|
_id: "skills:openclaw",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
statsDownloads: 20,
|
|
statsStars: 2,
|
|
statsInstallsAllTime: 15,
|
|
stats: { downloads: 20, stars: 2, installsCurrent: 4, installsAllTime: 15 },
|
|
},
|
|
];
|
|
const packageRows = [
|
|
{
|
|
_id: "packages:alice",
|
|
ownerPublisherId: "publishers:alice",
|
|
softDeletedAt: undefined,
|
|
stats: { downloads: 5, stars: 0, installs: 2, versions: 1 },
|
|
},
|
|
];
|
|
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") return { _id: id, image: "https://github.com/alice.png" };
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({ collect: vi.fn(async () => publisherRows) })),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows(
|
|
skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId),
|
|
);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows(
|
|
packageRows.filter((pkg) => pkg.ownerPublisherId === fields.ownerPublisherId),
|
|
);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicHandler(ctx as never, { limit: 48 });
|
|
|
|
expect(result.total).toBe(2);
|
|
expect(result.counts).toEqual({ all: 2, individuals: 1, organizations: 1 });
|
|
expect(result.items.map((item) => item.handle)).toEqual(["openclaw", "alice"]);
|
|
expect(result.items.map((item) => item.kind)).toEqual(["org", "user"]);
|
|
expect(result.items.map((item) => item.stats.downloads)).toEqual([20, 9]);
|
|
});
|
|
|
|
it("filters public publisher listings by kind", async () => {
|
|
const publisherRows = [
|
|
{
|
|
_id: "publishers:alice",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "alice",
|
|
displayName: "Alice",
|
|
linkedUserId: "users:alice",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 4,
|
|
totalDownloads: 10,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
publishedSkills: 0,
|
|
publishedPackages: 1,
|
|
totalInstalls: 20,
|
|
totalDownloads: 40,
|
|
totalStars: 2,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") return { _id: id, image: "https://github.com/alice.png" };
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({ collect: vi.fn(async () => publisherRows) })),
|
|
};
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicHandler(ctx as never, { kind: "org" });
|
|
|
|
expect(result.total).toBe(1);
|
|
expect(result.counts).toEqual({ all: 2, individuals: 1, organizations: 1 });
|
|
expect(result.items.map((item) => item.handle)).toEqual(["openclaw"]);
|
|
});
|
|
|
|
it("omits hidden publisher preview skills without scanning extra pages", async () => {
|
|
const publisherRows = [
|
|
{
|
|
_id: "publishers:nvidia",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "nvidia",
|
|
displayName: "NVIDIA",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 0,
|
|
totalDownloads: 70,
|
|
totalStars: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const skillRows = [
|
|
...Array.from({ length: 3 }, (_, index) => 100 - index).map((installs, index) => ({
|
|
_id: `skills:hidden-${index}`,
|
|
ownerPublisherId: "publishers:nvidia",
|
|
ownerUserId: "users:nvidia",
|
|
slug: `hidden-${index}`,
|
|
displayName: `Hidden ${index}`,
|
|
summary: "Pending verification.",
|
|
icon: null,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "hidden",
|
|
statsDownloads: 1000 - index,
|
|
statsStars: 0,
|
|
statsInstallsAllTime: installs,
|
|
stats: {
|
|
downloads: 1000 - index,
|
|
stars: 0,
|
|
installsCurrent: installs,
|
|
installsAllTime: installs,
|
|
},
|
|
updatedAt: installs,
|
|
})),
|
|
{
|
|
_id: "skills:visible",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
ownerUserId: "users:nvidia",
|
|
slug: "visible",
|
|
displayName: "Visible Skill",
|
|
summary: "Shown.",
|
|
icon: null,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
statsDownloads: 70,
|
|
statsStars: 0,
|
|
statsInstallsAllTime: 1,
|
|
stats: { downloads: 70, stars: 0, installsCurrent: 1, installsAllTime: 1 },
|
|
updatedAt: 70,
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async () => null),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({ collect: vi.fn(async () => publisherRows) })),
|
|
};
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async (limit: number) =>
|
|
skillRows
|
|
.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId)
|
|
.slice(0, limit),
|
|
),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({ take: vi.fn(async () => []) })),
|
|
};
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicHandler(ctx as never, { limit: 48 });
|
|
|
|
expect(result.items[0]?.publishedItems).toEqual([]);
|
|
});
|
|
|
|
it("pages public publishers by kind and query", async () => {
|
|
const publisherRows = [
|
|
{
|
|
_id: "publishers:alice",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "alice",
|
|
displayName: "Alice Labs",
|
|
linkedUserId: "users:alice",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 4,
|
|
totalDownloads: 10,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "publishers:bob",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "bob",
|
|
displayName: "Bob Tools",
|
|
linkedUserId: "users:bob",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 2,
|
|
totalDownloads: 8,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
publishedSkills: 0,
|
|
publishedPackages: 1,
|
|
totalInstalls: 20,
|
|
totalDownloads: 40,
|
|
totalStars: 2,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") return { _id: id, image: "https://github.com/alice.png" };
|
|
if (id === "users:bob") return { _id: id, image: "https://github.com/bob.png" };
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const range: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
gte: (field: string, value: unknown) => {
|
|
range.gte = { field, value };
|
|
return q;
|
|
},
|
|
lt: (field: string, value: unknown) => {
|
|
range.lt = { field, value };
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_kind_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: vi.fn(async () =>
|
|
publisherRows.filter((publisher) => publisher.kind === fields.kind),
|
|
),
|
|
take: vi.fn(async () =>
|
|
publisherRows.filter((publisher) => publisher.kind === fields.kind),
|
|
),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_kind_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () =>
|
|
publisherRows.filter((publisher) => publisher.kind === fields.kind),
|
|
),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: vi.fn(async () => publisherRows),
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_kind_handle") {
|
|
return {
|
|
take: vi.fn(async () => {
|
|
const prefix = (range.gte as { value: string } | undefined)?.value ?? "";
|
|
return publisherRows.filter(
|
|
(publisher) =>
|
|
publisher.kind === fields.kind && publisher.handle.startsWith(prefix),
|
|
);
|
|
}),
|
|
};
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
kind: "user",
|
|
query: "alice",
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
|
|
expect(result.counts).toEqual({ all: 1, individuals: 1, organizations: 0 });
|
|
expect(result.globalCounts).toEqual({ all: 3, individuals: 2, organizations: 1 });
|
|
expect(result.page.map((item) => item.handle)).toEqual(["alice"]);
|
|
});
|
|
|
|
it("finds publishers outside the popular install window via handle prefix search", async () => {
|
|
const popularRows = Array.from({ length: 500 }, (_, index) => ({
|
|
_id: `publishers:popular-${index}`,
|
|
_creationTime: index,
|
|
kind: "user" as const,
|
|
handle: `popular-${index}`,
|
|
displayName: `Popular ${index}`,
|
|
linkedUserId: `users:popular-${index}`,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 500 - index,
|
|
totalDownloads: 500 - index,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}));
|
|
const vincentkoc = {
|
|
_id: "publishers:vincentkoc",
|
|
_creationTime: 1,
|
|
kind: "user" as const,
|
|
handle: "vincentkoc",
|
|
displayName: "Vincent Koc",
|
|
linkedUserId: "users:vincentkoc",
|
|
publishedSkills: 0,
|
|
publishedPackages: 0,
|
|
totalInstalls: 0,
|
|
totalDownloads: 0,
|
|
totalStars: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:vincentkoc") {
|
|
return { _id: id, image: "https://github.com/vincentkoc.png" };
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const range: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
gte: (field: string, value: unknown) => {
|
|
range.gte = { field, value };
|
|
return q;
|
|
},
|
|
lt: (field: string, value: unknown) => {
|
|
range.lt = { field, value };
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
fields.handle === "vincent"
|
|
? null
|
|
: fields.handle === "vincentkoc"
|
|
? vincentkoc
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => popularRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => popularRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_kind_handle") {
|
|
return {
|
|
take: vi.fn(async () => {
|
|
const prefix = (range.gte as { value: string } | undefined)?.value ?? "";
|
|
const upper = (range.lt as { value: string } | undefined)?.value ?? "";
|
|
if (fields.kind === "user" && prefix === "vincent" && upper === "vincent\uffff") {
|
|
return [vincentkoc];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
query: "vincent",
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
|
|
expect(result.page.map((item) => item.handle)).toEqual(["vincentkoc"]);
|
|
expect(result.counts).toEqual({ all: 1, individuals: 1, organizations: 0 });
|
|
});
|
|
|
|
it("finds publishers with published skills outside the popular install window", async () => {
|
|
const popularRows = Array.from({ length: 500 }, (_, index) => ({
|
|
_id: `publishers:popular-${index}`,
|
|
_creationTime: index,
|
|
kind: "user" as const,
|
|
handle: `popular-${index}`,
|
|
displayName: `Popular ${index}`,
|
|
linkedUserId: `users:popular-${index}`,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 500 - index,
|
|
totalDownloads: 500 - index,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}));
|
|
const vyctorbrzezowski = {
|
|
_id: "publishers:vyctorbrzezowski",
|
|
_creationTime: 1,
|
|
kind: "user" as const,
|
|
handle: "vyctorbrzezowski",
|
|
displayName: "Vyctor Brzezowski",
|
|
linkedUserId: "users:vyctorbrzezowski",
|
|
publishedSkills: 5,
|
|
publishedPackages: 1,
|
|
totalInstalls: 46,
|
|
totalDownloads: 1288,
|
|
totalStars: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:vyctorbrzezowski") {
|
|
return { _id: id, image: "https://github.com/vyctorbrzezowski.png" };
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const range: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
gte: (field: string, value: unknown) => {
|
|
range.gte = { field, value };
|
|
return q;
|
|
},
|
|
lt: (field: string, value: unknown) => {
|
|
range.lt = { field, value };
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
fields.handle === "vyctorbrzezowski" ? vyctorbrzezowski : null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => popularRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => popularRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_kind_handle") {
|
|
return {
|
|
take: vi.fn(async () => {
|
|
const prefix = (range.gte as { value: string } | undefined)?.value ?? "";
|
|
const upper = (range.lt as { value: string } | undefined)?.value ?? "";
|
|
if (fields.kind === "user" && prefix === "vyctor" && upper === "vyctor\uffff") {
|
|
return [vyctorbrzezowski];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows([
|
|
{
|
|
_id: "skills:vyctor-demo",
|
|
ownerPublisherId: "publishers:vyctorbrzezowski",
|
|
softDeletedAt: undefined,
|
|
displayName: "Demo Skill",
|
|
statsInstallsAllTime: 46,
|
|
statsDownloads: 1288,
|
|
statsStars: 0,
|
|
updatedAt: 1,
|
|
},
|
|
]);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const prefixResult = await listPublicPageHandler(ctx as never, {
|
|
query: "vyctor",
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
const exactResult = await listPublicPageHandler(ctx as never, {
|
|
query: "vyctorbrzezowski",
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
|
|
expect(prefixResult.page.map((item) => item.handle)).toEqual(["vyctorbrzezowski"]);
|
|
expect(exactResult.page.map((item) => item.handle)).toEqual(["vyctorbrzezowski"]);
|
|
});
|
|
|
|
it("filters hidden legacy user publishers before counting and paginating public publisher pages", async () => {
|
|
const publisherRows = [
|
|
{
|
|
_id: "publishers:proof-banned-builder",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "proof-banned-builder",
|
|
displayName: "Proof Banned Builder",
|
|
linkedUserId: undefined,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 10,
|
|
totalDownloads: 100,
|
|
totalStars: 5,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "publishers:alice",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "alice",
|
|
displayName: "Alice Labs",
|
|
linkedUserId: "users:alice",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 4,
|
|
totalDownloads: 10,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const get = vi.fn(async (id: string) => {
|
|
if (id === "users:proof-banned-builder") {
|
|
return { _id: id, deletedAt: 1_700_000_000_000 };
|
|
}
|
|
if (id === "users:alice") return { _id: id, image: "https://github.com/alice.png" };
|
|
return null;
|
|
});
|
|
const ownerPublisherQueries: string[] = [];
|
|
const ctx = {
|
|
db: {
|
|
get,
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: vi.fn(async () => publisherRows),
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
ownerPublisherQueries.push(String(fields.ownerPublisherId));
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "publisherMembers" && indexName === "by_publisher") {
|
|
return indexedRows(
|
|
fields.publisherId === "publishers:proof-banned-builder"
|
|
? [
|
|
{
|
|
_id: "publisherMembers:proof-banned-builder",
|
|
publisherId: "publishers:proof-banned-builder",
|
|
userId: "users:proof-banned-builder",
|
|
role: "owner",
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
paginationOpts: { cursor: null, numItems: 1 },
|
|
});
|
|
|
|
expect(result.page.map((item) => item.handle)).toEqual(["alice"]);
|
|
expect(result.counts).toEqual({ all: 1, individuals: 1, organizations: 0 });
|
|
expect(result.globalCounts).toEqual({ all: 1, individuals: 1, organizations: 0 });
|
|
expect(result.continueCursor).toBe("");
|
|
expect(result.isDone).toBe(true);
|
|
expect(get).toHaveBeenCalledWith("users:proof-banned-builder");
|
|
expect(get).toHaveBeenCalledWith("users:alice");
|
|
expect(ownerPublisherQueries).toEqual(["publishers:alice", "publishers:alice"]);
|
|
});
|
|
|
|
it("lists official creators and organizations from the official publisher index", async () => {
|
|
const publishers = [
|
|
{
|
|
_id: "publishers:steipete",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "steipete",
|
|
displayName: "steipete",
|
|
linkedUserId: "users:steipete",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 85_400,
|
|
totalDownloads: 100_000,
|
|
totalStars: 100,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 2,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
publishedSkills: 6,
|
|
publishedPackages: 59,
|
|
totalInstalls: 130,
|
|
totalDownloads: 95_000,
|
|
totalStars: 4,
|
|
createdAt: 2,
|
|
updatedAt: 2,
|
|
},
|
|
{
|
|
_id: "publishers:community",
|
|
_creationTime: 3,
|
|
kind: "org",
|
|
handle: "community",
|
|
displayName: "Community",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 1_000,
|
|
totalDownloads: 1_000,
|
|
totalStars: 1,
|
|
createdAt: 3,
|
|
updatedAt: 3,
|
|
},
|
|
];
|
|
const officialRows = [
|
|
{
|
|
_id: "officialPublishers:steipete",
|
|
publisherId: "publishers:steipete",
|
|
createdAt: 1,
|
|
},
|
|
{
|
|
_id: "officialPublishers:openclaw",
|
|
publisherId: "publishers:openclaw",
|
|
createdAt: 2,
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:steipete") {
|
|
return {
|
|
_id: id,
|
|
displayName: "Peter Steinberger",
|
|
image: "https://github.com/steipete.png",
|
|
};
|
|
}
|
|
return publishers.find((publisher) => publisher._id === id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery?: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery?.(q);
|
|
|
|
if (table === "officialPublishers" && indexName === "by_created") {
|
|
return {
|
|
order: vi.fn(() => ({ take: vi.fn(async () => officialRows) })),
|
|
};
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
officialRows.find((row) => row.publisherId === fields.publisherId),
|
|
),
|
|
};
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
return indexedRows([]);
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
official: true,
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
|
|
expect(result.page.map((publisher) => publisher.handle)).toEqual(["steipete", "openclaw"]);
|
|
expect(result.page.map((publisher) => publisher.kind)).toEqual(["user", "org"]);
|
|
expect(result.page[0]?.displayName).toBe("Peter Steinberger");
|
|
expect(result.page.every((publisher) => publisher.official)).toBe(true);
|
|
expect(result.isDone).toBe(true);
|
|
|
|
const creators = await listPublicPageHandler(ctx as never, {
|
|
official: true,
|
|
kind: "user",
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
|
|
expect(creators.page.map((publisher) => publisher.handle)).toEqual(["steipete"]);
|
|
expect(creators.globalCounts).toEqual({ all: 2, individuals: 1, organizations: 1 });
|
|
});
|
|
|
|
it("orders and renders public publisher card previews by downloads", async () => {
|
|
const publisherRows = [
|
|
{
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
publishedSkills: 1,
|
|
publishedPackages: 4,
|
|
totalInstalls: 20,
|
|
totalDownloads: 364,
|
|
totalStars: 2,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const skillRows = [
|
|
{
|
|
_id: "skills:popular-skill",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
displayName: "Popular Skill",
|
|
statsDownloads: 98,
|
|
statsStars: 1,
|
|
statsInstallsAllTime: 35,
|
|
stats: { downloads: 98, stars: 1, installsCurrent: 35, installsAllTime: 35 },
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const packageRows = [
|
|
{
|
|
_id: "packages:popular-plugin",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
displayName: "Popular Plugin",
|
|
stats: { downloads: 128, stars: 1, installs: 5, versions: 1 },
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "packages:recent-plugin",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
displayName: "Recent Plugin",
|
|
stats: { downloads: 12, stars: 1, installs: 50, versions: 1 },
|
|
updatedAt: 5,
|
|
},
|
|
{
|
|
_id: "packages:recent-helper",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
displayName: "Recent Helper",
|
|
stats: { downloads: 11, stars: 1, installs: 20, versions: 1 },
|
|
updatedAt: 4,
|
|
},
|
|
{
|
|
_id: "packages:recent-tool",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
displayName: "Recent Tool",
|
|
stats: { downloads: 10, stars: 1, installs: 40, versions: 1 },
|
|
updatedAt: 3,
|
|
},
|
|
];
|
|
const rowsByDownloads = <
|
|
T extends {
|
|
updatedAt: number;
|
|
stats?: { downloads?: number };
|
|
statsDownloads?: number;
|
|
},
|
|
>(
|
|
rows: T[],
|
|
) =>
|
|
[...rows].sort(
|
|
(a, b) =>
|
|
(b.statsDownloads ?? b.stats?.downloads ?? 0) -
|
|
(a.statsDownloads ?? a.stats?.downloads ?? 0) || b.updatedAt - a.updatedAt,
|
|
);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: vi.fn(async () => publisherRows),
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows(
|
|
rowsByDownloads(
|
|
skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId),
|
|
),
|
|
);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows(
|
|
rowsByDownloads(
|
|
packageRows.filter((pkg) => pkg.ownerPublisherId === fields.ownerPublisherId),
|
|
),
|
|
);
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
|
|
expect(result.page[0]?.publishedItems.map((item) => item.displayName)).toEqual([
|
|
"Popular Plugin",
|
|
"Popular Skill",
|
|
"Recent Plugin",
|
|
]);
|
|
expect(result.page[0]?.publishedItems.map((item) => item.installs)).toEqual([5, 35, 50]);
|
|
expect(result.page[0]?.publishedItems.map((item) => item.downloads)).toEqual([128, 98, 12]);
|
|
});
|
|
|
|
it("does not hydrate every publisher catalog preview before filtering public publisher pages", async () => {
|
|
const publisherRows = Array.from({ length: 120 }, (_, index) => ({
|
|
_id: `publishers:user-${index}`,
|
|
_creationTime: index,
|
|
kind: "user",
|
|
handle: `user-${index}`,
|
|
displayName: `User ${index}`,
|
|
linkedUserId: `users:user-${index}`,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 120 - index,
|
|
totalDownloads: 120 - index,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}));
|
|
const get = vi.fn(async (id: string) => ({ _id: id, image: `https://github.com/${id}.png` }));
|
|
const ownerPublisherQueries: string[] = [];
|
|
const ctx = {
|
|
db: {
|
|
get,
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: vi.fn(async () => publisherRows),
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
ownerPublisherQueries.push(String(fields.ownerPublisherId));
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
paginationOpts: { cursor: null, numItems: 1 },
|
|
});
|
|
|
|
expect(result.page.map((item) => item.handle)).toEqual(["user-0"]);
|
|
expect(result.globalCounts).toEqual({ all: 120, individuals: 120, organizations: 0 });
|
|
expect(get).toHaveBeenCalledTimes(120);
|
|
expect(get).toHaveBeenCalledWith("users:user-0");
|
|
expect(ownerPublisherQueries).toEqual(["publishers:user-0", "publishers:user-0"]);
|
|
});
|
|
|
|
it("ranks bounded legacy publishers missing download aggregates before paginating", async () => {
|
|
const rankedPublisherRows = Array.from({ length: 2 }, (_, index) => ({
|
|
_id: `publishers:user-${index}`,
|
|
_creationTime: index,
|
|
kind: "user",
|
|
handle: `user-${index}`,
|
|
displayName: `User ${index}`,
|
|
linkedUserId: `users:user-${index}`,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 500 - index,
|
|
totalDownloads: 500 - index,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}));
|
|
const legacyFillerRows = Array.from({ length: 500 }, (_, index) => ({
|
|
_id: `publishers:legacy-filler-${index}`,
|
|
_creationTime: 100 + index,
|
|
kind: "user",
|
|
handle: `legacy-filler-${index}`,
|
|
displayName: `Legacy Filler ${index}`,
|
|
linkedUserId: `users:legacy-filler-${index}`,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 500 - index,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1_000 - index,
|
|
}));
|
|
const legacyPublisher = {
|
|
_id: "publishers:legacy-popular",
|
|
_creationTime: 600,
|
|
kind: "user",
|
|
handle: "legacy-popular",
|
|
displayName: "Legacy Popular",
|
|
linkedUserId: "users:legacy-popular",
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 1_000,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const legacyRowsByDownloadsIndex = [...legacyFillerRows, legacyPublisher];
|
|
const legacyRowsByInstallsIndex = [legacyPublisher, ...legacyFillerRows];
|
|
const skillRows = [
|
|
{
|
|
_id: "skills:legacy-popular",
|
|
ownerPublisherId: "publishers:legacy-popular",
|
|
softDeletedAt: undefined,
|
|
displayName: "Legacy Popular Skill",
|
|
moderationStatus: "active",
|
|
statsDownloads: 1000,
|
|
statsStars: 1,
|
|
statsInstallsAllTime: 1,
|
|
stats: { downloads: 1000, stars: 1, installsCurrent: 1, installsAllTime: 1 },
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const get = vi.fn(async (id: string) => ({ _id: id, image: `https://github.com/${id}.png` }));
|
|
const legacyFallbackCollect = vi.fn(async () => {
|
|
throw new Error("legacy publisher fallback must stay bounded");
|
|
});
|
|
const legacyDownloadsFallbackTake = vi.fn(async (limit: number) =>
|
|
legacyRowsByDownloadsIndex.slice(0, limit),
|
|
);
|
|
const legacyInstallsFallbackTake = vi.fn(async (limit: number) =>
|
|
legacyRowsByInstallsIndex.slice(0, limit),
|
|
);
|
|
const ctx = {
|
|
db: {
|
|
get,
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const explicitFields = new Set<string>();
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
explicitFields.add(field);
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
const rows = explicitFields.has("totalDownloads")
|
|
? legacyRowsByDownloadsIndex
|
|
: rankedPublisherRows;
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: explicitFields.has("totalDownloads")
|
|
? legacyFallbackCollect
|
|
: vi.fn(async () => rows),
|
|
take: explicitFields.has("totalDownloads")
|
|
? legacyDownloadsFallbackTake
|
|
: vi.fn(async (limit: number) => rows.slice(0, limit)),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: legacyFallbackCollect,
|
|
take: legacyInstallsFallbackTake,
|
|
})),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows(
|
|
skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId),
|
|
);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([]);
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
return indexedRows(
|
|
table === "skills"
|
|
? skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId)
|
|
: [],
|
|
);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
paginationOpts: { cursor: null, numItems: 1 },
|
|
});
|
|
|
|
expect(result.page.map((item) => item.handle)).toEqual(["legacy-popular"]);
|
|
expect(result.page.map((item) => item.stats.downloads)).toEqual([1000]);
|
|
expect(legacyDownloadsFallbackTake).not.toHaveBeenCalled();
|
|
expect(legacyInstallsFallbackTake).toHaveBeenCalledWith(500);
|
|
expect(legacyFallbackCollect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not hydrate publisher catalog previews when a public publisher search has no matches", async () => {
|
|
const publisherRows = Array.from({ length: 120 }, (_, index) => ({
|
|
_id: `publishers:user-${index}`,
|
|
_creationTime: index,
|
|
kind: "user",
|
|
handle: `user-${index}`,
|
|
displayName: `User ${index}`,
|
|
linkedUserId: `users:user-${index}`,
|
|
publishedSkills: 1,
|
|
publishedPackages: 0,
|
|
totalInstalls: 120 - index,
|
|
totalDownloads: 120 - index,
|
|
totalStars: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}));
|
|
const get = vi.fn(async (id: string) => ({ _id: id }));
|
|
const ownerPublisherQueries: string[] = [];
|
|
const ctx = {
|
|
db: {
|
|
get,
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const range: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
gte: (field: string, value: unknown) => {
|
|
range.gte = { field, value };
|
|
return q;
|
|
},
|
|
lt: (field: string, value: unknown) => {
|
|
range.lt = { field, value };
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_active_total_downloads") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
collect: vi.fn(async () => publisherRows),
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_total_installs") {
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => publisherRows),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_active_kind_handle") {
|
|
return {
|
|
take: vi.fn(async () => []),
|
|
};
|
|
}
|
|
if (
|
|
(table === "skills" || table === "packages") &&
|
|
indexName === "by_owner_publisher_active_downloads"
|
|
) {
|
|
ownerPublisherQueries.push(String(fields.ownerPublisherId));
|
|
return indexedRows([]);
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublicPageHandler(ctx as never, {
|
|
query: "no matching publisher",
|
|
paginationOpts: { cursor: null, numItems: 25 },
|
|
});
|
|
|
|
expect(result.page).toEqual([]);
|
|
expect(result.counts).toEqual({ all: 0, individuals: 0, organizations: 0 });
|
|
expect(result.globalCounts).toEqual({ all: 120, individuals: 120, organizations: 0 });
|
|
expect(get).toHaveBeenCalledTimes(120);
|
|
expect(ownerPublisherQueries).toEqual([]);
|
|
});
|
|
|
|
it("orders profile catalog items by downloads", async () => {
|
|
const publisher = {
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const packageRows = [
|
|
{
|
|
_id: "packages:high-download-plugin",
|
|
_creationTime: 2,
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
name: "@openclaw/high-download-plugin",
|
|
displayName: "High Download Plugin",
|
|
summary: "Scoped plugin",
|
|
stats: { downloads: 70, installs: 3, stars: 1, versions: 1 },
|
|
updatedAt: 5,
|
|
},
|
|
{
|
|
_id: "packages:low-download-plugin",
|
|
_creationTime: 1,
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
name: "@openclaw/low-download-plugin",
|
|
displayName: "Low Download Plugin",
|
|
summary: "Scoped plugin",
|
|
stats: { downloads: 7, installs: 300, stars: 1, versions: 1 },
|
|
updatedAt: 6,
|
|
},
|
|
];
|
|
const packagePage = indexedRows(packageRows);
|
|
const legacyPackageRows = indexedRows(packageRows);
|
|
packagePage.collect.mockImplementation(async () => {
|
|
throw new Error("catalog pagination must not collect the package table");
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === "publishers:openclaw" ? publisher : null)),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "openclaw" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
|
|
return packagePage;
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return legacyPackageRows;
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const firstPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "openclaw",
|
|
kind: "plugin",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: null, numItems: 1 },
|
|
});
|
|
const secondPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "openclaw",
|
|
kind: "plugin",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: firstPage.continueCursor, numItems: 1 },
|
|
});
|
|
const resumedLegacyPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "openclaw",
|
|
kind: "plugin",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: "1", numItems: 1 },
|
|
});
|
|
|
|
expect([...firstPage.page, ...secondPage.page]).toMatchObject([
|
|
{
|
|
displayName: "High Download Plugin",
|
|
downloads: 70,
|
|
href: "/plugins/@openclaw/high-download-plugin",
|
|
installs: 3,
|
|
},
|
|
{
|
|
displayName: "Low Download Plugin",
|
|
downloads: 7,
|
|
href: "/plugins/@openclaw/low-download-plugin",
|
|
installs: 300,
|
|
},
|
|
]);
|
|
expect(packagePage.order).toHaveBeenCalledWith("desc");
|
|
expect(packagePage.paginate).toHaveBeenNthCalledWith(1, { cursor: null, numItems: 1 });
|
|
expect(packagePage.paginate).toHaveBeenNthCalledWith(2, { cursor: "1", numItems: 1 });
|
|
expect(firstPage.continueCursor).toBe("indexed:1");
|
|
expect(firstPage.isDone).toBe(false);
|
|
expect(secondPage.isDone).toBe(true);
|
|
expect(resumedLegacyPage.page.map((item) => item.displayName)).toEqual(["Low Download Plugin"]);
|
|
expect(resumedLegacyPage.isDone).toBe(true);
|
|
});
|
|
|
|
it("preserves mixed catalog pagination when kind is omitted", async () => {
|
|
const publisher = {
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const skill = {
|
|
_id: "skills:demo",
|
|
_creationTime: 2,
|
|
ownerPublisherId: publisher._id,
|
|
ownerUserId: "users:owner",
|
|
softDeletedAt: undefined,
|
|
slug: "demo",
|
|
displayName: "Demo Skill",
|
|
moderationStatus: "active",
|
|
statsSkillsShInstalls: 6,
|
|
stats: { downloads: 10, stars: 1, installsCurrent: 1, installsAllTime: 2 },
|
|
updatedAt: 5,
|
|
};
|
|
const pkg = {
|
|
_id: "packages:demo",
|
|
_creationTime: 3,
|
|
ownerPublisherId: publisher._id,
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
name: "@openclaw/demo",
|
|
displayName: "Demo Plugin",
|
|
stats: { downloads: 5, installs: 1, stars: 0, versions: 1 },
|
|
updatedAt: 4,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async () => null),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => publisher) };
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([skill]);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([pkg]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const firstPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "openclaw",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: null, numItems: 1 },
|
|
});
|
|
const secondPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "openclaw",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: firstPage.continueCursor, numItems: 1 },
|
|
});
|
|
|
|
expect(firstPage.page.map((item) => item.kind)).toEqual(["skill"]);
|
|
expect(firstPage.page[0]?.downloads).toBe(10);
|
|
expect(firstPage.continueCursor).toBe("1");
|
|
expect(firstPage.isDone).toBe(false);
|
|
expect(secondPage.page.map((item) => item.kind)).toEqual(["plugin"]);
|
|
expect(secondPage.isDone).toBe(true);
|
|
});
|
|
|
|
it("preserves canonical download order for legacy skills across pages", async () => {
|
|
const publisher = {
|
|
_id: "publishers:legacy",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "legacy",
|
|
displayName: "Legacy",
|
|
publishedSkills: 2,
|
|
publishedPackages: 0,
|
|
totalInstalls: 4,
|
|
totalDownloads: 77,
|
|
totalStars: 2,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const highDownloadLegacySkill = {
|
|
_id: "skills:legacy-high",
|
|
_creationTime: 1,
|
|
ownerPublisherId: publisher._id,
|
|
ownerUserId: "users:legacy",
|
|
softDeletedAt: undefined,
|
|
slug: "legacy-high",
|
|
displayName: "Legacy High",
|
|
moderationStatus: "active",
|
|
stats: { downloads: 70, stars: 1, installsCurrent: 1, installsAllTime: 2 },
|
|
updatedAt: 5,
|
|
};
|
|
const lowDownloadSkill = {
|
|
_id: "skills:modern-low",
|
|
_creationTime: 2,
|
|
ownerPublisherId: publisher._id,
|
|
ownerUserId: "users:legacy",
|
|
softDeletedAt: undefined,
|
|
slug: "modern-low",
|
|
displayName: "Modern Low",
|
|
moderationStatus: "active",
|
|
statsDownloads: 7,
|
|
statsSkillsShInstalls: 100,
|
|
stats: { downloads: 7, stars: 1, installsCurrent: 1, installsAllTime: 2 },
|
|
updatedAt: 6,
|
|
};
|
|
const legacyLookup = indexedRows([]);
|
|
legacyLookup.first.mockResolvedValue(highDownloadLegacySkill);
|
|
const legacyRows = indexedRows([lowDownloadSkill, highDownloadLegacySkill]);
|
|
const queriedTables: string[] = [];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === publisher._id ? publisher : null)),
|
|
query: vi.fn((table: string) => {
|
|
queriedTables.push(table);
|
|
return {
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => publisher) };
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (
|
|
table === "skills" &&
|
|
indexName === "by_owner_publisher_active_downloads" &&
|
|
"statsDownloads" in fields
|
|
) {
|
|
return legacyLookup;
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return legacyRows;
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
};
|
|
}),
|
|
},
|
|
};
|
|
|
|
const firstPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "legacy",
|
|
kind: "skill",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: null, numItems: 1 },
|
|
});
|
|
legacyLookup.first.mockResolvedValue(null);
|
|
expect(firstPage.page[0]?.displayName).toBe("Legacy High");
|
|
const secondPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "legacy",
|
|
kind: "skill",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: firstPage.continueCursor, numItems: 1 },
|
|
});
|
|
|
|
expect([...firstPage.page, ...secondPage.page].map((item) => item.displayName)).toEqual([
|
|
"Legacy High",
|
|
"Modern Low",
|
|
]);
|
|
expect(firstPage.continueCursor).toBe("legacy:1");
|
|
expect(secondPage.isDone).toBe(true);
|
|
expect(legacyLookup.first).toHaveBeenCalledTimes(1);
|
|
expect(legacyRows.collect).toHaveBeenCalledTimes(2);
|
|
expect(queriedTables).not.toContain("packages");
|
|
});
|
|
|
|
it("resumes untagged pre-index cursors with the original comparator across backfill", async () => {
|
|
const publisher = {
|
|
_id: "publishers:preindex",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "preindex",
|
|
displayName: "Pre-index",
|
|
publishedSkills: 3,
|
|
publishedPackages: 0,
|
|
totalInstalls: 3,
|
|
totalDownloads: 30,
|
|
totalStars: 6,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const originalRows = [
|
|
{
|
|
_id: "skills:old-a",
|
|
_creationTime: 1,
|
|
ownerPublisherId: publisher._id,
|
|
ownerUserId: "users:preindex",
|
|
softDeletedAt: undefined,
|
|
slug: "old-a",
|
|
displayName: "Old A",
|
|
moderationStatus: "active",
|
|
stats: { downloads: 10, stars: 3, installsCurrent: 1, installsAllTime: 1 },
|
|
updatedAt: 1,
|
|
},
|
|
{
|
|
_id: "skills:old-b",
|
|
_creationTime: 3,
|
|
ownerPublisherId: publisher._id,
|
|
ownerUserId: "users:preindex",
|
|
softDeletedAt: undefined,
|
|
slug: "old-b",
|
|
displayName: "Old B",
|
|
moderationStatus: "active",
|
|
stats: { downloads: 10, stars: 2, installsCurrent: 1, installsAllTime: 1 },
|
|
updatedAt: 3,
|
|
},
|
|
{
|
|
_id: "skills:old-c",
|
|
_creationTime: 2,
|
|
ownerPublisherId: publisher._id,
|
|
ownerUserId: "users:preindex",
|
|
softDeletedAt: undefined,
|
|
slug: "old-c",
|
|
displayName: "Old C",
|
|
moderationStatus: "active",
|
|
stats: { downloads: 10, stars: 1, installsCurrent: 1, installsAllTime: 1 },
|
|
updatedAt: 2,
|
|
},
|
|
];
|
|
let rows = originalRows;
|
|
const collect = vi.fn(async () => rows);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === publisher._id ? publisher : null)),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => publisher) };
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return { collect };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const resumedPage = await listPublishedPageHandler(ctx as never, {
|
|
handle: "preindex",
|
|
kind: "skill",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: "1", numItems: 1 },
|
|
});
|
|
rows = originalRows.map((row) => ({ ...row, statsDownloads: row.stats.downloads }));
|
|
const pageAfterBackfill = await listPublishedPageHandler(ctx as never, {
|
|
handle: "preindex",
|
|
kind: "skill",
|
|
sort: "downloads",
|
|
paginationOpts: { cursor: resumedPage.continueCursor, numItems: 1 },
|
|
});
|
|
|
|
expect(resumedPage.continueCursor).toBe("preindex:2");
|
|
expect([
|
|
"Old A",
|
|
...resumedPage.page.map((item) => item.displayName),
|
|
...pageAfterBackfill.page.map((item) => item.displayName),
|
|
]).toEqual(["Old A", "Old B", "Old C"]);
|
|
expect(pageAfterBackfill.isDone).toBe(true);
|
|
expect(collect).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("uses complete profile counters without reading resource or star tables", async () => {
|
|
const publisher = {
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
publishedSkills: 3,
|
|
publishedPackages: 2,
|
|
totalInstalls: 50,
|
|
totalDownloads: 80,
|
|
totalStars: 7,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const queriedTables: string[] = [];
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => {
|
|
queriedTables.push(table);
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => publisher) };
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
};
|
|
}),
|
|
},
|
|
};
|
|
|
|
const result = await getProfileByHandleHandler(ctx as never, { handle: "openclaw" });
|
|
|
|
expect(result).toEqual(
|
|
expect.objectContaining({
|
|
stats: { skills: 3, packages: 2, installs: 50, downloads: 80, stars: 7 },
|
|
}),
|
|
);
|
|
expect(result).not.toHaveProperty("publishedItems");
|
|
expect(queriedTables).not.toContain("skills");
|
|
expect(queriedTables).not.toContain("packages");
|
|
expect(queriedTables).not.toContain("stars");
|
|
});
|
|
|
|
it("computes OG metadata stats when publisher denormalized stats are missing", async () => {
|
|
const publisher = {
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const skill = {
|
|
_id: "skills:demo",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
statsSkillsShInstalls: 8,
|
|
stats: { downloads: 42, stars: 2, installsCurrent: 4, installsAllTime: 7 },
|
|
};
|
|
const pkg = {
|
|
_id: "packages:demo",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
stats: { downloads: 8, installs: 5, stars: 1 },
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "openclaw" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows(fields.ownerPublisherId === publisher._id ? [skill] : []);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows(fields.ownerPublisherId === publisher._id ? [pkg] : []);
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await getOgMetaByHandleHandler(ctx as never, { handle: "openclaw" });
|
|
|
|
expect(result?.stats).toEqual({
|
|
skills: 1,
|
|
packages: 1,
|
|
installs: 12,
|
|
downloads: 50,
|
|
stars: 3,
|
|
});
|
|
});
|
|
|
|
it("returns verified visible organization affiliations for user OG metadata", async () => {
|
|
const personalPublisher = {
|
|
_id: "publishers:teoslayer",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
userId: "users:teoslayer",
|
|
linkedUserId: "users:teoslayer",
|
|
handle: "teoslayer",
|
|
displayName: null,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
publishedSkills: 0,
|
|
publishedPackages: 0,
|
|
totalInstalls: 3,
|
|
totalDownloads: 10,
|
|
totalStars: 4,
|
|
statsDownloads: 10,
|
|
statsInstallsCurrent: 2,
|
|
statsInstallsAllTime: 3,
|
|
statsStars: 4,
|
|
stats: { downloads: 10, installsCurrent: 2, installsAllTime: 3, stars: 4 },
|
|
};
|
|
const user = {
|
|
_id: "users:teoslayer",
|
|
handle: "teoslayer",
|
|
displayName: "Calin Teodor",
|
|
image: "https://example.com/avatar.png",
|
|
bio: "Publisher @teoslayer on ClawHub.",
|
|
};
|
|
const openclawPublisher = {
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 2,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
image: "https://example.com/openclaw.png",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const deactivatedPublisher = {
|
|
_id: "publishers:archived",
|
|
_creationTime: 3,
|
|
kind: "org",
|
|
handle: "archived",
|
|
displayName: "Archived",
|
|
image: "https://example.com/archived.png",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
deactivatedAt: 2,
|
|
};
|
|
const irrelevantMemberships = Array.from({ length: 70 }, (_, index) => ({
|
|
_id: `publisherMembers:missing-${index}`,
|
|
publisherId: `publishers:missing-${index}`,
|
|
userId: user._id,
|
|
role: "publisher",
|
|
}));
|
|
const memberships = [
|
|
{
|
|
_id: "publisherMembers:self",
|
|
publisherId: personalPublisher._id,
|
|
userId: user._id,
|
|
role: "owner",
|
|
},
|
|
...irrelevantMemberships,
|
|
{
|
|
_id: "publisherMembers:openclaw",
|
|
publisherId: openclawPublisher._id,
|
|
userId: user._id,
|
|
role: "publisher",
|
|
},
|
|
{
|
|
_id: "publisherMembers:archived",
|
|
publisherId: deactivatedPublisher._id,
|
|
userId: user._id,
|
|
role: "publisher",
|
|
},
|
|
];
|
|
const publishersById = new Map<string, unknown>([
|
|
[personalPublisher._id, personalPublisher],
|
|
[openclawPublisher._id, openclawPublisher],
|
|
[deactivatedPublisher._id, deactivatedPublisher],
|
|
]);
|
|
const usersById = new Map<string, unknown>([[user._id, user]]);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => publishersById.get(id) ?? usersById.get(id) ?? null),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
fields.handle === personalPublisher.handle ? personalPublisher : null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publisherMembers" && indexName === "by_user") {
|
|
return indexedRows(fields.userId === user._id ? memberships : []);
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await getOgMetaByHandleHandler(ctx as never, { handle: "teoslayer" });
|
|
|
|
expect(result?.displayName).toBe("Calin Teodor");
|
|
expect(result?.affiliations).toEqual([
|
|
{
|
|
publisher: expect.objectContaining({
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
image: "https://example.com/openclaw.png",
|
|
}),
|
|
role: "publisher",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("bounds raw membership rows scanned for user OG metadata", async () => {
|
|
const personalPublisher = {
|
|
_id: "publishers:teoslayer",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
linkedUserId: "users:teoslayer",
|
|
handle: "teoslayer",
|
|
displayName: null,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
publishedSkills: 0,
|
|
publishedPackages: 0,
|
|
totalInstalls: 3,
|
|
totalDownloads: 10,
|
|
totalStars: 4,
|
|
statsDownloads: 10,
|
|
statsInstallsCurrent: 2,
|
|
statsInstallsAllTime: 3,
|
|
statsStars: 4,
|
|
stats: { downloads: 10, installsCurrent: 2, installsAllTime: 3, stars: 4 },
|
|
};
|
|
const user = {
|
|
_id: "users:teoslayer",
|
|
handle: "teoslayer",
|
|
displayName: "Calin Teodor",
|
|
image: "https://example.com/avatar.png",
|
|
bio: "Publisher @teoslayer on ClawHub.",
|
|
};
|
|
const memberships = [
|
|
{
|
|
_id: "publisherMembers:self",
|
|
publisherId: personalPublisher._id,
|
|
userId: user._id,
|
|
role: "owner",
|
|
},
|
|
...Array.from({ length: 600 }, (_, index) => ({
|
|
_id: `publisherMembers:missing-${index}`,
|
|
publisherId: `publishers:missing-${index}`,
|
|
userId: user._id,
|
|
role: "publisher",
|
|
})),
|
|
];
|
|
const usersById = new Map<string, unknown>([[user._id, user]]);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => usersById.get(id) ?? null),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
fields.handle === personalPublisher.handle ? personalPublisher : null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publisherMembers" && indexName === "by_user") {
|
|
return indexedRows(fields.userId === user._id ? memberships : []);
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await getOgMetaByHandleHandler(ctx as never, { handle: "teoslayer" });
|
|
|
|
expect(result?.affiliations).toEqual([]);
|
|
expect(ctx.db.get).toHaveBeenCalledWith("publishers:missing-510");
|
|
expect(ctx.db.get).not.toHaveBeenCalledWith("publishers:missing-511");
|
|
});
|
|
|
|
it("excludes hidden and removed skills from publisher catalogs", async () => {
|
|
const publisher = {
|
|
_id: "publishers:nvidia",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "nvidia",
|
|
displayName: "NVIDIA",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const skillRows = [
|
|
{
|
|
_id: "skills:visible",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
ownerUserId: "users:nvidia",
|
|
slug: "visible",
|
|
displayName: "Visible Skill",
|
|
summary: "Shown.",
|
|
icon: null,
|
|
moderationStatus: "active",
|
|
stats: { downloads: 3, stars: 1, installsCurrent: 0, installsAllTime: 0 },
|
|
updatedAt: 5,
|
|
},
|
|
{
|
|
_id: "skills:hidden",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
ownerUserId: "users:nvidia",
|
|
slug: "hidden",
|
|
displayName: "Hidden Skill",
|
|
summary: "Pending verification.",
|
|
icon: null,
|
|
moderationStatus: "hidden",
|
|
moderationReason: "pending.scan",
|
|
stats: { downloads: 10, stars: 1, installsCurrent: 0, installsAllTime: 0 },
|
|
updatedAt: 6,
|
|
},
|
|
{
|
|
_id: "skills:removed",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
ownerUserId: "users:nvidia",
|
|
slug: "removed",
|
|
displayName: "Removed Skill",
|
|
summary: "Removed upstream.",
|
|
icon: null,
|
|
moderationStatus: "removed",
|
|
moderationReason: "github.upstream.removed",
|
|
stats: { downloads: 9, stars: 1, installsCurrent: 0, installsAllTime: 0 },
|
|
updatedAt: 7,
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === "publishers:nvidia" ? publisher : null)),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "nvidia" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows(skillRows);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await listPublishedPageHandler(ctx as never, {
|
|
handle: "nvidia",
|
|
kind: "skill",
|
|
paginationOpts: { cursor: null, numItems: 12 },
|
|
});
|
|
|
|
expect(result.page.map((item) => item.displayName)).toEqual(["Visible Skill"]);
|
|
});
|
|
|
|
it("includes catalog icons but suppresses private plugin icon URLs (F7)", async () => {
|
|
// Regression guard for F2: listPublishedPage must mirror `skills.icon`
|
|
// onto the catalog DTO so the publisher profile page (/p/<handle>) can
|
|
// render the same custom glyph that SkillCard / SkillListItem show on
|
|
// /skills and /search. Public plugin icons mirror browse cards, but private
|
|
// plugin URLs must not leak through public publisher profiles.
|
|
const publisher = {
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === "publishers:openclaw" ? publisher : null)),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "openclaw" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows([
|
|
{
|
|
_id: "skills:icon-skill",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
slug: "icon-skill",
|
|
displayName: "Icon Skill",
|
|
summary: "Has a custom icon",
|
|
icon: "lucide:Plug",
|
|
stats: {
|
|
downloads: 10,
|
|
downloadsAllTime: 10,
|
|
installs: 5,
|
|
installsAllTime: 5,
|
|
stars: 2,
|
|
},
|
|
updatedAt: 8,
|
|
},
|
|
{
|
|
_id: "skills:plain-skill",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
slug: "plain-skill",
|
|
displayName: "Plain Skill",
|
|
summary: "No icon set",
|
|
// icon intentionally absent — must surface as null on the DTO
|
|
stats: {
|
|
downloads: 7,
|
|
downloadsAllTime: 7,
|
|
installs: 3,
|
|
installsAllTime: 3,
|
|
stars: 1,
|
|
},
|
|
updatedAt: 6,
|
|
},
|
|
]);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_downloads") {
|
|
return indexedRows([
|
|
{
|
|
_id: "packages:plugin",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
name: "@openclaw/example-plugin",
|
|
displayName: "Example Plugin",
|
|
summary: "A plugin",
|
|
channel: "community",
|
|
scanStatus: "clean",
|
|
icon: "https://cdn.simpleicons.org/github/111111",
|
|
stats: { downloads: 5, installs: 2, stars: 0, versions: 1 },
|
|
updatedAt: 4,
|
|
},
|
|
{
|
|
_id: "packages:blocked-plugin",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
name: "@openclaw/blocked-plugin",
|
|
displayName: "Blocked Plugin",
|
|
summary: "A blocked plugin",
|
|
channel: "community",
|
|
scanStatus: "malicious",
|
|
icon: "https://malicious.example/icon.png",
|
|
stats: { downloads: 1, installs: 1, stars: 0, versions: 1 },
|
|
updatedAt: 3,
|
|
},
|
|
{
|
|
_id: "packages:private-plugin",
|
|
ownerPublisherId: "publishers:openclaw",
|
|
softDeletedAt: undefined,
|
|
family: "code-plugin",
|
|
name: "@openclaw/private-plugin",
|
|
displayName: "Private Plugin",
|
|
summary: "A private plugin",
|
|
channel: "private",
|
|
scanStatus: "clean",
|
|
icon: "https://private.example/icon.png",
|
|
stats: { downloads: 1, installs: 1, stars: 0, versions: 1 },
|
|
updatedAt: 2,
|
|
},
|
|
]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const skillResult = await listPublishedPageHandler(ctx as never, {
|
|
handle: "openclaw",
|
|
kind: "skill",
|
|
paginationOpts: { cursor: null, numItems: 12 },
|
|
});
|
|
const pluginResult = await listPublishedPageHandler(ctx as never, {
|
|
handle: "openclaw",
|
|
kind: "plugin",
|
|
paginationOpts: { cursor: null, numItems: 12 },
|
|
});
|
|
const result = {
|
|
page: [...skillResult.page, ...pluginResult.page],
|
|
} as unknown as {
|
|
page: Array<{
|
|
displayName: string;
|
|
kind: "skill" | "plugin";
|
|
icon: string | null;
|
|
}>;
|
|
};
|
|
|
|
const byName = Object.fromEntries(result.page.map((item) => [item.displayName, item]));
|
|
// Skill with a stored icon must surface it on the DTO.
|
|
expect(byName["Icon Skill"]).toMatchObject({ kind: "skill", icon: "lucide:Plug" });
|
|
// Skill without a legacy icon must surface null (not undefined) so cached
|
|
// clients keep receiving a uniform response shape.
|
|
expect(byName["Plain Skill"]).toMatchObject({ kind: "skill", icon: null });
|
|
// Public plugins mirror the manifest icon used on browse cards.
|
|
expect(byName["Example Plugin"]).toMatchObject({
|
|
kind: "plugin",
|
|
icon: "https://cdn.simpleicons.org/github/111111",
|
|
});
|
|
// Blocked plugin icon URLs also stay out of public publisher profiles.
|
|
expect(byName["Blocked Plugin"]).toMatchObject({ kind: "plugin", icon: null });
|
|
// Private plugin icon URLs must not leak through public publisher profiles.
|
|
expect(byName["Private Plugin"]).toMatchObject({ kind: "plugin", icon: null });
|
|
});
|
|
|
|
it("returns GitHub-backed display manifest groups for publisher catalogs", async () => {
|
|
const publisher = {
|
|
_id: "publishers:nvidia",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "nvidia",
|
|
displayName: "NVIDIA",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const githubSource = {
|
|
_id: "githubSkillSources:nvidia",
|
|
repo: "NVIDIA/skills",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
displayManifestStatus: "ok",
|
|
displayManifest: {
|
|
notGrouped: "bottom",
|
|
groupings: [
|
|
{
|
|
title: "Agentic AI",
|
|
description: "Agentic AI skills.",
|
|
skills: ["aiq-deploy", "missing-entry"],
|
|
},
|
|
{
|
|
title: "Vision AI",
|
|
skills: ["vision-helper"],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
const skillRows = [
|
|
{
|
|
_id: "skills:aiq-deploy",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
softDeletedAt: undefined,
|
|
slug: "aiq-deploy",
|
|
displayName: "AIQ Deploy",
|
|
summary: "Deploy AgentIQ workflows.",
|
|
icon: null,
|
|
installKind: "github",
|
|
githubSourceId: "githubSkillSources:nvidia",
|
|
githubPath: "skills/aiq-deploy",
|
|
stats: { downloads: 10, stars: 2, installsCurrent: 1, installsAllTime: 3 },
|
|
updatedAt: 8,
|
|
},
|
|
{
|
|
_id: "skills:vision-helper",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
softDeletedAt: undefined,
|
|
slug: "vision-helper",
|
|
displayName: "Vision Helper",
|
|
summary: "Vision tools.",
|
|
icon: null,
|
|
installKind: "github",
|
|
githubSourceId: "githubSkillSources:nvidia",
|
|
githubPath: "skills/vision-helper",
|
|
stats: { downloads: 7, stars: 1, installsCurrent: 1, installsAllTime: 2 },
|
|
updatedAt: 6,
|
|
},
|
|
{
|
|
_id: "skills:other",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
softDeletedAt: undefined,
|
|
slug: "other",
|
|
displayName: "Other Skill",
|
|
summary: "Not listed in the manifest.",
|
|
icon: null,
|
|
installKind: "github",
|
|
githubSourceId: "githubSkillSources:nvidia",
|
|
githubPath: "skills/other",
|
|
stats: { downloads: 1, stars: 0, installsCurrent: 0, installsAllTime: 0 },
|
|
updatedAt: 2,
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === "publishers:nvidia" ? publisher : null)),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "nvidia" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows(skillRows);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "githubSkillSources" && indexName === "by_owner_publisher") {
|
|
return indexedRows([githubSource]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
const result = await getPublishedDisplayManifestHandler(ctx as never, {
|
|
handle: "nvidia",
|
|
kind: "skill",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
mode: "grouped",
|
|
sourceRepos: ["NVIDIA/skills"],
|
|
sections: [
|
|
{
|
|
title: "Agentic AI",
|
|
sourceRepo: "NVIDIA/skills",
|
|
items: [{ displayName: "AIQ Deploy", sourceBacked: true }],
|
|
},
|
|
{
|
|
title: "Vision AI",
|
|
sourceRepo: "NVIDIA/skills",
|
|
items: [{ displayName: "Vision Helper", sourceBacked: true }],
|
|
},
|
|
{
|
|
title: "Other skills",
|
|
sourceRepo: null,
|
|
items: [{ displayName: "Other Skill", sourceBacked: true }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("falls back to the normal catalog when no valid display manifest exists", async () => {
|
|
const publisher = {
|
|
_id: "publishers:nvidia",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "nvidia",
|
|
displayName: "NVIDIA",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === "publishers:nvidia" ? publisher : null)),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn((indexName: string, buildQuery: (q: unknown) => unknown) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
buildQuery(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "nvidia" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([
|
|
{
|
|
_id: "skills:aiq-deploy",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
softDeletedAt: undefined,
|
|
slug: "aiq-deploy",
|
|
displayName: "AIQ Deploy",
|
|
summary: "Deploy AgentIQ workflows.",
|
|
icon: null,
|
|
installKind: "github",
|
|
githubSourceId: "githubSkillSources:nvidia",
|
|
stats: { downloads: 10, stars: 2, installsCurrent: 1, installsAllTime: 3 },
|
|
updatedAt: 8,
|
|
},
|
|
]);
|
|
}
|
|
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
|
return indexedRows([]);
|
|
}
|
|
if (table === "githubSkillSources" && indexName === "by_owner_publisher") {
|
|
return indexedRows([
|
|
{
|
|
_id: "githubSkillSources:nvidia",
|
|
repo: "NVIDIA/skills",
|
|
ownerPublisherId: "publishers:nvidia",
|
|
displayManifestStatus: "invalid",
|
|
},
|
|
]);
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
}),
|
|
})),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
getPublishedDisplayManifestHandler(ctx as never, {
|
|
handle: "nvidia",
|
|
kind: "skill",
|
|
}),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
["missing", null],
|
|
["deleted", { _id: "users:proof-banned-builder", deletedAt: 1_700_000_000_000 }],
|
|
["deactivated", { _id: "users:proof-banned-builder", deactivatedAt: 1_700_000_000_000 }],
|
|
])("hides user publisher profiles when the linked user is %s", async (_state, linkedUser) => {
|
|
const ctx = makePublicPublisherVisibilityCtx({ linkedUser });
|
|
|
|
await expect(
|
|
getProfileByHandleHandler(ctx as never, { handle: "proof-banned-builder" }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
["missing", null],
|
|
["deleted", { _id: "users:proof-banned-builder", deletedAt: 1_700_000_000_000 }],
|
|
["deactivated", { _id: "users:proof-banned-builder", deactivatedAt: 1_700_000_000_000 }],
|
|
])(
|
|
"hides legacy no-link user publisher profiles when the owner user is %s",
|
|
async (_state, linkedUser) => {
|
|
const ctx = makePublicPublisherVisibilityCtx({
|
|
legacyPersonalPublisher: true,
|
|
linkedUser,
|
|
});
|
|
|
|
await expect(
|
|
getProfileByHandleHandler(ctx as never, { handle: "proof-banned-builder" }),
|
|
).resolves.toBeNull();
|
|
},
|
|
);
|
|
|
|
it("keeps active legacy no-link user publisher profiles visible through owner membership", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({ legacyPersonalPublisher: true });
|
|
|
|
const profile = await getProfileByHandleHandler(ctx as never, {
|
|
handle: "proof-banned-builder",
|
|
});
|
|
|
|
expect(profile).toEqual(expect.objectContaining({ handle: "proof-banned-builder" }));
|
|
expect(profile).not.toHaveProperty("starredCount");
|
|
});
|
|
|
|
it("hides published items for a user publisher whose linked user is deleted", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({
|
|
linkedUser: { _id: "users:proof-banned-builder", deletedAt: 1_700_000_000_000 },
|
|
});
|
|
|
|
await expect(
|
|
listPublishedPageHandler(ctx as never, {
|
|
handle: "proof-banned-builder",
|
|
kind: "skill",
|
|
paginationOpts: { cursor: null, numItems: 12 },
|
|
}),
|
|
).resolves.toEqual({ page: [], continueCursor: "", isDone: true });
|
|
});
|
|
|
|
it("hides published items for a legacy no-link user publisher whose owner is deleted", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({
|
|
legacyPersonalPublisher: true,
|
|
linkedUser: { _id: "users:proof-banned-builder", deletedAt: 1_700_000_000_000 },
|
|
});
|
|
|
|
await expect(
|
|
listPublishedPageHandler(ctx as never, {
|
|
handle: "proof-banned-builder",
|
|
kind: "skill",
|
|
paginationOpts: { cursor: null, numItems: 12 },
|
|
}),
|
|
).resolves.toEqual({ page: [], continueCursor: "", isDone: true });
|
|
});
|
|
|
|
it("hides display manifests for a user publisher whose linked user is deleted", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({
|
|
linkedUser: { _id: "users:proof-banned-builder", deletedAt: 1_700_000_000_000 },
|
|
});
|
|
|
|
await expect(
|
|
getPublishedDisplayManifestHandler(ctx as never, {
|
|
handle: "proof-banned-builder",
|
|
kind: "skill",
|
|
}),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("hides starred items for a user publisher whose linked user is deactivated", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({
|
|
linkedUser: { _id: "users:proof-banned-builder", deactivatedAt: 1_700_000_000_000 },
|
|
});
|
|
|
|
await expect(
|
|
listStarredPageHandler(ctx as never, {
|
|
handle: "proof-banned-builder",
|
|
paginationOpts: { cursor: null, numItems: 12 },
|
|
}),
|
|
).resolves.toEqual({ page: [], continueCursor: "", isDone: true });
|
|
});
|
|
|
|
it("uses the active legacy no-link user publisher owner for starred items", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({ legacyPersonalPublisher: true });
|
|
|
|
const result = await listStarredPageHandler(ctx as never, {
|
|
handle: "proof-banned-builder",
|
|
paginationOpts: { cursor: null, numItems: 12 },
|
|
});
|
|
|
|
expect(result.page.map((item) => item.displayName)).toEqual(["Demo Skill"]);
|
|
});
|
|
|
|
it("hides members for a user publisher whose linked user is deleted", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({
|
|
linkedUser: { _id: "users:proof-banned-builder", deletedAt: 1_700_000_000_000 },
|
|
});
|
|
|
|
await expect(
|
|
listMembersHandler(ctx as never, { publisherHandle: "proof-banned-builder" }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("hides members for a legacy no-link user publisher whose owner is deleted", async () => {
|
|
const ctx = makePublicPublisherVisibilityCtx({
|
|
legacyPersonalPublisher: true,
|
|
linkedUser: { _id: "users:proof-banned-builder", deletedAt: 1_700_000_000_000 },
|
|
});
|
|
|
|
await expect(
|
|
listMembersHandler(ctx as never, { publisherHandle: "proof-banned-builder" }),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
it("prevents admins from promoting members to owner", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "acme",
|
|
displayName: "Acme",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publisherMembers:admin",
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "peter", role: "owner" } as never,
|
|
),
|
|
).rejects.toThrow("Only org owners can promote members to owner");
|
|
});
|
|
|
|
it("prevents adding members to personal publishers", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
linkedUserId: "users:owner",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:personal",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:personal", userHandle: "friend", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow("Personal publishers do not support member management");
|
|
|
|
expect(ctx.db.insert).not.toHaveBeenCalled();
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets linked owners remove stale members from personal publishers", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
linkedUserId: "users:owner",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publisherMembers:friend",
|
|
publisherId: "publishers:personal",
|
|
userId: "users:friend",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
delete: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
removeMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:personal", userId: "users:friend" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true });
|
|
|
|
expect(ctx.db.delete).toHaveBeenCalledWith("publisherMembers:friend");
|
|
expect(ctx.db.insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.member.remove",
|
|
targetId: "publishers:personal",
|
|
metadata: { memberUserId: "users:friend" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("lets legacy no-link personal owners remove stale members by personal publisher link", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const memberships: Record<string, Record<string, unknown>> = {
|
|
"users:friend": {
|
|
_id: "publisherMembers:friend",
|
|
publisherId: "publishers:personal",
|
|
userId: "users:friend",
|
|
role: "admin",
|
|
},
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id, personalPublisherId: "publishers:personal" };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
linkedUserId: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let userId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "userId") userId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder(q);
|
|
return {
|
|
unique: vi.fn().mockResolvedValue(memberships[userId] ?? null),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
delete: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
removeMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:personal", userId: "users:friend" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true });
|
|
|
|
expect(ctx.db.delete).toHaveBeenCalledWith("publisherMembers:friend");
|
|
});
|
|
|
|
it("lets legacy no-link personal owners remove stale members by owner membership", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const memberships: Record<string, Record<string, unknown>> = {
|
|
"users:owner": {
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:personal",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
},
|
|
"users:friend": {
|
|
_id: "publisherMembers:friend",
|
|
publisherId: "publishers:personal",
|
|
userId: "users:friend",
|
|
role: "admin",
|
|
},
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
linkedUserId: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let userId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "userId") userId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder(q);
|
|
return {
|
|
unique: vi.fn().mockResolvedValue(memberships[userId] ?? null),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
delete: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
removeMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:personal", userId: "users:friend" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true });
|
|
|
|
expect(ctx.db.delete).toHaveBeenCalledWith("publisherMembers:friend");
|
|
});
|
|
|
|
it("prevents removing the linked owner from personal publishers", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "publishers:personal") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
linkedUserId: "users:owner",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:personal",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
delete: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
removeMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:personal", userId: "users:owner" } as never,
|
|
),
|
|
).rejects.toThrow("Personal publisher owner membership cannot be removed");
|
|
|
|
expect(ctx.db.delete).not.toHaveBeenCalled();
|
|
expect(ctx.db.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prevents removing the last remaining owner", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "acme",
|
|
displayName: "Acme",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_publisher_user") {
|
|
return {
|
|
unique: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
_id: "publisherMembers:owner-actor",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
_id: "publisherMembers:owner-target",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
}),
|
|
};
|
|
}
|
|
if (indexName === "by_publisher") {
|
|
return {
|
|
collect: vi.fn().mockResolvedValue([
|
|
{
|
|
_id: "publisherMembers:owner-target",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
},
|
|
]),
|
|
};
|
|
}
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
delete: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
removeMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userId: "users:owner" } as never,
|
|
),
|
|
).rejects.toThrow("Publisher must have at least one owner");
|
|
});
|
|
|
|
it("ignores inactive owner rows when removing org owners", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "users:inactive-owner") {
|
|
return {
|
|
_id: id,
|
|
handle: "inactive-owner",
|
|
deactivatedAt: 9_000,
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "acme",
|
|
displayName: "Acme",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_publisher_user") {
|
|
return {
|
|
unique: vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
_id: "publisherMembers:owner-actor",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
_id: "publisherMembers:owner-target",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
}),
|
|
};
|
|
}
|
|
if (indexName === "by_publisher") {
|
|
return {
|
|
collect: vi.fn().mockResolvedValue([
|
|
{
|
|
_id: "publisherMembers:owner-target",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
},
|
|
{
|
|
_id: "publisherMembers:inactive-owner",
|
|
publisherId: "publishers:org",
|
|
userId: "users:inactive-owner",
|
|
role: "owner",
|
|
},
|
|
]),
|
|
};
|
|
}
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
delete: vi.fn(),
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
removeMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userId: "users:owner" } as never,
|
|
),
|
|
).rejects.toThrow("Publisher must have at least one owner");
|
|
|
|
expect(ctx.db.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prevents adding a new org member without invitation acceptance", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const publisherMembers: Array<Record<string, unknown>> = [
|
|
{
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
},
|
|
];
|
|
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
if (table === "publisherMembers") {
|
|
const row = { _id: "publisherMembers:new", ...value };
|
|
publisherMembers.push(row);
|
|
return row._id;
|
|
}
|
|
if (table === "auditLogs") return "auditLogs:1";
|
|
if (table === "publishers") return "publishers:jaredforreal";
|
|
throw new Error(`unexpected insert ${table}`);
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "users:jared") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
handle: undefined,
|
|
name: "JaredForReal",
|
|
displayName: "Jared",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "zai-org",
|
|
displayName: "ZAI Org",
|
|
};
|
|
}
|
|
if (id === "publishers:jaredforreal") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "jaredforreal",
|
|
displayName: "Jared",
|
|
linkedUserId: "users:jared",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (indexName !== "by_publisher_user") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
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 === "users") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (indexName !== "handle") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
let handle = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (handle === "owner") return { _id: "users:owner", handle: "owner" };
|
|
return null;
|
|
}),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: 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);
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (indexName === "by_handle" && handle === "jaredforreal") {
|
|
return {
|
|
_id: "publishers:jaredforreal",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "jaredforreal",
|
|
displayName: "Jared",
|
|
linkedUserId: "users:jared",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
if (indexName === "by_linked_user" && linkedUserId === "users:jared") {
|
|
return {
|
|
_id: "publishers:jaredforreal",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "jaredforreal",
|
|
displayName: "Jared",
|
|
linkedUserId: "users:jared",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
insert,
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "jaredforreal", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow(
|
|
"New organization members must accept an invitation before they can be added",
|
|
);
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
function makeInviteCtx(options?: {
|
|
authUserId?: string;
|
|
actorRole?: "owner" | "admin" | "publisher";
|
|
invites?: Array<Record<string, unknown>>;
|
|
strangerHandle?: string;
|
|
targetIsMember?: boolean;
|
|
targetRole?: "owner" | "admin" | "publisher";
|
|
inactiveOwnerMember?: boolean;
|
|
}) {
|
|
const publisherMembers: Array<Record<string, unknown>> = [
|
|
{
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: options?.actorRole ?? "owner",
|
|
},
|
|
];
|
|
if (options?.targetIsMember) {
|
|
publisherMembers.push({
|
|
_id: "publisherMembers:target",
|
|
publisherId: "publishers:org",
|
|
userId: "users:target",
|
|
role: options.targetRole ?? "publisher",
|
|
});
|
|
}
|
|
if (options?.inactiveOwnerMember) {
|
|
publisherMembers.push({
|
|
_id: "publisherMembers:inactive-owner",
|
|
publisherId: "publishers:org",
|
|
userId: "users:inactive-owner",
|
|
role: "owner",
|
|
});
|
|
}
|
|
const publisherInvites = new Map<string, Record<string, unknown>>();
|
|
for (const invite of options?.invites ?? []) {
|
|
publisherInvites.set(String(invite._id), invite);
|
|
}
|
|
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
if (table === "publisherInvites") {
|
|
const id = `publisherInvites:${publisherInvites.size + 1}`;
|
|
publisherInvites.set(id, { _id: id, ...value });
|
|
return id;
|
|
}
|
|
if (table === "publisherMembers") {
|
|
const id = `publisherMembers:${publisherMembers.length + 1}`;
|
|
publisherMembers.push({ _id: id, ...value });
|
|
return id;
|
|
}
|
|
if (table === "auditLogs") return `auditLogs:${insert.mock.calls.length}`;
|
|
throw new Error(`unexpected insert ${table}`);
|
|
});
|
|
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
|
const invite = publisherInvites.get(id);
|
|
if (invite) {
|
|
Object.assign(invite, value);
|
|
return;
|
|
}
|
|
const member = publisherMembers.find((row) => row._id === id);
|
|
if (member) {
|
|
Object.assign(member, value);
|
|
return;
|
|
}
|
|
throw new Error(`unexpected patch ${id}`);
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id, handle: "owner", displayName: "Owner" };
|
|
if (id === "users:target") return { _id: id, handle: "target", displayName: "Target" };
|
|
if (id === "users:inactive-owner") {
|
|
return {
|
|
_id: id,
|
|
handle: "inactive-owner",
|
|
displayName: "Inactive Owner",
|
|
deactivatedAt: 9_000,
|
|
};
|
|
}
|
|
if (id === "users:stranger") {
|
|
return {
|
|
_id: id,
|
|
handle: options?.strangerHandle ?? "stranger",
|
|
displayName: "Stranger",
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return { _id: id, kind: "org", handle: "acme", displayName: "Acme" };
|
|
}
|
|
if (id === "publishers:target") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "target",
|
|
displayName: "Target",
|
|
linkedUserId: "users:target",
|
|
};
|
|
}
|
|
return publisherInvites.get(id) ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: {
|
|
eq: (field: string, value: string) => unknown;
|
|
gte: (field: string, value: number) => unknown;
|
|
}) => unknown,
|
|
) => {
|
|
const fields: Record<string, string> = {};
|
|
const lowerBounds: Record<string, number> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
gte: (field: string, value: number) => {
|
|
lowerBounds[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
if (table === "publisherMembers" && indexName === "by_publisher_user") {
|
|
return {
|
|
unique: vi.fn(
|
|
async () =>
|
|
publisherMembers.find(
|
|
(member) =>
|
|
member.publisherId === fields.publisherId &&
|
|
member.userId === fields.userId,
|
|
) ?? null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherMembers" && indexName === "by_publisher") {
|
|
return {
|
|
collect: vi.fn(async () =>
|
|
publisherMembers.filter((member) => member.publisherId === fields.publisherId),
|
|
),
|
|
};
|
|
}
|
|
if (table === "users" && indexName === "handle") {
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (fields.handle === "owner") {
|
|
return { _id: "users:owner", handle: "owner", displayName: "Owner" };
|
|
}
|
|
if (fields.handle === "target") {
|
|
return { _id: "users:target", handle: "target", displayName: "Target" };
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "publishers" && indexName === "by_linked_user") {
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
fields.linkedUserId === "users:target"
|
|
? {
|
|
_id: "publishers:target",
|
|
kind: "user",
|
|
handle: "target",
|
|
displayName: "Target",
|
|
linkedUserId: "users:target",
|
|
}
|
|
: null,
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherInvites" && indexName === "by_publisher_status_expires") {
|
|
return {
|
|
take: vi.fn(async (limit: number) =>
|
|
[...publisherInvites.values()]
|
|
.filter(
|
|
(invite) =>
|
|
invite.publisherId === fields.publisherId &&
|
|
invite.status === fields.status &&
|
|
Number(invite.expiresAt) >= lowerBounds.expiresAt,
|
|
)
|
|
.sort((left, right) => Number(left.expiresAt) - Number(right.expiresAt))
|
|
.slice(0, limit),
|
|
),
|
|
};
|
|
}
|
|
if (
|
|
table === "publisherInvites" &&
|
|
indexName === "by_publisher_target_status_expires"
|
|
) {
|
|
return {
|
|
take: vi.fn(async (limit: number) =>
|
|
[...publisherInvites.values()]
|
|
.filter(
|
|
(invite) =>
|
|
invite.publisherId === fields.publisherId &&
|
|
invite.targetHandle === fields.targetHandle &&
|
|
invite.status === fields.status &&
|
|
Number(invite.expiresAt) >= lowerBounds.expiresAt,
|
|
)
|
|
.sort((left, right) => Number(left.expiresAt) - Number(right.expiresAt))
|
|
.slice(0, limit),
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherInvites" && indexName === "by_target_handle_status_expires") {
|
|
return {
|
|
take: vi.fn(async (limit: number) =>
|
|
[...publisherInvites.values()]
|
|
.filter(
|
|
(invite) =>
|
|
invite.targetHandle === fields.targetHandle &&
|
|
invite.status === fields.status &&
|
|
Number(invite.expiresAt) >= lowerBounds.expiresAt,
|
|
)
|
|
.sort((left, right) => Number(left.expiresAt) - Number(right.expiresAt))
|
|
.slice(0, limit),
|
|
),
|
|
};
|
|
}
|
|
if (
|
|
table === "publisherInvites" &&
|
|
indexName === "by_publisher_target_user_status_expires"
|
|
) {
|
|
return {
|
|
take: vi.fn(async (limit: number) =>
|
|
[...publisherInvites.values()]
|
|
.filter(
|
|
(invite) =>
|
|
invite.publisherId === fields.publisherId &&
|
|
invite.targetUserId === fields.targetUserId &&
|
|
invite.status === fields.status &&
|
|
Number(invite.expiresAt) >= lowerBounds.expiresAt,
|
|
)
|
|
.sort((left, right) => Number(left.expiresAt) - Number(right.expiresAt))
|
|
.slice(0, limit),
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherInvites" && indexName === "by_target_user_status_expires") {
|
|
return {
|
|
take: vi.fn(async (limit: number) =>
|
|
[...publisherInvites.values()]
|
|
.filter(
|
|
(invite) =>
|
|
invite.targetUserId === fields.targetUserId &&
|
|
invite.status === fields.status &&
|
|
Number(invite.expiresAt) >= lowerBounds.expiresAt,
|
|
)
|
|
.sort((left, right) => Number(left.expiresAt) - Number(right.expiresAt))
|
|
.slice(0, limit),
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected query ${table}.${indexName}`);
|
|
},
|
|
),
|
|
})),
|
|
insert,
|
|
patch,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
vi.mocked(getAuthUserId).mockResolvedValue((options?.authUserId ?? "users:owner") as never);
|
|
return { ctx, insert, patch, publisherInvites, publisherMembers };
|
|
}
|
|
|
|
it("creates pending organization member invitations instead of membership rows", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert } = makeInviteCtx();
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "admin" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true, inviteId: "publisherInvites:1" });
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"publisherInvites",
|
|
expect.objectContaining({
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
}),
|
|
);
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.member.invite.create",
|
|
targetId: "publishers:org",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("prevents admins from inviting new org owners", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert } = makeInviteCtx({ actorRole: "admin" });
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "owner" } as never,
|
|
),
|
|
).rejects.toThrow("Only org owners can invite new owners");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherInvites", expect.anything());
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
});
|
|
|
|
it("prevents admins from demoting org owners through member role updates", async () => {
|
|
const { ctx, patch } = makeInviteCtx({
|
|
actorRole: "admin",
|
|
targetIsMember: true,
|
|
targetRole: "owner",
|
|
});
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "publisher" } as never,
|
|
),
|
|
).rejects.toThrow("Only org owners can demote owners");
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prevents demoting the last remaining org owner through member role updates", async () => {
|
|
const { ctx, patch } = makeInviteCtx();
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "owner", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow("Publisher must have at least one owner");
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores inactive owner rows when demoting org owners", async () => {
|
|
const { ctx, patch } = makeInviteCtx({ inactiveOwnerMember: true });
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "owner", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow("Publisher must have at least one owner");
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lets org owners demote another owner when one owner remains", async () => {
|
|
const { ctx, patch } = makeInviteCtx({ targetIsMember: true, targetRole: "owner" });
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "admin" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true });
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherMembers:target",
|
|
expect.objectContaining({ role: "admin" }),
|
|
);
|
|
});
|
|
|
|
it("rejects invitations for unresolved handles", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert } = makeInviteCtx();
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "typo-target", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow('User "@typo-target" not found');
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherInvites", expect.anything());
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
});
|
|
|
|
it("rejects duplicate active member invitations", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx } = makeInviteCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:existing",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow("@target already has a pending invitation");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("rejects duplicate active invitations when an older pending invite is expired", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert } = makeInviteCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:expired",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 1_000,
|
|
updatedAt: 1_000,
|
|
expiresAt: 9_000,
|
|
},
|
|
{
|
|
_id: "publisherInvites:active",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow("@target already has a pending invitation");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherInvites", expect.anything());
|
|
});
|
|
|
|
it("allows a fresh invitation when the only matching pending invite is expired", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert } = makeInviteCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:expired",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 1_000,
|
|
updatedAt: 1_000,
|
|
expiresAt: 9_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "admin" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true, inviteId: "publisherInvites:2" });
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"publisherInvites",
|
|
expect.objectContaining({
|
|
publisherId: "publishers:org",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects duplicate active invitations for the same resolved user", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert } = makeInviteCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:active",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target-personal",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "admin" } as never,
|
|
),
|
|
).rejects.toThrow("@target already has a pending invitation");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherInvites", expect.anything());
|
|
});
|
|
|
|
it("allows a fresh invitation when a handle was reclaimed from another pending invite target", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert } = makeInviteCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:old-target",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:stranger",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
createMemberInviteHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "target", role: "admin" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true, inviteId: "publisherInvites:2" });
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"publisherInvites",
|
|
expect.objectContaining({
|
|
publisherId: "publishers:org",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts a matching invitation and creates the membership row", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert, patch } = makeInviteCtx({
|
|
authUserId: "users:target",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:accepted",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
acceptMemberInviteHandler(
|
|
ctx as never,
|
|
{
|
|
inviteId: "publisherInvites:accepted",
|
|
} as never,
|
|
),
|
|
).resolves.toEqual({ ok: true });
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"publisherMembers",
|
|
expect.objectContaining({
|
|
publisherId: "publishers:org",
|
|
userId: "users:target",
|
|
role: "admin",
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherInvites:accepted",
|
|
expect.objectContaining({
|
|
status: "accepted",
|
|
acceptedByUserId: "users:target",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects accepting expired pending invitations before they are pruned", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert, patch } = makeInviteCtx({
|
|
authUserId: "users:target",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:expired",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 1_000,
|
|
updatedAt: 1_000,
|
|
expiresAt: 9_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
acceptMemberInviteHandler(
|
|
ctx as never,
|
|
{
|
|
inviteId: "publisherInvites:expired",
|
|
} as never,
|
|
),
|
|
).rejects.toThrow("Invitation has expired");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let stale invitations overwrite an existing member role", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert, patch, publisherMembers } = makeInviteCtx({
|
|
authUserId: "users:target",
|
|
targetIsMember: true,
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:accepted",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "owner",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
acceptMemberInviteHandler(
|
|
ctx as never,
|
|
{
|
|
inviteId: "publisherInvites:accepted",
|
|
} as never,
|
|
),
|
|
).resolves.toEqual({ ok: true });
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
expect(publisherMembers).toContainEqual(
|
|
expect.objectContaining({
|
|
_id: "publisherMembers:target",
|
|
role: "publisher",
|
|
}),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherInvites:accepted",
|
|
expect.objectContaining({
|
|
status: "accepted",
|
|
acceptedByUserId: "users:target",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not let non-target users accept member invitations", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert, patch } = makeInviteCtx({
|
|
authUserId: "users:stranger",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:target",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
acceptMemberInviteHandler(ctx as never, { inviteId: "publisherInvites:target" } as never),
|
|
).rejects.toThrow("Forbidden");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let a reclaimed handle accept an invitation bound to another user", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, insert, patch } = makeInviteCtx({
|
|
authUserId: "users:stranger",
|
|
strangerHandle: "target",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:target",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
acceptMemberInviteHandler(ctx as never, { inviteId: "publisherInvites:target" } as never),
|
|
).rejects.toThrow("Forbidden");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(insert).not.toHaveBeenCalledWith("publisherMembers", expect.anything());
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not list handle-matched invitations bound to another user", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx } = makeInviteCtx({
|
|
authUserId: "users:stranger",
|
|
strangerHandle: "target",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:bound-to-original",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 8_000,
|
|
updatedAt: 8_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
{
|
|
_id: "publisherInvites:handle-only",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
await expect(listMyInvitesHandler(ctx as never, {})).resolves.toEqual([
|
|
expect.objectContaining({ _id: "publisherInvites:handle-only" }),
|
|
]);
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("lets the invite target decline member invitations", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, patch, insert } = makeInviteCtx({
|
|
authUserId: "users:target",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:target",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
declineMemberInviteHandler(ctx as never, { inviteId: "publisherInvites:target" } as never),
|
|
).resolves.toEqual({ ok: true });
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherInvites:target",
|
|
expect.objectContaining({
|
|
status: "declined",
|
|
declinedByUserId: "users:target",
|
|
targetUserId: "users:target",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.member.invite.decline",
|
|
targetId: "publishers:org",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not let non-target users decline member invitations", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, patch, insert } = makeInviteCtx({
|
|
authUserId: "users:stranger",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:target",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
|
|
try {
|
|
await expect(
|
|
declineMemberInviteHandler(ctx as never, { inviteId: "publisherInvites:target" } as never),
|
|
).rejects.toThrow("Forbidden");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
expect(insert).not.toHaveBeenCalledWith("auditLogs", expect.anything());
|
|
});
|
|
|
|
it("lets org managers revoke pending member invitations", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, patch, insert } = makeInviteCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:pending",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
revokeMemberInviteHandler(ctx as never, { inviteId: "publisherInvites:pending" } as never),
|
|
).resolves.toEqual({ ok: true });
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherInvites:pending",
|
|
expect.objectContaining({
|
|
status: "revoked",
|
|
revokedByUserId: "users:owner",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({ action: "publisher.member.invite.revoke" }),
|
|
);
|
|
});
|
|
|
|
it("prevents admins from revoking pending owner invitations", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx, patch } = makeInviteCtx({
|
|
actorRole: "admin",
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:pending",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "owner",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
revokeMemberInviteHandler(ctx as never, { inviteId: "publisherInvites:pending" } as never),
|
|
).rejects.toThrow("Only org owners can revoke owner invitations");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
|
|
expect(patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists only pending non-expired publisher invitations for org managers", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx } = makeInviteCtx({
|
|
invites: [
|
|
{
|
|
_id: "publisherInvites:active",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "admin",
|
|
status: "pending",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
{
|
|
_id: "publisherInvites:expired",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "pending",
|
|
createdAt: 1_000,
|
|
updatedAt: 1_000,
|
|
expiresAt: 9_000,
|
|
},
|
|
{
|
|
_id: "publisherInvites:revoked",
|
|
publisherId: "publishers:org",
|
|
inviterUserId: "users:owner",
|
|
targetHandle: "target",
|
|
targetUserId: "users:target",
|
|
role: "publisher",
|
|
status: "revoked",
|
|
createdAt: 9_000,
|
|
updatedAt: 9_000,
|
|
expiresAt: 20_000,
|
|
},
|
|
],
|
|
});
|
|
try {
|
|
await expect(
|
|
listInvitesForPublisherHandler(
|
|
ctx as never,
|
|
{
|
|
publisherId: "publishers:org",
|
|
} as never,
|
|
),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({
|
|
_id: "publisherInvites:active",
|
|
targetHandle: "target",
|
|
role: "admin",
|
|
publisher: expect.objectContaining({ handle: "acme" }),
|
|
inviter: expect.objectContaining({ handle: "owner" }),
|
|
targetUser: expect.objectContaining({ handle: "target" }),
|
|
}),
|
|
]);
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("prevents publisher-role members from listing pending publisher invitations", async () => {
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
const { ctx } = makeInviteCtx({ actorRole: "publisher" });
|
|
try {
|
|
await expect(
|
|
listInvitesForPublisherHandler(
|
|
ctx as never,
|
|
{
|
|
publisherId: "publishers:org",
|
|
} as never,
|
|
),
|
|
).rejects.toThrow("Forbidden");
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("lets org owners update an existing member role", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never);
|
|
const publisherMembers: Array<Record<string, unknown>> = [
|
|
{
|
|
_id: "publisherMembers:owner",
|
|
publisherId: "publishers:org",
|
|
userId: "users:owner",
|
|
role: "owner",
|
|
},
|
|
{
|
|
_id: "publisherMembers:jared",
|
|
publisherId: "publishers:org",
|
|
userId: "users:jared",
|
|
role: "publisher",
|
|
},
|
|
];
|
|
const insert = vi.fn(async (table: string) => {
|
|
if (table === "auditLogs") return "auditLogs:1";
|
|
throw new Error(`unexpected insert ${table}`);
|
|
});
|
|
const patch = vi.fn(async () => {});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:owner") return { _id: id };
|
|
if (id === "users:jared") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
handle: "jaredforreal",
|
|
name: "JaredForReal",
|
|
displayName: "Jared",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "zai-org",
|
|
displayName: "ZAI Org",
|
|
};
|
|
}
|
|
if (id === "publishers:jaredforreal") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "jaredforreal",
|
|
displayName: "Jared",
|
|
linkedUserId: "users:jared",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (indexName !== "by_publisher_user") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
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 === "users") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
if (indexName !== "handle") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
let handle = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
handle === "jaredforreal"
|
|
? {
|
|
_id: "users:jared",
|
|
handle: "jaredforreal",
|
|
displayName: "Jared",
|
|
}
|
|
: null,
|
|
),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let linkedUserId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "linkedUserId") linkedUserId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (indexName === "by_linked_user" && linkedUserId === "users:jared") {
|
|
return {
|
|
_id: "publishers:jaredforreal",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "jaredforreal",
|
|
displayName: "Jared",
|
|
linkedUserId: "users:jared",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
insert,
|
|
patch,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
addMemberHandler(
|
|
ctx as never,
|
|
{ publisherId: "publishers:org", userHandle: "jaredforreal", role: "admin" } as never,
|
|
),
|
|
).resolves.toEqual({ ok: true });
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherMembers:jared",
|
|
expect.objectContaining({ role: "admin" }),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.member.upsert",
|
|
targetId: "publishers:org",
|
|
metadata: {
|
|
memberUserId: "users:jared",
|
|
memberHandle: "jaredforreal",
|
|
role: "admin",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("lets org admins update org profile fields", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "shopify",
|
|
displayName: "Shopify",
|
|
image: undefined,
|
|
bio: undefined,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publisherMembers:admin",
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
updateProfileHandler(
|
|
ctx as never,
|
|
{
|
|
publisherId: "publishers:org",
|
|
displayName: "Shopify",
|
|
bio: "Commerce platform",
|
|
} as never,
|
|
),
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
publisher: expect.objectContaining({
|
|
_id: "publishers:org",
|
|
displayName: "Shopify",
|
|
}),
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:org",
|
|
expect.objectContaining({
|
|
displayName: "Shopify",
|
|
bio: "Commerce platform",
|
|
}),
|
|
);
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.profile.update",
|
|
targetId: "publishers:org",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("links only a freshly verified GitHub organization membership", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const publisher = {
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "cua",
|
|
displayName: "Cua",
|
|
image: undefined,
|
|
bio: undefined,
|
|
};
|
|
const patch = vi.fn(async () => {});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") return publisher;
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publisherMembers:admin",
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "githubOrgMemberships") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
userId: "users:admin",
|
|
githubOrgId: "42",
|
|
login: "trycua",
|
|
role: "member",
|
|
syncedAt: Date.now(),
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") return emptyOfficialPublishersQuery();
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert: vi.fn(async () => "auditLogs:1"),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await updateProfileHandler(ctx as never, {
|
|
publisherId: "publishers:org",
|
|
displayName: "Cua",
|
|
githubOrgId: "42",
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:org",
|
|
expect.objectContaining({
|
|
githubHandle: "trycua",
|
|
githubOrgId: "42",
|
|
githubVerifiedAt: expect.any(Number),
|
|
githubVerifiedByUserId: "users:admin",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects stale GitHub organization membership verification", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "cua",
|
|
displayName: "Cua",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "githubOrgMemberships") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
userId: "users:admin",
|
|
githubOrgId: "42",
|
|
login: "trycua",
|
|
role: "member",
|
|
syncedAt: Date.now() - 16 * 60 * 1000,
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch: vi.fn(),
|
|
insert: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
updateProfileHandler(ctx as never, {
|
|
publisherId: "publishers:org",
|
|
displayName: "Cua",
|
|
githubOrgId: "42",
|
|
}),
|
|
).rejects.toThrow("Reconnect GitHub to verify your organization membership");
|
|
});
|
|
|
|
it("issues logo upload tickets only to org admins", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const insert = vi.fn(async () => "publisherImageUploadTickets:1");
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
return { _id: id, kind: "org", handle: "shopify", displayName: "Shopify" };
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
insert,
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
storage: {
|
|
generateUploadUrl: vi.fn(async () => "https://storage.example/upload"),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
createImageUploadHandler(ctx as never, { publisherId: "publishers:org" }),
|
|
).resolves.toEqual({
|
|
uploadUrl: "https://storage.example/upload",
|
|
uploadTicket: "publisherImageUploadTickets:1",
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"publisherImageUploadTickets",
|
|
expect.objectContaining({
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
expiresAt: expect.any(Number),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts a validated uploaded logo and removes the previous stored image", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const publisher = {
|
|
_id: "publishers:org",
|
|
kind: "org",
|
|
handle: "shopify",
|
|
displayName: "Shopify",
|
|
image: "https://storage.example/old",
|
|
imageStorageId: "storage:old",
|
|
};
|
|
let publisherReadCount = 0;
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const deleteStorage = vi.fn(async () => {});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
publisherReadCount += 1;
|
|
return publisherReadCount === 1
|
|
? publisher
|
|
: {
|
|
...publisher,
|
|
image: "https://storage.example/new-logo",
|
|
imageStorageId: "storage:new",
|
|
};
|
|
}
|
|
if (id === "publisherImageUploadTickets:1") {
|
|
return {
|
|
_id: id,
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
createdAt: 10,
|
|
expiresAt: Date.now() + 10_000,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
system: {
|
|
get: vi.fn(async () => ({
|
|
_creationTime: 20,
|
|
contentType: "image/webp",
|
|
size: 1000,
|
|
})),
|
|
},
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") return emptyOfficialPublishersQuery();
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
storage: {
|
|
getUrl: vi.fn(async () => "https://storage.example/new-logo"),
|
|
delete: deleteStorage,
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
updateProfileHandler(ctx as never, {
|
|
publisherId: "publishers:org",
|
|
displayName: "Shopify",
|
|
imageStorageId: "storage:new",
|
|
imageUploadTicket: "publisherImageUploadTickets:1",
|
|
}),
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
publisher: expect.objectContaining({
|
|
_id: "publishers:org",
|
|
image: "https://storage.example/new-logo",
|
|
}),
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publisherImageUploadTickets:1",
|
|
expect.objectContaining({ storageId: "storage:new", usedAt: expect.any(Number) }),
|
|
);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:org",
|
|
expect.objectContaining({
|
|
image: "https://storage.example/new-logo",
|
|
imageStorageId: "storage:new",
|
|
}),
|
|
);
|
|
expect(deleteStorage).toHaveBeenCalledWith("storage:old");
|
|
});
|
|
|
|
it("preserves an existing stored logo during ordinary profile edits", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const patch = vi.fn(async () => {});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "shopify",
|
|
displayName: "Shopify Labs",
|
|
image: "https://storage.example/current-logo",
|
|
imageStorageId: "storage:current",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") return emptyOfficialPublishersQuery();
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch,
|
|
insert: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
storage: {
|
|
delete: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
updateProfileHandler(ctx as never, {
|
|
publisherId: "publishers:org",
|
|
displayName: "Shopify Labs",
|
|
image: "https://storage.example/current-logo",
|
|
}),
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
publisher: expect.objectContaining({
|
|
handle: "shopify",
|
|
image: "https://storage.example/current-logo",
|
|
}),
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:org",
|
|
expect.objectContaining({
|
|
displayName: "Shopify Labs",
|
|
image: "https://storage.example/current-logo",
|
|
imageStorageId: "storage:current",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects direct org profile image URL changes", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:admin" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id };
|
|
if (id === "publishers:org") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "shopify",
|
|
displayName: "Shopify",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publisherMembers:admin",
|
|
publisherId: "publishers:org",
|
|
userId: "users:admin",
|
|
role: "admin",
|
|
}),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
patch: vi.fn(),
|
|
insert: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
updateProfileHandler(
|
|
ctx as never,
|
|
{
|
|
publisherId: "publishers:org",
|
|
displayName: "Shopify",
|
|
image: "not-a-url",
|
|
} as never,
|
|
),
|
|
).rejects.toThrow("Logo changes require an uploaded image");
|
|
});
|
|
});
|
|
|
|
describe("publisher audit logs", () => {
|
|
it("audits org trusted-publisher changes", async () => {
|
|
const patch = vi.fn();
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id, role: "admin" };
|
|
if (id === "publishers:openclaw") {
|
|
return {
|
|
_id: id,
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
trustedPublisher: false,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
patch,
|
|
insert,
|
|
query: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await setTrustedPublisherInternalHandler(ctx, {
|
|
actorUserId: "users:admin",
|
|
publisherId: "publishers:openclaw",
|
|
trustedPublisher: true,
|
|
});
|
|
|
|
expect(patch).toHaveBeenCalledWith("publishers:openclaw", {
|
|
trustedPublisher: true,
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.trusted.set",
|
|
actorUserId: "users:admin",
|
|
targetType: "publisher",
|
|
targetId: "publishers:openclaw",
|
|
metadata: {
|
|
handle: "openclaw",
|
|
previousTrustedPublisher: false,
|
|
trustedPublisher: true,
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("official publisher administration", () => {
|
|
it("marks personal publishers official", async () => {
|
|
const actor = { _id: "users:admin", role: "admin" };
|
|
const publisher = {
|
|
_id: "publishers:steipete",
|
|
kind: "user",
|
|
handle: "steipete",
|
|
displayName: "Peter Steinberger",
|
|
linkedUserId: "users:steipete",
|
|
};
|
|
const inserted: Array<{ table: string; doc: Record<string, unknown> }> = [];
|
|
const query = vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
const fields: Record<string, string> = {};
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder(q);
|
|
if (table === "publishers" && indexName === "by_handle") {
|
|
return {
|
|
unique: vi.fn(async () => (fields.handle === "steipete" ? publisher : null)),
|
|
};
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_publisher") {
|
|
return { unique: vi.fn(async () => null) };
|
|
}
|
|
if (table === "officialPublishers" && indexName === "by_created") {
|
|
return { take: vi.fn(async () => []) };
|
|
}
|
|
throw new Error(`unexpected ${table} index ${indexName}`);
|
|
},
|
|
),
|
|
}));
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => (id === "users:admin" ? actor : null)),
|
|
query,
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
insert: vi.fn(async (table: string, doc: Record<string, unknown>) => {
|
|
inserted.push({ table, doc });
|
|
return table === "officialPublishers"
|
|
? "officialPublishers:steipete"
|
|
: `auditLogs:${inserted.length}`;
|
|
}),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
addOfficialPublisherInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
handle: "@steipete",
|
|
reason: "Verified individual publisher",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
added: true,
|
|
publisherId: "publishers:steipete",
|
|
handle: "steipete",
|
|
officialPublisherId: "officialPublishers:steipete",
|
|
});
|
|
expect(ctx.db.insert).toHaveBeenCalledWith(
|
|
"officialPublishers",
|
|
expect.objectContaining({
|
|
publisherId: "publishers:steipete",
|
|
reason: "Verified individual publisher",
|
|
createdByUserId: "users:admin",
|
|
}),
|
|
);
|
|
expect(ctx.db.insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
action: "publisher.official.add",
|
|
targetId: "publishers:steipete",
|
|
metadata: { handle: "steipete", reason: "Verified individual publisher" },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("publisher-owned resource authorization", () => {
|
|
function makeOwnerResourceCtx(options: {
|
|
publisher: Record<string, unknown> | null;
|
|
membership?: Record<string, unknown> | null;
|
|
}) {
|
|
return {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "publishers:owner") return options.publisher;
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn().mockResolvedValue(options.membership ?? null),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
it("does not let stale ownerUserId bypass org ownership", async () => {
|
|
const ctx = makeOwnerResourceCtx({
|
|
publisher: { _id: "publishers:owner", kind: "org", handle: "opik" },
|
|
});
|
|
|
|
await expect(
|
|
assertCanManageOwnedResource(
|
|
ctx as never,
|
|
{
|
|
actor: { _id: "users:vincent" },
|
|
ownerUserId: "users:vincent",
|
|
ownerPublisherId: "publishers:owner",
|
|
} as never,
|
|
),
|
|
).rejects.toThrow("Forbidden");
|
|
});
|
|
|
|
it("keeps linked users authorized for personal publishers", async () => {
|
|
const ctx = makeOwnerResourceCtx({
|
|
publisher: {
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "vincentkoc",
|
|
linkedUserId: "users:vincent",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
assertCanManageOwnedResource(
|
|
ctx as never,
|
|
{
|
|
actor: { _id: "users:vincent" },
|
|
ownerUserId: "users:vincent",
|
|
ownerPublisherId: "publishers:owner",
|
|
} as never,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("keeps legacy personal publishers without linked users manageable by the resource owner", async () => {
|
|
const ctx = makeOwnerResourceCtx({
|
|
publisher: {
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "vincentkoc",
|
|
linkedUserId: undefined,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
assertCanManageOwnedResource(
|
|
ctx as never,
|
|
{
|
|
actor: { _id: "users:vincent" },
|
|
ownerUserId: "users:vincent",
|
|
ownerPublisherId: "publishers:owner",
|
|
} as never,
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("does not honor extra memberships on personal publishers", async () => {
|
|
const ctx = makeOwnerResourceCtx({
|
|
publisher: {
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "vincentkoc",
|
|
linkedUserId: "users:vincent",
|
|
},
|
|
membership: {
|
|
_id: "publisherMembers:stale",
|
|
publisherId: "publishers:owner",
|
|
userId: "users:friend",
|
|
role: "owner",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
assertCanManageOwnedResource(
|
|
ctx as never,
|
|
{
|
|
actor: { _id: "users:friend" },
|
|
ownerUserId: "users:vincent",
|
|
ownerPublisherId: "publishers:owner",
|
|
} as never,
|
|
),
|
|
).rejects.toThrow("Forbidden");
|
|
});
|
|
|
|
it("does not authorize personal publisher roles for non-linked members", async () => {
|
|
const ctx = makeOwnerResourceCtx({
|
|
publisher: {
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "vincentkoc",
|
|
linkedUserId: "users:vincent",
|
|
},
|
|
membership: {
|
|
_id: "publisherMembers:stale",
|
|
publisherId: "publishers:owner",
|
|
userId: "users:friend",
|
|
role: "owner",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
requirePublisherRole(
|
|
ctx as never,
|
|
{
|
|
publisherId: "publishers:owner",
|
|
userId: "users:friend",
|
|
allowed: ["owner"],
|
|
} as never,
|
|
),
|
|
).rejects.toThrow("Forbidden");
|
|
});
|
|
|
|
it("treats linked users as personal publisher owners even with stale membership roles", async () => {
|
|
const ctx = makeOwnerResourceCtx({
|
|
publisher: {
|
|
_id: "publishers:owner",
|
|
kind: "user",
|
|
handle: "vincentkoc",
|
|
linkedUserId: "users:vincent",
|
|
},
|
|
membership: {
|
|
_id: "publisherMembers:stale",
|
|
publisherId: "publishers:owner",
|
|
userId: "users:vincent",
|
|
role: "publisher",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
requirePublisherRole(
|
|
ctx as never,
|
|
{
|
|
publisherId: "publishers:owner",
|
|
userId: "users:vincent",
|
|
allowed: ["admin"],
|
|
} as never,
|
|
),
|
|
).resolves.toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("publisher bootstrap", () => {
|
|
function makeSynthesizedPublisherCtx(userId: string, user: Record<string, unknown>) {
|
|
return {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === userId) return { _id: id, ...user };
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_user") throw new Error(`unexpected index ${indexName}`);
|
|
return { collect: vi.fn().mockResolvedValue([]) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_handle") return { unique: vi.fn().mockResolvedValue(null) };
|
|
if (indexName !== "by_linked_user") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return { unique: vi.fn().mockResolvedValue(null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skills" || table === "packages") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
it("returns the real personal publisher handle when it differs from the user handle", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") {
|
|
return {
|
|
_id: id,
|
|
handle: "claimed",
|
|
personalPublisherId: "publishers:alice-profile",
|
|
createdAt: 1,
|
|
};
|
|
}
|
|
if (id === "publishers:alice-profile") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "alice-profile",
|
|
linkedUserId: "users:alice",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn(() => {
|
|
throw new Error("unexpected query");
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(getMyProfileHandleHandler(ctx as never, {} as never)).resolves.toBe(
|
|
"alice-profile",
|
|
);
|
|
});
|
|
|
|
it("falls back to the linked personal publisher when the direct pointer is stale", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") {
|
|
return {
|
|
_id: id,
|
|
handle: "claimed",
|
|
personalPublisherId: "publishers:stale",
|
|
createdAt: 1,
|
|
};
|
|
}
|
|
if (id === "publishers:stale") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "stale",
|
|
linkedUserId: "users:bob",
|
|
deactivatedAt: 2,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "publishers") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_linked_user") throw new Error(`unexpected index ${indexName}`);
|
|
return {
|
|
unique: vi.fn().mockResolvedValue({
|
|
_id: "publishers:alice-profile",
|
|
kind: "user",
|
|
handle: "alice-profile",
|
|
linkedUserId: "users:alice",
|
|
}),
|
|
};
|
|
}),
|
|
};
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(getMyProfileHandleHandler(ctx as never, {} as never)).resolves.toBe(
|
|
"alice-profile",
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "hides a legacy pointer without an owner membership",
|
|
memberships: [],
|
|
expected: null,
|
|
},
|
|
{
|
|
name: "returns a legacy pointer with the signed-in owner's membership",
|
|
memberships: [
|
|
{
|
|
_id: "publisherMembers:alice",
|
|
publisherId: "publishers:legacy-alice",
|
|
userId: "users:alice",
|
|
role: "owner",
|
|
},
|
|
],
|
|
expected: "legacy-alice",
|
|
},
|
|
])("$name", async ({ memberships, expected }) => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") {
|
|
return {
|
|
_id: id,
|
|
handle: "alice",
|
|
personalPublisherId: "publishers:legacy-alice",
|
|
createdAt: 1,
|
|
};
|
|
}
|
|
if (id === "publishers:legacy-alice") {
|
|
return {
|
|
_id: id,
|
|
kind: "user",
|
|
handle: "legacy-alice",
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "publisherMembers") throw new Error(`unexpected table ${table}`);
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_publisher") throw new Error(`unexpected index ${indexName}`);
|
|
return { collect: vi.fn().mockResolvedValue(memberships) };
|
|
}),
|
|
};
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(getMyProfileHandleHandler(ctx as never, {} as never)).resolves.toBe(expected);
|
|
});
|
|
|
|
it("lists a synthesized personal publisher when membership rows are missing", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
handle: "alice",
|
|
displayName: "Alice",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_user") throw new Error(`unexpected index ${indexName}`);
|
|
return { collect: vi.fn().mockResolvedValue([]) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_handle") return { unique: vi.fn().mockResolvedValue(null) };
|
|
if (indexName !== "by_linked_user") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return { unique: vi.fn().mockResolvedValue(null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skills" || table === "packages") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(listMineHandler(ctx as never, {} as never)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
role: "owner",
|
|
publisher: expect.objectContaining({
|
|
handle: "alice",
|
|
kind: "user",
|
|
linkedUserId: "users:alice",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("derives route-safe handles for synthesized personal publishers", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:local" as never);
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:local") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
name: "Local Owner",
|
|
displayName: "Local Owner",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_user") throw new Error(`unexpected index ${indexName}`);
|
|
return { collect: vi.fn().mockResolvedValue([]) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_handle") return { unique: vi.fn().mockResolvedValue(null) };
|
|
if (indexName !== "by_linked_user") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return { unique: vi.fn().mockResolvedValue(null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skills" || table === "packages") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(listMineHandler(ctx as never, {} as never)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
role: "owner",
|
|
publisher: expect.objectContaining({
|
|
displayName: "Local Owner",
|
|
handle: "local-owner",
|
|
kind: "user",
|
|
linkedUserId: "users:local",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("falls back when synthesized personal publisher handles sanitize to empty", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:symbols" as never);
|
|
const ctx = makeSynthesizedPublisherCtx("users:symbols", {
|
|
_creationTime: 1,
|
|
name: "!!!",
|
|
displayName: "!!!",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
});
|
|
|
|
await expect(listMineHandler(ctx as never, {} as never)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
role: "owner",
|
|
publisher: expect.objectContaining({
|
|
displayName: "!!!",
|
|
handle: "user",
|
|
kind: "user",
|
|
linkedUserId: "users:symbols",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("rejects personal publisher handles containing openclaw", async () => {
|
|
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:openclaw-china") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
handle: "openclaw-china",
|
|
displayName: "Openclaw China",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_linked_user") {
|
|
return { unique: vi.fn().mockResolvedValue(null) };
|
|
}
|
|
if (indexName === "by_handle") {
|
|
return { unique: vi.fn().mockResolvedValue(null) };
|
|
}
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
insert: vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
inserts.push({ table, value });
|
|
return `${table}:${inserts.length}`;
|
|
}),
|
|
patch: vi.fn(),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
ensurePersonalPublisherForUser(
|
|
ctx as never,
|
|
{
|
|
_id: "users:openclaw-china",
|
|
_creationTime: 1,
|
|
handle: "openclaw-china",
|
|
displayName: "Openclaw China",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
} as never,
|
|
),
|
|
).rejects.toThrow('Handle "@openclaw-china" is reserved for OpenClaw publishers');
|
|
expect(inserts).toHaveLength(0);
|
|
});
|
|
|
|
it("filters stale personal memberships from mine listings", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:friend" as never);
|
|
const memberships = [
|
|
{
|
|
_id: "publisherMembers:stale-personal",
|
|
publisherId: "publishers:owner",
|
|
userId: "users:friend",
|
|
role: "owner",
|
|
},
|
|
{
|
|
_id: "publisherMembers:own-personal",
|
|
publisherId: "publishers:friend",
|
|
userId: "users:friend",
|
|
role: "publisher",
|
|
},
|
|
{
|
|
_id: "publisherMembers:team",
|
|
publisherId: "publishers:team",
|
|
userId: "users:friend",
|
|
role: "admin",
|
|
},
|
|
];
|
|
const publishers = {
|
|
"publishers:owner": {
|
|
_id: "publishers:owner",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "owner",
|
|
displayName: "Owner",
|
|
linkedUserId: "users:owner",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
"publishers:friend": {
|
|
_id: "publishers:friend",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "friend",
|
|
displayName: "Friend",
|
|
linkedUserId: "users:friend",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
"publishers:team": {
|
|
_id: "publishers:team",
|
|
_creationTime: 1,
|
|
kind: "org",
|
|
handle: "team",
|
|
displayName: "Team",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:friend") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
handle: "friend",
|
|
displayName: "Friend",
|
|
personalPublisherId: "publishers:friend",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return publishers[id as keyof typeof publishers] ?? null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_user") throw new Error(`unexpected index ${indexName}`);
|
|
return { collect: vi.fn().mockResolvedValue(memberships) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName === "by_handle") return { unique: vi.fn().mockResolvedValue(null) };
|
|
if (indexName !== "by_linked_user") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return { unique: vi.fn().mockResolvedValue(null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skills" || table === "packages") {
|
|
return emptyOwnedResourcesQuery();
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
|
|
const result = await listMineHandler(ctx as never, {} as never);
|
|
|
|
expect(result).toEqual([
|
|
expect.objectContaining({
|
|
role: "owner",
|
|
publisher: expect.objectContaining({
|
|
_id: "publishers:friend",
|
|
handle: "friend",
|
|
kind: "user",
|
|
linkedUserId: "users:friend",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
role: "admin",
|
|
publisher: expect.objectContaining({
|
|
_id: "publishers:team",
|
|
handle: "team",
|
|
kind: "org",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("uses complete counters for mine summaries without reading resource tables", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const publisher = {
|
|
_id: "publishers:alice",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "alice",
|
|
displayName: "Alice",
|
|
linkedUserId: "users:alice",
|
|
publishedSkills: 4,
|
|
publishedPackages: 1,
|
|
totalInstalls: 14,
|
|
totalDownloads: 14,
|
|
totalStars: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") {
|
|
return {
|
|
_id: id,
|
|
personalPublisherId: publisher._id,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
return id === publisher._id ? publisher : null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() =>
|
|
indexedRows([
|
|
{
|
|
_id: "publisherMembers:alice",
|
|
publisherId: publisher._id,
|
|
userId: "users:alice",
|
|
role: "owner",
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") return emptyOfficialPublishersQuery();
|
|
throw new Error(`mine summary must not query ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
|
|
const result = (await listMineHandler(ctx as never, {
|
|
includePublishedItems: false,
|
|
})) as Array<{
|
|
publisher: Record<string, unknown>;
|
|
}>;
|
|
|
|
expect(result[0]?.publisher).toEqual(
|
|
expect.objectContaining({
|
|
stats: { skills: 4, packages: 1, installs: 14, downloads: 14, stars: 0 },
|
|
}),
|
|
);
|
|
expect(result[0]?.publisher).not.toHaveProperty("publishedItems");
|
|
});
|
|
|
|
it("keeps complete published items for listMine callers without the lightweight opt-out", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const publisher = {
|
|
_id: "publishers:alice",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "alice",
|
|
displayName: "Alice",
|
|
linkedUserId: "users:alice",
|
|
publishedSkills: 1,
|
|
publishedPackages: 1,
|
|
totalInstalls: 3,
|
|
totalDownloads: 5,
|
|
totalStars: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") {
|
|
return { _id: id, personalPublisherId: publisher._id, createdAt: 1, updatedAt: 1 };
|
|
}
|
|
return id === publisher._id ? publisher : null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() =>
|
|
indexedRows([
|
|
{
|
|
_id: "publisherMembers:alice",
|
|
publisherId: publisher._id,
|
|
userId: "users:alice",
|
|
role: "owner",
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return { withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })) };
|
|
}
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: vi.fn(() =>
|
|
indexedRows([
|
|
{
|
|
_id: "skills:alice-tool",
|
|
_creationTime: 1,
|
|
ownerPublisherId: publisher._id,
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
displayName: "Alice Tool",
|
|
updatedAt: 1,
|
|
stats: {
|
|
downloads: 3,
|
|
stars: 0,
|
|
installsCurrent: 1,
|
|
installsAllTime: 1,
|
|
},
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
if (table === "packages") {
|
|
return {
|
|
withIndex: vi.fn(() =>
|
|
indexedRows([
|
|
{
|
|
_id: "packages:alice-plugin",
|
|
_creationTime: 2,
|
|
ownerPublisherId: publisher._id,
|
|
family: "code-plugin",
|
|
softDeletedAt: undefined,
|
|
displayName: "Alice Plugin",
|
|
stats: { downloads: 2, stars: 0, installs: 2, versions: 1 },
|
|
},
|
|
]),
|
|
),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") return emptyOfficialPublishersQuery();
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
|
|
const result = (await listMineHandler(ctx as never, {})) as Array<{
|
|
publisher: { publishedItems: Array<{ displayName: string }> };
|
|
}>;
|
|
|
|
expect(result[0]?.publisher.publishedItems.map((item) => item.displayName)).toEqual([
|
|
"Alice Tool",
|
|
"Alice Plugin",
|
|
]);
|
|
});
|
|
|
|
it("loads every published item only from deletion inventory", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const publisher = {
|
|
_id: "publishers:alice",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "alice",
|
|
displayName: "Alice",
|
|
linkedUserId: "users:alice",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const skills = Array.from({ length: 4 }, (_, index) => ({
|
|
_id: `skills:tool-${index}`,
|
|
_creationTime: index,
|
|
ownerPublisherId: "publishers:alice",
|
|
softDeletedAt: undefined,
|
|
moderationStatus: "active",
|
|
displayName: `Skill ${index + 1}`,
|
|
updatedAt: index,
|
|
stats: {
|
|
downloads: index,
|
|
stars: 0,
|
|
installsCurrent: index,
|
|
installsAllTime: index,
|
|
},
|
|
}));
|
|
const packages = [
|
|
{
|
|
_id: "packages:plugin-1",
|
|
_creationTime: 10,
|
|
ownerPublisherId: "publishers:alice",
|
|
family: "plugin",
|
|
softDeletedAt: undefined,
|
|
displayName: "Plugin 1",
|
|
stats: { downloads: 8, stars: 0, installs: 8, versions: 1 },
|
|
},
|
|
];
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") {
|
|
return {
|
|
_id: id,
|
|
_creationTime: 1,
|
|
handle: "alice",
|
|
displayName: "Alice",
|
|
personalPublisherId: "publishers:alice",
|
|
trustedPublisher: false,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|
|
if (id === "publishers:alice") return publisher;
|
|
return null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_user") throw new Error(`unexpected index ${indexName}`);
|
|
return {
|
|
collect: vi.fn(async () => [
|
|
{
|
|
_id: "publisherMembers:alice",
|
|
publisherId: "publishers:alice",
|
|
userId: "users:alice",
|
|
role: "owner",
|
|
},
|
|
]),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_owner_publisher_active_updated") {
|
|
throw new Error(`unexpected skills index ${indexName}`);
|
|
}
|
|
return indexedRows(skills);
|
|
}),
|
|
};
|
|
}
|
|
if (table === "packages") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_owner_publisher_active_updated") {
|
|
throw new Error(`unexpected packages index ${indexName}`);
|
|
}
|
|
return indexedRows(packages);
|
|
}),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
}),
|
|
},
|
|
};
|
|
|
|
const result = await getDeletionInventoryHandler(ctx as never, {});
|
|
|
|
expect(result[0]?.publishedItems.map((item) => item.displayName)).toEqual([
|
|
"Plugin 1",
|
|
"Skill 4",
|
|
"Skill 3",
|
|
"Skill 2",
|
|
"Skill 1",
|
|
]);
|
|
});
|
|
|
|
it("rejects organization deletion inventory for non-owners", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:alice" as never);
|
|
const publisher = {
|
|
_id: "publishers:team",
|
|
kind: "org",
|
|
handle: "team",
|
|
displayName: "Team",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:alice") return { _id: id, createdAt: 1, updatedAt: 1 };
|
|
return id === publisher._id ? publisher : null;
|
|
}),
|
|
query: vi.fn((table: string) => {
|
|
if (table !== "publisherMembers") {
|
|
throw new Error(`inventory authorization must not query ${table}`);
|
|
}
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_publisher_user") {
|
|
throw new Error(`unexpected membership index ${indexName}`);
|
|
}
|
|
return {
|
|
unique: vi.fn(async () => ({
|
|
publisherId: publisher._id,
|
|
userId: "users:alice",
|
|
role: "admin",
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
getDeletionInventoryHandler(ctx as never, { publisherId: publisher._id }),
|
|
).rejects.toThrow("Forbidden");
|
|
});
|
|
});
|
|
|
|
describe("self-serve org publisher creation", () => {
|
|
function makeCreateOrgPublisherCtx(options: {
|
|
existingPublisher?: Record<string, unknown> | null;
|
|
existingUser?: Record<string, unknown> | null;
|
|
reservedHandle?: Record<string, unknown> | null;
|
|
actor?: Record<string, unknown> | null;
|
|
}) {
|
|
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_handle") throw new Error(`unexpected index ${indexName}`);
|
|
return { unique: vi.fn().mockResolvedValue(options.existingPublisher ?? null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "handle") throw new Error(`unexpected index ${indexName}`);
|
|
return { unique: vi.fn().mockResolvedValue(options.existingUser ?? null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_handle_active_updatedAt") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => (options.reservedHandle ? [options.reservedHandle] : [])),
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (...args: string[]) => {
|
|
const id = args.length === 2 ? args[1] : args[0];
|
|
if (id === "users:vincent") return options.actor ?? { _id: id, handle: "vincentkoc" };
|
|
const inserted = inserts.find((entry) => entry.value._id === id);
|
|
if (inserted) return inserted.value;
|
|
return null;
|
|
}),
|
|
query,
|
|
insert: vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
const id = `${table}:${inserts.length + 1}`;
|
|
inserts.push({ table, value: { _id: id, ...value } });
|
|
return id;
|
|
}),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)),
|
|
},
|
|
};
|
|
return { ctx, inserts };
|
|
}
|
|
|
|
it("creates an untrusted org publisher and makes the actor owner", async () => {
|
|
const { ctx, inserts } = makeCreateOrgPublisherCtx({});
|
|
|
|
const result = await createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle: "Opik",
|
|
displayName: "Opik",
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
publisherId: "publishers:1",
|
|
handle: "opik",
|
|
created: true,
|
|
trusted: false,
|
|
});
|
|
expect(inserts).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
table: "publishers",
|
|
value: expect.objectContaining({
|
|
kind: "org",
|
|
handle: "opik",
|
|
displayName: "Opik",
|
|
trustedPublisher: undefined,
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
table: "publisherMembers",
|
|
value: expect.objectContaining({
|
|
publisherId: "publishers:1",
|
|
userId: "users:vincent",
|
|
role: "owner",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
table: "auditLogs",
|
|
value: expect.objectContaining({
|
|
actorUserId: "users:vincent",
|
|
action: "publisher.org.create",
|
|
targetType: "publisher",
|
|
targetId: "publishers:1",
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("creates org publishers for npm-compatible scoped package handles", async () => {
|
|
const examples = ["example.tools", "lab_1", "studio_tools", "market_square"];
|
|
|
|
for (const handle of examples) {
|
|
const { ctx, inserts } = makeCreateOrgPublisherCtx({});
|
|
|
|
await expect(
|
|
createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle,
|
|
displayName: handle,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
handle,
|
|
created: true,
|
|
});
|
|
expect(inserts).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
table: "publishers",
|
|
value: expect.objectContaining({
|
|
kind: "org",
|
|
handle,
|
|
displayName: handle,
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("rejects creation when the org publisher already exists", async () => {
|
|
const { ctx } = makeCreateOrgPublisherCtx({
|
|
existingPublisher: { _id: "publishers:opik", kind: "org", handle: "opik" },
|
|
});
|
|
|
|
await expect(
|
|
createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle: "opik",
|
|
}),
|
|
).rejects.toThrow('Publisher "@opik" already exists');
|
|
});
|
|
|
|
it("rejects self-serve org handles containing openclaw", async () => {
|
|
const { ctx, inserts } = makeCreateOrgPublisherCtx({});
|
|
|
|
await expect(
|
|
createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle: "openclaw-china",
|
|
displayName: "Openclaw China",
|
|
}),
|
|
).rejects.toThrow('Handle "@openclaw-china" is reserved for OpenClaw publishers');
|
|
expect(inserts).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects creation when the handle belongs to a user or personal publisher", async () => {
|
|
const { ctx } = makeCreateOrgPublisherCtx({
|
|
existingUser: { _id: "users:opik", handle: "opik" },
|
|
});
|
|
|
|
await expect(
|
|
createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle: "opik",
|
|
}),
|
|
).rejects.toThrow('Handle "@opik" is already used by a user or personal publisher');
|
|
});
|
|
|
|
it("rejects creation when the handle belongs to a personal publisher", async () => {
|
|
const { ctx } = makeCreateOrgPublisherCtx({
|
|
existingPublisher: { _id: "publishers:opik", kind: "user", handle: "opik" },
|
|
});
|
|
|
|
await expect(
|
|
createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle: "opik",
|
|
}),
|
|
).rejects.toThrow('Handle "@opik" is already used by a user or personal publisher');
|
|
});
|
|
|
|
it("rejects creation when the handle is reserved for another user", async () => {
|
|
const { ctx } = makeCreateOrgPublisherCtx({
|
|
reservedHandle: {
|
|
_id: "reservedHandles:opik",
|
|
handle: "opik",
|
|
rightfulOwnerUserId: "users:opik",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle: "opik",
|
|
}),
|
|
).rejects.toThrow('Handle "@opik" is reserved for another user');
|
|
});
|
|
|
|
it("allows creation when the handle is reserved for the actor", async () => {
|
|
const { ctx } = makeCreateOrgPublisherCtx({
|
|
reservedHandle: {
|
|
_id: "reservedHandles:opik",
|
|
handle: "opik",
|
|
rightfulOwnerUserId: "users:vincent",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
createOrgPublisherForUserInternalHandler(ctx as never, {
|
|
actorUserId: "users:vincent",
|
|
handle: "opik",
|
|
}),
|
|
).resolves.toMatchObject({ ok: true, handle: "opik" });
|
|
});
|
|
|
|
function makeSettingsCreateOrgCtx(options: {
|
|
reservedHandle?: Record<string, unknown> | null;
|
|
existingOrgPublisher?: Record<string, unknown> | null;
|
|
}) {
|
|
const actor = {
|
|
_id: "users:vincent",
|
|
_creationTime: 1,
|
|
handle: "vincentkoc",
|
|
displayName: "Vincent",
|
|
personalPublisherId: "publishers:vincent",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const personalPublisher = {
|
|
_id: "publishers:vincent",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "vincentkoc",
|
|
displayName: "Vincent",
|
|
linkedUserId: "users:vincent",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
|
const insertCounts = new Map<string, number>();
|
|
const insertedById = new Map<string, Record<string, unknown>>();
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string, builder: (q: unknown) => unknown) => {
|
|
const eqValues: Record<string, unknown> = {};
|
|
builder({
|
|
eq: vi.fn((field: string, value: unknown) => {
|
|
eqValues[field] = value;
|
|
return { eq: vi.fn() };
|
|
}),
|
|
});
|
|
if (indexName === "by_linked_user") {
|
|
return { unique: vi.fn().mockResolvedValue(personalPublisher) };
|
|
}
|
|
if (indexName !== "by_handle") throw new Error(`unexpected index ${indexName}`);
|
|
const handle = eqValues.handle;
|
|
const publisher =
|
|
handle === "vincentkoc"
|
|
? personalPublisher
|
|
: handle === "opik"
|
|
? options.existingOrgPublisher
|
|
: null;
|
|
return { unique: vi.fn().mockResolvedValue(publisher ?? null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_publisher_user") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return { unique: vi.fn().mockResolvedValue({ _id: "publisherMembers:personal" }) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "handle") throw new Error(`unexpected index ${indexName}`);
|
|
return { unique: vi.fn().mockResolvedValue(null) };
|
|
}),
|
|
};
|
|
}
|
|
if (table === "reservedHandles") {
|
|
return {
|
|
withIndex: vi.fn((indexName: string) => {
|
|
if (indexName !== "by_handle_active_updatedAt") {
|
|
throw new Error(`unexpected index ${indexName}`);
|
|
}
|
|
return {
|
|
order: vi.fn(() => ({
|
|
take: vi.fn(async () => (options.reservedHandle ? [options.reservedHandle] : [])),
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
if (table === "officialPublishers") {
|
|
return emptyOfficialPublishersQuery();
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (...args: string[]) => {
|
|
const id = args.length === 2 ? args[1] : args[0];
|
|
if (id === actor._id) return actor;
|
|
if (id === personalPublisher._id) return personalPublisher;
|
|
return insertedById.get(id) ?? null;
|
|
}),
|
|
query,
|
|
insert: vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
const next = (insertCounts.get(table) ?? 0) + 1;
|
|
insertCounts.set(table, next);
|
|
const id = `${table}:${next}`;
|
|
const doc = { _id: id, _creationTime: next, ...value };
|
|
inserts.push({ table, value: doc });
|
|
insertedById.set(id, doc);
|
|
return id;
|
|
}),
|
|
patch: vi.fn(),
|
|
replace: vi.fn(),
|
|
delete: vi.fn(),
|
|
normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)),
|
|
},
|
|
};
|
|
return { ctx, inserts };
|
|
}
|
|
|
|
it("rejects Settings org creation when the handle is reserved for another user", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:vincent" as never);
|
|
const { ctx } = makeSettingsCreateOrgCtx({
|
|
reservedHandle: {
|
|
_id: "reservedHandles:opik",
|
|
handle: "opik",
|
|
rightfulOwnerUserId: "users:opik",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
createOrgHandler(ctx as never, {
|
|
handle: "opik",
|
|
displayName: "Opik",
|
|
}),
|
|
).rejects.toThrow('Handle "@opik" is reserved for another user');
|
|
});
|
|
|
|
it("lets Settings org creation use handles reserved for the actor", async () => {
|
|
vi.mocked(getAuthUserId).mockResolvedValue("users:vincent" as never);
|
|
const { ctx, inserts } = makeSettingsCreateOrgCtx({
|
|
reservedHandle: {
|
|
_id: "reservedHandles:opik",
|
|
handle: "opik",
|
|
rightfulOwnerUserId: "users:vincent",
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
createOrgHandler(ctx as never, {
|
|
handle: "Opik",
|
|
displayName: "Opik",
|
|
bio: "Team publisher",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
publisher: { handle: "opik", bio: "Team publisher" },
|
|
role: "owner",
|
|
});
|
|
expect(inserts).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
table: "publishers",
|
|
value: expect.objectContaining({
|
|
kind: "org",
|
|
handle: "opik",
|
|
displayName: "Opik",
|
|
bio: "Team publisher",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
table: "publisherMembers",
|
|
value: expect.objectContaining({
|
|
userId: "users:vincent",
|
|
role: "owner",
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("legacy publisher migration", () => {
|
|
function makePersonalPublisherRecoveryCtx(
|
|
options: {
|
|
destinationHasResources?: boolean;
|
|
legacyResources?: boolean;
|
|
mixedCaseUserHandles?: boolean;
|
|
tooManyLegacySkills?: boolean;
|
|
unexpectedResourceOwner?: boolean;
|
|
unexpectedReservationOwner?: boolean;
|
|
} = {},
|
|
) {
|
|
const users = new Map<string, Record<string, unknown>>([
|
|
["users:admin", { _id: "users:admin", role: "admin", handle: "admin" }],
|
|
[
|
|
"users:legacy",
|
|
{
|
|
_id: "users:legacy",
|
|
role: "user",
|
|
handle: options.mixedCaseUserHandles ? "Gingiris" : "gingiris",
|
|
personalPublisherId: "publishers:gingiris",
|
|
publishedSkills: 5,
|
|
totalDownloads: 100,
|
|
totalStars: 20,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
[
|
|
"users:current",
|
|
{
|
|
_id: "users:current",
|
|
role: "user",
|
|
handle: options.mixedCaseUserHandles ? "Gingiris-1031" : "gingiris-1031",
|
|
personalPublisherId: "publishers:gingiris-1031",
|
|
publishedSkills: 2,
|
|
totalDownloads: 40,
|
|
totalStars: 4,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
]);
|
|
const publishers = new Map<string, Record<string, unknown>>([
|
|
[
|
|
"publishers:gingiris",
|
|
{
|
|
_id: "publishers:gingiris",
|
|
kind: "user",
|
|
handle: "gingiris",
|
|
displayName: "gingiris",
|
|
linkedUserId: "users:legacy",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
[
|
|
"publishers:gingiris-1031",
|
|
{
|
|
_id: "publishers:gingiris-1031",
|
|
kind: "user",
|
|
handle: "gingiris-1031",
|
|
displayName: "gingiris-1031",
|
|
linkedUserId: "users:current",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
]);
|
|
const authAccounts = [
|
|
{
|
|
_id: "authAccounts:legacy",
|
|
provider: "github",
|
|
providerAccountId: "111",
|
|
userId: "users:legacy",
|
|
},
|
|
{
|
|
_id: "authAccounts:current",
|
|
provider: "github",
|
|
providerAccountId: "222",
|
|
userId: "users:current",
|
|
},
|
|
];
|
|
const publisherMembers = new Map<string, Record<string, unknown>>([
|
|
[
|
|
"publisherMembers:legacy",
|
|
{
|
|
_id: "publisherMembers:legacy",
|
|
publisherId: "publishers:gingiris",
|
|
userId: "users:legacy",
|
|
role: "owner",
|
|
},
|
|
],
|
|
[
|
|
"publisherMembers:current",
|
|
{
|
|
_id: "publisherMembers:current",
|
|
publisherId: "publishers:gingiris-1031",
|
|
userId: "users:current",
|
|
role: "owner",
|
|
},
|
|
],
|
|
]);
|
|
const baseSkill = {
|
|
_id: "skills:legacy-skill",
|
|
slug: "demo-skill",
|
|
displayName: "Demo Skill",
|
|
summary: "Recovered skill",
|
|
ownerUserId: options.unexpectedResourceOwner ? "users:someone-else" : "users:legacy",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
forkOf: undefined,
|
|
tags: {},
|
|
badges: {},
|
|
stats: {
|
|
downloads: 99,
|
|
stars: 19,
|
|
comments: 0,
|
|
installsCurrent: 0,
|
|
installsAllTime: 0,
|
|
},
|
|
statsDownloads: 12,
|
|
statsStars: 3,
|
|
moderationStatus: "approved",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const skills = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [["skills:legacy-skill", baseSkill]]
|
|
: options.tooManyLegacySkills
|
|
? Array.from({ length: 101 }, (_, index) => [
|
|
`skills:legacy-${index}`,
|
|
{
|
|
...baseSkill,
|
|
_id: `skills:legacy-${index}`,
|
|
slug: `demo-skill-${index}`,
|
|
},
|
|
])
|
|
: [],
|
|
);
|
|
const skillSlugAliases = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [
|
|
[
|
|
"skillSlugAliases:legacy",
|
|
{
|
|
_id: "skillSlugAliases:legacy",
|
|
slug: "old-demo-skill",
|
|
skillId: "skills:legacy-skill",
|
|
ownerUserId: "users:legacy",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
const skillSearchDigest = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [
|
|
[
|
|
"skillSearchDigest:legacy",
|
|
{
|
|
_id: "skillSearchDigest:legacy",
|
|
skillId: "skills:legacy-skill",
|
|
ownerUserId: "users:legacy",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
},
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
const basePackage = {
|
|
_id: "packages:legacy-package",
|
|
name: "@gingiris/demo-plugin",
|
|
normalizedName: "@gingiris/demo-plugin",
|
|
displayName: "Demo Plugin",
|
|
ownerUserId: "users:legacy",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
family: "code-plugin",
|
|
channel: "community",
|
|
isOfficial: false,
|
|
tags: {},
|
|
compatibility: {},
|
|
verification: {},
|
|
scanStatus: "pending",
|
|
stats: { downloads: 0, installs: 0, stars: 0, versions: 1 },
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
const packages = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources ? [["packages:legacy-package", basePackage]] : [],
|
|
);
|
|
const packageSearchDigest = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [
|
|
[
|
|
"packageSearchDigest:legacy",
|
|
{
|
|
_id: "packageSearchDigest:legacy",
|
|
packageId: "packages:legacy-package",
|
|
ownerUserId: "users:legacy",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
},
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
const packageCapabilitySearchDigest = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [
|
|
[
|
|
"packageCapabilitySearchDigest:legacy-tools",
|
|
{
|
|
_id: "packageCapabilitySearchDigest:legacy-tools",
|
|
packageId: "packages:legacy-package",
|
|
capabilityTag: "tools",
|
|
ownerUserId: "users:legacy",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
},
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
const packageTopicSearchDigest = new Map<string, Record<string, unknown>>();
|
|
const packagePluginCategorySearchDigest = new Map<string, Record<string, unknown>>();
|
|
const packageInspectorWarnings = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [
|
|
[
|
|
"packageInspectorWarnings:legacy",
|
|
{
|
|
_id: "packageInspectorWarnings:legacy",
|
|
packageId: "packages:legacy-package",
|
|
releaseId: "packageReleases:legacy",
|
|
ownerUserId: "users:legacy",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
createdAt: 1,
|
|
},
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
const githubSkillSources = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [
|
|
[
|
|
"githubSkillSources:legacy",
|
|
{
|
|
_id: "githubSkillSources:legacy",
|
|
repo: "gingiris/skills",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
},
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
const reservedHandles = new Map<string, Record<string, unknown>>(
|
|
options.legacyResources
|
|
? [
|
|
[
|
|
"reservedHandles:gingiris",
|
|
{
|
|
_id: "reservedHandles:gingiris",
|
|
handle: "gingiris",
|
|
rightfulOwnerUserId: options.unexpectedReservationOwner
|
|
? "users:someone-else"
|
|
: "users:legacy",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
]
|
|
: [],
|
|
);
|
|
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
|
const patches: Array<{ id: string; patch: Record<string, unknown> }> = [];
|
|
const deletes: string[] = [];
|
|
|
|
const allRows = [
|
|
users,
|
|
publishers,
|
|
publisherMembers,
|
|
skills,
|
|
skillSlugAliases,
|
|
skillSearchDigest,
|
|
packages,
|
|
packageSearchDigest,
|
|
packageCapabilitySearchDigest,
|
|
packageTopicSearchDigest,
|
|
packagePluginCategorySearchDigest,
|
|
packageInspectorWarnings,
|
|
githubSkillSources,
|
|
reservedHandles,
|
|
];
|
|
const get = vi.fn(async (id: string) => {
|
|
return allRows.map((rows) => rows.get(id)).find(Boolean) ?? null;
|
|
});
|
|
const patch = vi.fn(async (id: string, patchValue: Record<string, unknown>) => {
|
|
patches.push({ id, patch: patchValue });
|
|
const row = allRows.map((rows) => rows.get(id)).find(Boolean);
|
|
if (row) Object.assign(row, patchValue);
|
|
});
|
|
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
const id = `${table}:inserted-${inserts.length + 1}`;
|
|
const row = { _id: id, ...value };
|
|
inserts.push({ table, value: row });
|
|
if (table === "publisherMembers") publisherMembers.set(id, row);
|
|
if (table === "skillSearchDigest") skillSearchDigest.set(id, row);
|
|
if (table === "packageSearchDigest") packageSearchDigest.set(id, row);
|
|
if (table === "packageCapabilitySearchDigest") packageCapabilitySearchDigest.set(id, row);
|
|
if (table === "packageTopicSearchDigest") packageTopicSearchDigest.set(id, row);
|
|
if (table === "packagePluginCategorySearchDigest") {
|
|
packagePluginCategorySearchDigest.set(id, row);
|
|
}
|
|
return id;
|
|
});
|
|
const deleteFn = vi.fn(async (id: string) => {
|
|
deletes.push(id);
|
|
publisherMembers.delete(id);
|
|
packageCapabilitySearchDigest.delete(id);
|
|
packageTopicSearchDigest.delete(id);
|
|
packagePluginCategorySearchDigest.delete(id);
|
|
});
|
|
const query = vi.fn((table: string) => ({
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
|
) => {
|
|
const fields: Record<string, unknown> = {};
|
|
const q = {
|
|
eq: (field: string, value: unknown) => {
|
|
fields[field] = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
const indexedQuery = {
|
|
unique: vi.fn(async () => {
|
|
if (table === "users") {
|
|
return [...users.values()].find((user) => user.handle === fields.handle) ?? null;
|
|
}
|
|
if (table === "publishers" && fields.handle) {
|
|
return (
|
|
[...publishers.values()].find(
|
|
(publisher) => publisher.handle === fields.handle,
|
|
) ?? null
|
|
);
|
|
}
|
|
if (table === "publishers" && fields.linkedUserId) {
|
|
return (
|
|
[...publishers.values()].find(
|
|
(publisher) => publisher.linkedUserId === fields.linkedUserId,
|
|
) ?? null
|
|
);
|
|
}
|
|
if (table === "skillSearchDigest" && fields.skillId) {
|
|
return (
|
|
[...skillSearchDigest.values()].find(
|
|
(digest) => digest.skillId === fields.skillId,
|
|
) ?? null
|
|
);
|
|
}
|
|
if (table === "packageSearchDigest" && fields.packageId) {
|
|
return (
|
|
[...packageSearchDigest.values()].find(
|
|
(digest) => digest.packageId === fields.packageId,
|
|
) ?? null
|
|
);
|
|
}
|
|
return null;
|
|
}),
|
|
take: vi.fn(async () => {
|
|
if (table === "authAccounts") {
|
|
return authAccounts.filter(
|
|
(account) =>
|
|
account.provider === fields.provider &&
|
|
account.providerAccountId === fields.providerAccountId,
|
|
);
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return [...publisherMembers.values()].filter(
|
|
(member) => member.publisherId === fields.publisherId,
|
|
);
|
|
}
|
|
if (table === "reservedHandles") {
|
|
return [...reservedHandles.values()].filter(
|
|
(reservation) =>
|
|
reservation.handle === fields.handle &&
|
|
reservation.releasedAt === fields.releasedAt,
|
|
);
|
|
}
|
|
if (table === "skills" && fields.ownerPublisherId === "publishers:gingiris") {
|
|
return [...skills.values()];
|
|
}
|
|
if (
|
|
table === "skillSlugAliases" &&
|
|
fields.ownerPublisherId === "publishers:gingiris"
|
|
) {
|
|
return [...skillSlugAliases.values()];
|
|
}
|
|
if (table === "packages" && fields.ownerPublisherId === "publishers:gingiris") {
|
|
return [...packages.values()];
|
|
}
|
|
if (
|
|
table === "packageInspectorWarnings" &&
|
|
fields.ownerPublisherId === "publishers:gingiris"
|
|
) {
|
|
return [...packageInspectorWarnings.values()];
|
|
}
|
|
if (
|
|
table === "githubSkillSources" &&
|
|
fields.ownerPublisherId === "publishers:gingiris"
|
|
) {
|
|
return [...githubSkillSources.values()];
|
|
}
|
|
if (
|
|
options.destinationHasResources &&
|
|
(table === "skills" || table === "packages" || table === "githubSkillSources") &&
|
|
fields.ownerPublisherId === "publishers:gingiris-1031"
|
|
) {
|
|
return [{ _id: `${table}:resource` }];
|
|
}
|
|
return [];
|
|
}),
|
|
collect: vi.fn(async () => {
|
|
if (table === "packageCapabilitySearchDigest" && fields.packageId) {
|
|
return [...packageCapabilitySearchDigest.values()].filter(
|
|
(digest) => digest.packageId === fields.packageId,
|
|
);
|
|
}
|
|
if (table === "packageTopicSearchDigest" && fields.packageId) {
|
|
return [...packageTopicSearchDigest.values()].filter(
|
|
(digest) => digest.packageId === fields.packageId,
|
|
);
|
|
}
|
|
if (table === "packagePluginCategorySearchDigest" && fields.packageId) {
|
|
return [...packagePluginCategorySearchDigest.values()].filter(
|
|
(digest) => digest.packageId === fields.packageId,
|
|
);
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
return {
|
|
...indexedQuery,
|
|
order: vi.fn(() => indexedQuery),
|
|
};
|
|
},
|
|
),
|
|
}));
|
|
|
|
return {
|
|
ctx: {
|
|
db: {
|
|
get,
|
|
patch,
|
|
insert,
|
|
delete: deleteFn,
|
|
query,
|
|
normalizeId: vi.fn(),
|
|
},
|
|
},
|
|
users,
|
|
publishers,
|
|
inserts,
|
|
patches,
|
|
deletes,
|
|
skills,
|
|
skillSlugAliases,
|
|
skillSearchDigest,
|
|
packages,
|
|
packageSearchDigest,
|
|
packageCapabilitySearchDigest,
|
|
packageInspectorWarnings,
|
|
githubSkillSources,
|
|
reservedHandles,
|
|
};
|
|
}
|
|
|
|
it("recovers a personal publisher for a verified replacement GitHub principal", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
const {
|
|
ctx,
|
|
users,
|
|
publishers,
|
|
inserts,
|
|
patches,
|
|
deletes,
|
|
skills,
|
|
skillSlugAliases,
|
|
skillSearchDigest,
|
|
packages,
|
|
packageSearchDigest,
|
|
packageCapabilitySearchDigest,
|
|
packageInspectorWarnings,
|
|
reservedHandles,
|
|
} = makePersonalPublisherRecoveryCtx({ legacyResources: true });
|
|
|
|
const result = await recoverPersonalPublisherInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
publisherHandle: "gingiris",
|
|
previousGitHubProviderAccountId: "111",
|
|
nextGitHubProviderAccountId: "222",
|
|
nextUserHandle: "gingiris-1031",
|
|
reason: "Verified account continuity for issue #2555",
|
|
confirmIdentityVerified: true,
|
|
dryRun: false,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
dryRun: false,
|
|
recovered: true,
|
|
publisherId: "publishers:gingiris",
|
|
handle: "gingiris",
|
|
previousUser: { userId: "users:legacy", nextHandle: "gingiris-recovered" },
|
|
nextUser: { userId: "users:current", nextHandle: "gingiris" },
|
|
retiredPersonalPublisher: {
|
|
publisherId: "publishers:gingiris-1031",
|
|
handle: "gingiris-1031",
|
|
},
|
|
resourceOwnerMigration: {
|
|
skills: 1,
|
|
skillSlugAliases: 1,
|
|
packages: 1,
|
|
packageInspectorWarnings: 1,
|
|
githubSourcesChecked: 1,
|
|
handleReservations: 1,
|
|
},
|
|
});
|
|
expect(users.get("users:legacy")).toMatchObject({
|
|
handle: "gingiris-recovered",
|
|
personalPublisherId: undefined,
|
|
publishedSkills: 4,
|
|
totalDownloads: 88,
|
|
totalStars: 17,
|
|
});
|
|
expect(users.get("users:current")).toMatchObject({
|
|
handle: "gingiris",
|
|
personalPublisherId: "publishers:gingiris",
|
|
publishedSkills: 3,
|
|
totalDownloads: 52,
|
|
totalStars: 7,
|
|
});
|
|
expect(publishers.get("publishers:gingiris")).toMatchObject({
|
|
linkedUserId: "users:current",
|
|
});
|
|
expect(publishers.get("publishers:gingiris-1031")).toMatchObject({
|
|
linkedUserId: undefined,
|
|
deactivatedAt: 1_700_000_000_000,
|
|
});
|
|
expect(skills.get("skills:legacy-skill")).toMatchObject({
|
|
ownerUserId: "users:current",
|
|
updatedAt: 1_700_000_000_000,
|
|
});
|
|
expect(skillSlugAliases.get("skillSlugAliases:legacy")).toMatchObject({
|
|
ownerUserId: "users:current",
|
|
updatedAt: 1_700_000_000_000,
|
|
});
|
|
expect(skillSearchDigest.get("skillSearchDigest:legacy")).toMatchObject({
|
|
ownerUserId: "users:current",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
ownerHandle: "gingiris",
|
|
ownerKind: "user",
|
|
});
|
|
expect(packages.get("packages:legacy-package")).toMatchObject({
|
|
ownerUserId: "users:current",
|
|
updatedAt: 1_700_000_000_000,
|
|
});
|
|
expect(packageSearchDigest.get("packageSearchDigest:legacy")).toMatchObject({
|
|
ownerUserId: "users:current",
|
|
ownerPublisherId: "publishers:gingiris",
|
|
ownerHandle: "gingiris",
|
|
ownerKind: "user",
|
|
});
|
|
expect(
|
|
packageCapabilitySearchDigest.get("packageCapabilitySearchDigest:legacy-tools"),
|
|
).toBeDefined();
|
|
expect(packageInspectorWarnings.get("packageInspectorWarnings:legacy")).toMatchObject({
|
|
ownerUserId: "users:current",
|
|
});
|
|
expect(reservedHandles.get("reservedHandles:gingiris")).toMatchObject({
|
|
rightfulOwnerUserId: "users:current",
|
|
updatedAt: 1_700_000_000_000,
|
|
});
|
|
expect(deletes).toContain("publisherMembers:legacy");
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "publisherMembers",
|
|
value: expect.objectContaining({
|
|
publisherId: "publishers:gingiris",
|
|
userId: "users:current",
|
|
role: "owner",
|
|
}),
|
|
}),
|
|
);
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "auditLogs",
|
|
value: expect.objectContaining({
|
|
actorUserId: "users:admin",
|
|
action: "publisher.personal.recover",
|
|
targetType: "publisher",
|
|
targetId: "publishers:gingiris",
|
|
metadata: expect.objectContaining({
|
|
previousGitHubProviderAccountId: "111",
|
|
nextGitHubProviderAccountId: "222",
|
|
identityVerified: true,
|
|
resourceOwnerMigration: expect.objectContaining({
|
|
skills: 1,
|
|
packages: 1,
|
|
packageInspectorWarnings: 1,
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
expect(patches.map((entry) => entry.id)).toEqual(
|
|
expect.arrayContaining([
|
|
"publishers:gingiris-1031",
|
|
"users:legacy",
|
|
"users:current",
|
|
"publishers:gingiris",
|
|
"skills:legacy-skill",
|
|
"skillSearchDigest:legacy",
|
|
"skillSlugAliases:legacy",
|
|
"packages:legacy-package",
|
|
"packageSearchDigest:legacy",
|
|
"packageInspectorWarnings:legacy",
|
|
"reservedHandles:gingiris",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("recovers users whose stored handles retain mixed-case GitHub casing", async () => {
|
|
const { ctx, users } = makePersonalPublisherRecoveryCtx({
|
|
mixedCaseUserHandles: true,
|
|
});
|
|
|
|
const result = await recoverPersonalPublisherInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
publisherHandle: "gingiris",
|
|
previousGitHubProviderAccountId: "111",
|
|
nextGitHubProviderAccountId: "222",
|
|
nextUserHandle: "gingiris-1031",
|
|
reason: "Verified account continuity for issue #2555",
|
|
confirmIdentityVerified: true,
|
|
dryRun: false,
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
previousUser: { nextHandle: "gingiris-recovered" },
|
|
nextUser: { nextHandle: "gingiris" },
|
|
});
|
|
expect(users.get("users:legacy")).toMatchObject({
|
|
handle: "gingiris-recovered",
|
|
personalPublisherId: undefined,
|
|
});
|
|
expect(users.get("users:current")).toMatchObject({
|
|
handle: "gingiris",
|
|
personalPublisherId: "publishers:gingiris",
|
|
});
|
|
});
|
|
|
|
it("fails closed when the destination personal publisher has resources", async () => {
|
|
const { ctx, inserts, patches } = makePersonalPublisherRecoveryCtx({
|
|
destinationHasResources: true,
|
|
});
|
|
|
|
await expect(
|
|
recoverPersonalPublisherInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
publisherHandle: "gingiris",
|
|
previousGitHubProviderAccountId: "111",
|
|
nextGitHubProviderAccountId: "222",
|
|
nextUserHandle: "gingiris-1031",
|
|
reason: "Verified account continuity for issue #2555",
|
|
confirmIdentityVerified: true,
|
|
dryRun: false,
|
|
}),
|
|
).rejects.toThrow(/has resources/i);
|
|
expect(patches).toHaveLength(0);
|
|
expect(inserts).toHaveLength(0);
|
|
});
|
|
|
|
it("fails closed when recovered publisher resources belong to another user", async () => {
|
|
const { ctx, inserts, patches } = makePersonalPublisherRecoveryCtx({
|
|
legacyResources: true,
|
|
unexpectedResourceOwner: true,
|
|
});
|
|
|
|
await expect(
|
|
recoverPersonalPublisherInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
publisherHandle: "gingiris",
|
|
previousGitHubProviderAccountId: "111",
|
|
nextGitHubProviderAccountId: "222",
|
|
nextUserHandle: "gingiris-1031",
|
|
reason: "Verified account continuity for issue #2555",
|
|
confirmIdentityVerified: true,
|
|
dryRun: false,
|
|
}),
|
|
).rejects.toThrow(/another user/i);
|
|
expect(patches).toHaveLength(0);
|
|
expect(inserts).toHaveLength(0);
|
|
});
|
|
|
|
it("fails closed when the recovered handle reservation belongs to another user", async () => {
|
|
const { ctx, inserts, patches } = makePersonalPublisherRecoveryCtx({
|
|
legacyResources: true,
|
|
unexpectedReservationOwner: true,
|
|
});
|
|
|
|
await expect(
|
|
recoverPersonalPublisherInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
publisherHandle: "gingiris",
|
|
previousGitHubProviderAccountId: "111",
|
|
nextGitHubProviderAccountId: "222",
|
|
nextUserHandle: "gingiris-1031",
|
|
reason: "Verified account continuity for issue #2555",
|
|
confirmIdentityVerified: true,
|
|
dryRun: false,
|
|
}),
|
|
).rejects.toThrow(/reservation .* belongs to another user/i);
|
|
expect(patches).toHaveLength(0);
|
|
expect(inserts).toHaveLength(0);
|
|
});
|
|
|
|
it("fails closed when recovered publisher resource migration exceeds the bounded batch", async () => {
|
|
const { ctx, inserts, patches } = makePersonalPublisherRecoveryCtx({
|
|
tooManyLegacySkills: true,
|
|
});
|
|
|
|
await expect(
|
|
recoverPersonalPublisherInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
publisherHandle: "gingiris",
|
|
previousGitHubProviderAccountId: "111",
|
|
nextGitHubProviderAccountId: "222",
|
|
nextUserHandle: "gingiris-1031",
|
|
reason: "Verified account continuity for issue #2555",
|
|
confirmIdentityVerified: true,
|
|
dryRun: false,
|
|
}),
|
|
).rejects.toThrow(/resumable owner migration/i);
|
|
expect(patches).toHaveLength(0);
|
|
expect(inserts).toHaveLength(0);
|
|
});
|
|
|
|
it("lets admins create a missing reserved OpenClaw org publisher with only the legacy package owner as owner", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
|
|
const users = new Map<string, Record<string, unknown>>([
|
|
["users:admin", { _id: "users:admin", role: "admin", handle: "admin" }],
|
|
[
|
|
"users:vincent",
|
|
{
|
|
_id: "users:vincent",
|
|
handle: "vincentkoc",
|
|
displayName: "Vincent Koc",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
]);
|
|
const publishers = new Map<string, Record<string, unknown>>();
|
|
const publisherMembers: Array<Record<string, unknown>> = [];
|
|
const inserts: Array<{ table: 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 === "publishers") publishers.set(id, row);
|
|
if (table === "publisherMembers") publisherMembers.push(row);
|
|
return id;
|
|
});
|
|
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
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(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let handle = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(
|
|
async () =>
|
|
[...publishers.values()].find((publisher) => publisher.handle === handle) ??
|
|
null,
|
|
),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
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,
|
|
),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
|
|
const result = await ensureOrgPublisherHandleInternalHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (...args: string[]) => {
|
|
const id = args.length === 2 ? args[1] : args[0];
|
|
const inserted = inserts.find((entry) => entry.value._id === id);
|
|
return users.get(id) ?? publishers.get(id) ?? inserted?.value ?? null;
|
|
}),
|
|
query,
|
|
insert,
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:admin",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
memberHandle: "vincentkoc",
|
|
memberRole: "owner",
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
handle: "openclaw",
|
|
created: true,
|
|
member: {
|
|
userId: "users:vincent",
|
|
handle: "vincentkoc",
|
|
role: "owner",
|
|
},
|
|
});
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "publishers",
|
|
value: expect.objectContaining({
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
}),
|
|
}),
|
|
);
|
|
const memberInserts = inserts.filter(
|
|
(entry) =>
|
|
entry.table === "publisherMembers" && entry.value.publisherId === result.publisherId,
|
|
);
|
|
expect(memberInserts).toEqual([
|
|
expect.objectContaining({
|
|
value: expect.objectContaining({
|
|
publisherId: result.publisherId,
|
|
userId: "users:vincent",
|
|
role: "owner",
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("lets admins update an org bio while preserving its logo and recording the reason", async () => {
|
|
const publisher = {
|
|
_id: "publishers:opik",
|
|
kind: "org",
|
|
handle: "opik",
|
|
displayName: "Opik",
|
|
bio: undefined,
|
|
image: "https://storage.example/opik-logo",
|
|
imageStorageId: "storage:opik-logo",
|
|
};
|
|
const patch = vi.fn(async () => {});
|
|
const insert = vi.fn(async () => "auditLogs:1");
|
|
const deleteStorage = vi.fn(async () => {});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id, role: "admin" };
|
|
return null;
|
|
}),
|
|
query: vi.fn(() => ({
|
|
withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })),
|
|
})),
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
storage: { delete: deleteStorage },
|
|
};
|
|
|
|
await expect(
|
|
updateOrgPublisherProfileInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
handle: "OPIK",
|
|
bio: "Open-source AI observability and evaluation platform.",
|
|
reason: "Replace placeholder copy with verified official publisher description",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
handle: "opik",
|
|
bioUpdated: true,
|
|
logoUpdated: false,
|
|
image: "https://storage.example/opik-logo",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:opik",
|
|
expect.objectContaining({
|
|
bio: "Open-source AI observability and evaluation platform.",
|
|
}),
|
|
);
|
|
expect(deleteStorage).not.toHaveBeenCalled();
|
|
expect(insert).toHaveBeenCalledWith(
|
|
"auditLogs",
|
|
expect.objectContaining({
|
|
actorUserId: "users:admin",
|
|
action: "publisher.profile.update",
|
|
targetId: "publishers:opik",
|
|
metadata: expect.objectContaining({
|
|
source: "publisher.org.admin",
|
|
reason: "Replace placeholder copy with verified official publisher description",
|
|
bioUpdated: true,
|
|
logoUpdated: false,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("replaces a stored org logo only after validating the uploaded file", async () => {
|
|
const publisher = {
|
|
_id: "publishers:heygen",
|
|
kind: "org",
|
|
handle: "heygen-com",
|
|
displayName: "HeyGen",
|
|
image: "https://storage.example/old-logo",
|
|
imageStorageId: "storage:old-logo",
|
|
};
|
|
const patch = vi.fn(async () => {});
|
|
const deleteStorage = vi.fn(async () => {});
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn(async (id: string) => {
|
|
if (id === "users:admin") return { _id: id, role: "admin" };
|
|
return null;
|
|
}),
|
|
system: {
|
|
get: vi.fn(async () => ({ contentType: "image/png", size: 1024 })),
|
|
},
|
|
query: vi.fn(() => ({
|
|
withIndex: vi.fn(() => ({ unique: vi.fn(async () => publisher) })),
|
|
})),
|
|
patch,
|
|
insert: vi.fn(async () => "auditLogs:1"),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
storage: {
|
|
getUrl: vi.fn(async () => "https://storage.example/new-logo"),
|
|
delete: deleteStorage,
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
updateOrgPublisherProfileInternalHandler(ctx as never, {
|
|
actorUserId: "users:admin",
|
|
handle: "heygen-com",
|
|
imageStorageId: "storage:new-logo",
|
|
reason: "Replace personal avatar with the official HeyGen brand symbol",
|
|
}),
|
|
).resolves.toMatchObject({
|
|
bioUpdated: false,
|
|
logoUpdated: true,
|
|
image: "https://storage.example/new-logo",
|
|
});
|
|
expect(patch).toHaveBeenCalledWith(
|
|
"publishers:heygen",
|
|
expect.objectContaining({
|
|
image: "https://storage.example/new-logo",
|
|
imageStorageId: "storage:new-logo",
|
|
}),
|
|
);
|
|
expect(deleteStorage).toHaveBeenCalledWith("storage:old-logo");
|
|
});
|
|
|
|
it("lets an admin remove one org owner when another owner remains", async () => {
|
|
const publisherMembers = [
|
|
{
|
|
_id: "publisherMembers:patrick",
|
|
publisherId: "publishers:opik",
|
|
userId: "users:patrick",
|
|
role: "owner",
|
|
},
|
|
{
|
|
_id: "publisherMembers:vincent",
|
|
publisherId: "publishers:opik",
|
|
userId: "users:vincent",
|
|
role: "owner",
|
|
},
|
|
];
|
|
const deleted: string[] = [];
|
|
const inserts: Array<{ table: string; value: Record<string, unknown> }> = [];
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let handle = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
handle === "patrick-erichsen-2"
|
|
? { _id: "users:patrick", handle: "patrick-erichsen-2" }
|
|
: null,
|
|
),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let handle = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "handle") handle = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
unique: vi.fn(async () =>
|
|
handle === "opik" ? { _id: "publishers:opik", kind: "org", handle } : null,
|
|
),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
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 () => {
|
|
if (indexName !== "by_publisher_user") return null;
|
|
return (
|
|
publisherMembers.find(
|
|
(member) => member.publisherId === publisherId && member.userId === userId,
|
|
) ?? null
|
|
);
|
|
}),
|
|
collect: vi.fn(async () =>
|
|
publisherMembers.filter((member) => member.publisherId === publisherId),
|
|
),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
|
|
const result = await removeOrgPublisherMemberInternalHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) =>
|
|
id === "users:admin"
|
|
? { _id: id, role: "admin" }
|
|
: id === "users:vincent"
|
|
? { _id: id, handle: "vincentkoc" }
|
|
: null,
|
|
),
|
|
query,
|
|
insert: vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
inserts.push({ table, value });
|
|
return `${table}:audit`;
|
|
}),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(async (id: string) => {
|
|
deleted.push(id);
|
|
}),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:admin",
|
|
handle: "opik",
|
|
memberHandle: "patrick-erichsen-2",
|
|
},
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
handle: "opik",
|
|
removed: true,
|
|
member: { handle: "patrick-erichsen-2", role: "owner" },
|
|
});
|
|
expect(deleted).toEqual(["publisherMembers:patrick"]);
|
|
expect(inserts).toContainEqual(
|
|
expect.objectContaining({
|
|
table: "auditLogs",
|
|
value: expect.objectContaining({
|
|
actorUserId: "users:admin",
|
|
action: "publisher.member.remove",
|
|
targetId: "publishers:opik",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects removing the last org owner", async () => {
|
|
const publisherMembers = [
|
|
{
|
|
_id: "publisherMembers:patrick",
|
|
publisherId: "publishers:opik",
|
|
userId: "users:patrick",
|
|
role: "owner",
|
|
},
|
|
];
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn(async () => ({ _id: "users:patrick", handle: "patrick-erichsen-2" })),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publishers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn(async () => ({ _id: "publishers:opik", kind: "org", handle: "opik" })),
|
|
})),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
unique: vi.fn(async () => publisherMembers[0]),
|
|
collect: vi.fn(async () => publisherMembers),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
|
|
await expect(
|
|
removeOrgPublisherMemberInternalHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (id: string) =>
|
|
id === "users:admin" ? { _id: id, role: "admin" } : null,
|
|
),
|
|
query,
|
|
insert: vi.fn(),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:admin",
|
|
handle: "opik",
|
|
memberHandle: "patrick-erichsen-2",
|
|
},
|
|
),
|
|
).rejects.toThrow("Publisher must have at least one owner");
|
|
});
|
|
|
|
it("converts a reserved OpenClaw legacy personal publisher into an org with a safe default fallback", async () => {
|
|
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
|
|
|
const users = new Map<string, Record<string, unknown>>([
|
|
["users:admin", { _id: "users:admin", role: "admin" }],
|
|
[
|
|
"users:openclaw",
|
|
{
|
|
_id: "users:openclaw",
|
|
_creationTime: 1,
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
trustedPublisher: true,
|
|
personalPublisherId: "publishers:openclaw",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
]);
|
|
const publishers = new Map<string, Record<string, unknown>>([
|
|
[
|
|
"publishers:openclaw",
|
|
{
|
|
_id: "publishers:openclaw",
|
|
_creationTime: 1,
|
|
kind: "user",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
linkedUserId: "users:openclaw",
|
|
trustedPublisher: true,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
],
|
|
]);
|
|
const packages = [
|
|
{
|
|
_id: "packages:demo",
|
|
ownerUserId: "users:openclaw",
|
|
ownerPublisherId: undefined,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
const publisherMembers = [
|
|
{
|
|
_id: "publisherMembers:openclaw-owner",
|
|
publisherId: "publishers:openclaw",
|
|
userId: "users:openclaw",
|
|
role: "owner",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
];
|
|
|
|
const patch = vi.fn(async (id: string, value: Record<string, unknown>) => {
|
|
if (users.has(id)) {
|
|
users.set(id, { ...users.get(id), ...value });
|
|
return;
|
|
}
|
|
if (publishers.has(id)) {
|
|
publishers.set(id, { ...publishers.get(id), ...value });
|
|
return;
|
|
}
|
|
const pkg = packages.find((entry) => entry._id === id);
|
|
if (pkg) {
|
|
Object.assign(pkg, value);
|
|
return;
|
|
}
|
|
const member = publisherMembers.find((entry) => entry._id === id);
|
|
if (member) {
|
|
Object.assign(member, value);
|
|
return;
|
|
}
|
|
throw new Error(`unexpected patch ${id}`);
|
|
});
|
|
|
|
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
|
|
if (table === "publishers") {
|
|
const id = "publishers:user";
|
|
publishers.set(id, { _id: id, _creationTime: 1, ...value });
|
|
return id;
|
|
}
|
|
if (table === "publisherMembers") {
|
|
const id = `publisherMembers:${publisherMembers.length + 1}`;
|
|
publisherMembers.push({
|
|
_id: id,
|
|
publisherId: String(value.publisherId),
|
|
userId: String(value.userId),
|
|
role: String(value.role),
|
|
createdAt: Number(value.createdAt),
|
|
updatedAt: Number(value.updatedAt),
|
|
});
|
|
return id;
|
|
}
|
|
if (table === "auditLogs") return "auditLogs:1";
|
|
throw new Error(`unexpected insert ${table}`);
|
|
});
|
|
|
|
const query = vi.fn((table: string) => {
|
|
if (table === "users") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
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(
|
|
(
|
|
_indexName: 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);
|
|
return {
|
|
unique: vi.fn(async () => {
|
|
if (handle) {
|
|
return (
|
|
[...publishers.values()].find((publisher) => publisher.handle === handle) ??
|
|
null
|
|
);
|
|
}
|
|
if (linkedUserId) {
|
|
return (
|
|
[...publishers.values()].find(
|
|
(publisher) => publisher.linkedUserId === linkedUserId,
|
|
) ?? null
|
|
);
|
|
}
|
|
return null;
|
|
}),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "publisherMembers") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
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 === "packages") {
|
|
return {
|
|
withIndex: vi.fn(
|
|
(
|
|
_indexName: string,
|
|
builder?: (q: { eq: (field: string, value: string) => unknown }) => unknown,
|
|
) => {
|
|
let ownerUserId = "";
|
|
let ownerPublisherId = "";
|
|
const q = {
|
|
eq: (field: string, value: string) => {
|
|
if (field === "ownerUserId") ownerUserId = value;
|
|
if (field === "ownerPublisherId") ownerPublisherId = value;
|
|
return q;
|
|
},
|
|
};
|
|
builder?.(q);
|
|
return {
|
|
collect: vi.fn(async () => {
|
|
if (ownerUserId) {
|
|
return packages.filter((pkg) => pkg.ownerUserId === ownerUserId);
|
|
}
|
|
if (ownerPublisherId) {
|
|
return packages.filter((pkg) => pkg.ownerPublisherId === ownerPublisherId);
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
},
|
|
),
|
|
};
|
|
}
|
|
if (table === "skills") {
|
|
return {
|
|
withIndex: vi.fn(() => ({
|
|
collect: vi.fn(async () => []),
|
|
})),
|
|
};
|
|
}
|
|
throw new Error(`unexpected table ${table}`);
|
|
});
|
|
|
|
const result = await migrateLegacyPublisherHandleToOrgInternalHandler(
|
|
{
|
|
db: {
|
|
get: vi.fn(async (...args: string[]) => {
|
|
const id = args.length === 2 ? args[1] : args[0];
|
|
return users.get(id) ?? publishers.get(id) ?? null;
|
|
}),
|
|
query,
|
|
patch,
|
|
insert,
|
|
delete: vi.fn(),
|
|
replace: vi.fn(),
|
|
normalizeId: vi.fn(),
|
|
},
|
|
} as never,
|
|
{
|
|
actorUserId: "users:admin",
|
|
handle: "openclaw",
|
|
displayName: "OpenClaw",
|
|
} as never,
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
handle: "openclaw",
|
|
orgPublisherId: "publishers:openclaw",
|
|
legacyUserId: "users:openclaw",
|
|
fallbackUserHandle: "user",
|
|
personalPublisherId: "publishers:user",
|
|
convertedExistingPublisher: true,
|
|
packagesMigrated: 1,
|
|
});
|
|
expect(users.get("users:openclaw")).toEqual(
|
|
expect.objectContaining({
|
|
handle: "user",
|
|
personalPublisherId: "publishers:user",
|
|
}),
|
|
);
|
|
expect(publishers.get("publishers:openclaw")).toEqual(
|
|
expect.objectContaining({
|
|
kind: "org",
|
|
handle: "openclaw",
|
|
linkedUserId: undefined,
|
|
}),
|
|
);
|
|
expect(publishers.get("publishers:user")).toEqual(
|
|
expect.objectContaining({
|
|
kind: "user",
|
|
handle: "user",
|
|
linkedUserId: "users:openclaw",
|
|
}),
|
|
);
|
|
expect(packages[0]).toEqual(
|
|
expect.objectContaining({
|
|
ownerPublisherId: "publishers:openclaw",
|
|
}),
|
|
);
|
|
});
|
|
});
|