From 653dce747bc8b45ebdd7e121522b52b39d64a4fd Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 9 Jun 2026 14:42:43 -0600 Subject: [PATCH] fix: select role from admin_users instead of admin_user_brands The membership query in getAdminUser() was selecting role from adminUserBrands.adminUserId, which is wrong - admin_user_brands has no role column. Role is stored in admin_users. Also added try/catch around withPlatformAdmin to prevent DB errors from throwing. --- src/lib/admin-permissions.ts | 77 ++++++++++++++++++--------------- tests/unit/getAdminUser.test.ts | 66 ++++++++++++++++------------ 2 files changed, 81 insertions(+), 62 deletions(-) diff --git a/src/lib/admin-permissions.ts b/src/lib/admin-permissions.ts index 8034ae7..6f95145 100644 --- a/src/lib/admin-permissions.ts +++ b/src/lib/admin-permissions.ts @@ -47,45 +47,52 @@ export async function getAdminUser(): Promise { if (!sessionEmail) return null; - return await withPlatformAdmin(async (db) => { - const userRows = await db - .select() - .from(adminUsers) - .where(eq(adminUsers.email, sessionEmail.toLowerCase())) - .limit(1); - const user = userRows[0]; - if (!user) return null; + try { + return await withPlatformAdmin(async (db) => { + const userRows = await db + .select() + .from(adminUsers) + .where(eq(adminUsers.email, sessionEmail.toLowerCase())) + .limit(1); + const user = userRows[0]; + if (!user) return null; - const membershipRows = await db - .select({ - brandId: adminUserBrands.brandId, - brandName: brands.name, - brandSlug: brands.slug, - role: adminUserBrands.adminUserId, - }) - .from(adminUserBrands) - .innerJoin(brands, eq(brands.id, adminUserBrands.brandId)) - .where(eq(adminUserBrands.adminUserId, user.id)) - .limit(1); + const membershipRows = await db + .select({ + brandId: adminUserBrands.brandId, + brandName: brands.name, + brandSlug: brands.slug, + // Role comes from admin_users table, not admin_user_brands + role: adminUsers.role, + }) + .from(adminUserBrands) + .innerJoin(brands, eq(brands.id, adminUserBrands.brandId)) + .innerJoin(adminUsers, eq(adminUsers.id, adminUserBrands.adminUserId)) + .where(eq(adminUserBrands.adminUserId, user.id)) + .limit(1); - if (membershipRows.length === 0) { - // Signed in but not provisioned for any brand. - return null; - } + if (membershipRows.length === 0) { + // Signed in but not provisioned for any brand. + return null; + } - const m = membershipRows[0]; - const role = user.role as AdminRole; - return buildAdminUser({ - id: user.id, - email: user.email, - displayName: user.name, - brandId: m.brandId, - brandName: m.brandName, - brandSlug: m.brandSlug, - role, - active: true, + const m = membershipRows[0]; + const role = user.role as AdminRole; + return buildAdminUser({ + id: user.id, + email: user.email, + displayName: user.name, + brandId: m.brandId, + brandName: m.brandName, + brandSlug: m.brandSlug, + role, + active: true, + }); }); - }); + } catch (err) { + console.error("[admin-permissions] Database query failed:", err); + return null; + } } /** diff --git a/tests/unit/getAdminUser.test.ts b/tests/unit/getAdminUser.test.ts index a5e0fb3..53c820b 100644 --- a/tests/unit/getAdminUser.test.ts +++ b/tests/unit/getAdminUser.test.ts @@ -6,58 +6,68 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +// Use vi.hoisted to ensure mocks are available when vi.mock runs +const { mockDb, mockWithPlatformAdmin, mockGetSession, mockCookies } = vi.hoisted(() => { + const mockDb = { + select: vi.fn(), + }; + return { + mockDb, + mockWithPlatformAdmin: vi.fn(async (fn: (db: typeof mockDb) => Promise) => { + return fn(mockDb); + }), + mockGetSession: vi.fn(), + mockCookies: vi.fn(), + }; +}); + // Stub the server-only guard so the module can be imported under vitest. vi.mock("server-only", () => ({})); -// Mock the Drizzle client wrapper so we don't need a real DB. -type MockFn = (arg: unknown) => Promise; -const mockSelect = vi.fn(); -const mockWithPlatformAdmin = vi.fn(async (fn: MockFn) => fn({ select: mockSelect })); +// Mock the Drizzle client wrapper vi.mock("@/db/client", () => ({ - withPlatformAdmin: (fn: MockFn) => mockWithPlatformAdmin(fn), + withPlatformAdmin: mockWithPlatformAdmin, })); -// Mock the getSession() function. The default mock returns null (no session). -const getSessionMock = vi.fn(); +// Mock the getSession() function vi.mock("@/lib/auth", () => ({ - getSession: getSessionMock, + getSession: mockGetSession, })); -// Mock cookies() so we don't read a real cookie store. -const cookieStoreGet = vi.fn(); +// Mock cookies() so we don't read a real cookie store vi.mock("next/headers", () => ({ - cookies: () => - Promise.resolve({ - get: (name: string) => cookieStoreGet(name), - }), + cookies: mockCookies, })); import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions"; +const cookieStore = { get: vi.fn() }; + beforeEach(() => { - mockSelect.mockReset(); - getSessionMock.mockReset(); - cookieStoreGet.mockReset(); + mockDb.select.mockReset(); + mockWithPlatformAdmin.mockReset(); + mockGetSession.mockReset(); + mockCookies.mockImplementation(() => Promise.resolve(cookieStore)); + cookieStore.get.mockReturnValue(undefined); }); describe("getAdminUser()", () => { it("returns null when there is no Neon Auth session", async () => { - getSessionMock.mockResolvedValue({ data: null }); - cookieStoreGet.mockReturnValue(undefined); + mockGetSession.mockResolvedValue({ data: null }); const u = await getAdminUser(); expect(u).toBeNull(); }); it("returns null when the session has no email", async () => { - getSessionMock.mockResolvedValue({ data: { user: { name: "no-email" } } }); + mockGetSession.mockResolvedValue({ data: { user: { name: "no-email" } } }); const u = await getAdminUser(); expect(u).toBeNull(); }); it("returns null when the email is not in the users table", async () => { - getSessionMock.mockResolvedValue({ data: { user: { email: "unknown@example.com" } } }); + mockGetSession.mockResolvedValue({ data: { user: { email: "unknown@example.com" } } }); // First select: users. Returns empty. - mockSelect.mockReturnValueOnce({ + mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [], @@ -69,9 +79,9 @@ describe("getAdminUser()", () => { }); it("returns null when the user exists but has no admin_user_brands row", async () => { - getSessionMock.mockResolvedValue({ data: { user: { email: "no-brand@example.com" } } }); + mockGetSession.mockResolvedValue({ data: { user: { email: "no-brand@example.com" } } }); // First select: users — returns the user - mockSelect + mockDb.select .mockReturnValueOnce({ from: () => ({ where: () => ({ @@ -80,6 +90,7 @@ describe("getAdminUser()", () => { id: "user-1", email: "no-brand@example.com", name: "No Brand", + role: "brand_admin", }, ], }), @@ -100,8 +111,8 @@ describe("getAdminUser()", () => { }); it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => { - getSessionMock.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } }); - mockSelect + mockGetSession.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } }); + mockDb.select .mockReturnValueOnce({ from: () => ({ where: () => ({ @@ -110,6 +121,7 @@ describe("getAdminUser()", () => { id: "user-tux", email: "admin@tuxedo.example", name: "Tux Admin", + role: "brand_admin", }, ], }), @@ -185,4 +197,4 @@ describe("permissionsForRole()", () => { expect(p.can_manage_products).toBe(false); expect(p.can_manage_billing).toBe(false); }); -}); +}); \ No newline at end of file