mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
263 lines
8.0 KiB
TypeScript
263 lines
8.0 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import {
|
|
BANNED_REAUTH_MESSAGE,
|
|
DELETED_ACCOUNT_REAUTH_MESSAGE,
|
|
createGitHubAuthProvider,
|
|
handleDeletedUserSignIn,
|
|
normalizeGitHubProfileId,
|
|
} from "./auth";
|
|
|
|
function makeCtx({
|
|
user,
|
|
banRecords,
|
|
}: {
|
|
user: {
|
|
deletedAt?: number;
|
|
deactivatedAt?: number;
|
|
purgedAt?: number;
|
|
banReason?: string;
|
|
} | null;
|
|
banRecords?: Array<Record<string, unknown>>;
|
|
}) {
|
|
const query = {
|
|
withIndex: vi.fn().mockReturnValue({
|
|
collect: vi.fn().mockResolvedValue(banRecords ?? []),
|
|
}),
|
|
};
|
|
const ctx = {
|
|
db: {
|
|
get: vi.fn().mockResolvedValue(user),
|
|
patch: vi.fn().mockResolvedValue(null),
|
|
query: vi.fn().mockReturnValue(query),
|
|
},
|
|
};
|
|
return { ctx, query };
|
|
}
|
|
|
|
describe("handleDeletedUserSignIn", () => {
|
|
const userId = "users:1" as Id<"users">;
|
|
|
|
it("skips when user not found", async () => {
|
|
const { ctx } = makeCtx({ user: null });
|
|
|
|
await handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId });
|
|
|
|
expect(ctx.db.get).toHaveBeenCalledWith(userId);
|
|
expect(ctx.db.query).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips active users", async () => {
|
|
const { ctx } = makeCtx({ user: { deletedAt: undefined, deactivatedAt: undefined } });
|
|
|
|
await handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId });
|
|
|
|
expect(ctx.db.query).not.toHaveBeenCalled();
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks sign-in for deactivated users", async () => {
|
|
const { ctx } = makeCtx({ user: { deactivatedAt: 123, purgedAt: 123 } });
|
|
|
|
await expect(
|
|
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
|
).rejects.toThrow(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
|
|
|
expect(ctx.db.query).not.toHaveBeenCalled();
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("migrates legacy self-deleted users and blocks sign-in", async () => {
|
|
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecords: [] });
|
|
|
|
await expect(
|
|
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
|
).rejects.toThrow(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
|
|
|
expect(ctx.db.patch).toHaveBeenCalledWith(userId, {
|
|
deletedAt: undefined,
|
|
deactivatedAt: 123,
|
|
purgedAt: 123,
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("migrates legacy users on fresh login (existingUserId is null)", async () => {
|
|
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecords: [] });
|
|
|
|
await expect(
|
|
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: null }),
|
|
).rejects.toThrow(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
|
|
|
expect(ctx.db.patch).toHaveBeenCalledWith(userId, {
|
|
deletedAt: undefined,
|
|
deactivatedAt: 123,
|
|
purgedAt: 123,
|
|
updatedAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("skips mutation when existingUserId does not match userId", async () => {
|
|
const otherUserId = "users:999" as Id<"users">;
|
|
const { ctx } = makeCtx({ user: { deletedAt: 123 } });
|
|
|
|
await handleDeletedUserSignIn(ctx as never, { userId, existingUserId: otherUserId });
|
|
|
|
expect(ctx.db.query).not.toHaveBeenCalled();
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks banned users with a custom message", async () => {
|
|
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecords: [{ action: "user.ban" }] });
|
|
|
|
await expect(
|
|
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
|
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
|
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks users auto-banned for malware", async () => {
|
|
const { ctx } = makeCtx({
|
|
user: { deletedAt: 123, banReason: "malware auto-ban" },
|
|
banRecords: [{ action: "user.autoban.malware" }],
|
|
});
|
|
|
|
await expect(
|
|
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
|
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
|
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks users auto-banned for publisher abuse", async () => {
|
|
const { ctx } = makeCtx({
|
|
user: { deletedAt: 123, banReason: "publisher_abuse: potential ban candidate" },
|
|
banRecords: [{ action: "user.autoban.publisher_abuse" }],
|
|
});
|
|
|
|
await expect(
|
|
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
|
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
|
|
|
expect(ctx.db.patch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not leak the moderator ban reason in the sign-in error", async () => {
|
|
const { ctx } = makeCtx({
|
|
user: { deletedAt: 123, banReason: "Chargeback fraud" },
|
|
banRecords: [{ action: "user.ban" }],
|
|
});
|
|
|
|
await expect(
|
|
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
|
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
|
});
|
|
});
|
|
|
|
describe("GitHub auth provider", () => {
|
|
it("requests read-only GitHub organization membership access", () => {
|
|
const provider = createGitHubAuthProvider() as {
|
|
options?: { authorization?: { params?: { scope?: string } } };
|
|
};
|
|
|
|
expect(provider.options?.authorization?.params?.scope?.split(" ")).toContain("read:org");
|
|
});
|
|
|
|
it("does not link ClawHub accounts by GitHub profile email", () => {
|
|
const provider = createGitHubAuthProvider() as {
|
|
options?: { allowDangerousEmailAccountLinking?: boolean };
|
|
};
|
|
|
|
expect(provider.options?.allowDangerousEmailAccountLinking).toBe(false);
|
|
});
|
|
|
|
it("normalizes numeric GitHub profile ids", () => {
|
|
expect(normalizeGitHubProfileId(123456)).toBe("123456");
|
|
expect(normalizeGitHubProfileId("789012")).toBe("789012");
|
|
});
|
|
|
|
it("rejects missing or nonnumeric GitHub profile ids", () => {
|
|
expect(() => normalizeGitHubProfileId(undefined)).toThrow(
|
|
"GitHub OAuth profile is missing a valid numeric id",
|
|
);
|
|
expect(() => normalizeGitHubProfileId("undefined")).toThrow(
|
|
"GitHub OAuth profile is missing a valid numeric id",
|
|
);
|
|
expect(() => normalizeGitHubProfileId("github-user")).toThrow(
|
|
"GitHub OAuth profile is missing a valid numeric id",
|
|
);
|
|
});
|
|
|
|
it("fails closed when the GitHub provider receives a malformed profile", async () => {
|
|
const provider = createGitHubAuthProvider() as {
|
|
options?: {
|
|
profile?: (
|
|
profile: Record<string, unknown>,
|
|
tokens: { access_token?: string },
|
|
) => Promise<Record<string, unknown>>;
|
|
};
|
|
};
|
|
|
|
await expect(provider.options?.profile?.({ message: "Bad credentials" }, {})).rejects.toThrow(
|
|
"GitHub OAuth profile is missing a valid numeric id",
|
|
);
|
|
await expect(
|
|
provider.options?.profile?.({ id: 123456, login: "fixture-user" }, {}),
|
|
).resolves.toEqual({
|
|
id: "123456",
|
|
name: "fixture-user",
|
|
email: undefined,
|
|
image: undefined,
|
|
});
|
|
});
|
|
|
|
it("adds a verified GitHub organization snapshot to the OAuth profile", async () => {
|
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify([
|
|
{
|
|
state: "active",
|
|
role: "member",
|
|
organization: { id: 42, login: "trycua" },
|
|
},
|
|
]),
|
|
{ status: 200 },
|
|
),
|
|
);
|
|
const provider = createGitHubAuthProvider() as {
|
|
options?: {
|
|
profile?: (
|
|
profile: Record<string, unknown>,
|
|
tokens: { access_token?: string },
|
|
) => Promise<Record<string, unknown>>;
|
|
};
|
|
};
|
|
|
|
await expect(
|
|
provider.options?.profile?.(
|
|
{ id: 123456, login: "fixture-user" },
|
|
{ access_token: "test-token-placeholder" },
|
|
),
|
|
).resolves.toEqual({
|
|
id: "123456",
|
|
name: "fixture-user",
|
|
email: undefined,
|
|
image: undefined,
|
|
githubOrgMembershipSync: {
|
|
memberships: [
|
|
{
|
|
githubOrgId: "42",
|
|
login: "trycua",
|
|
avatarUrl: undefined,
|
|
role: "member",
|
|
},
|
|
],
|
|
syncedAt: expect.any(Number),
|
|
truncated: false,
|
|
},
|
|
});
|
|
fetchMock.mockRestore();
|
|
});
|
|
});
|