fix: select role from admin_users instead of admin_user_brands
Deploy to route.crispygoat.com / deploy (push) Has been cancelled

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.
This commit is contained in:
openclaw
2026-06-09 14:42:43 -06:00
parent b46e00fefd
commit 653dce747b
2 changed files with 81 additions and 62 deletions
+42 -35
View File
@@ -47,45 +47,52 @@ export async function getAdminUser(): Promise<AdminUser | null> {
if (!sessionEmail) return null; if (!sessionEmail) return null;
return await withPlatformAdmin(async (db) => { try {
const userRows = await db return await withPlatformAdmin(async (db) => {
.select() const userRows = await db
.from(adminUsers) .select()
.where(eq(adminUsers.email, sessionEmail.toLowerCase())) .from(adminUsers)
.limit(1); .where(eq(adminUsers.email, sessionEmail.toLowerCase()))
const user = userRows[0]; .limit(1);
if (!user) return null; const user = userRows[0];
if (!user) return null;
const membershipRows = await db const membershipRows = await db
.select({ .select({
brandId: adminUserBrands.brandId, brandId: adminUserBrands.brandId,
brandName: brands.name, brandName: brands.name,
brandSlug: brands.slug, brandSlug: brands.slug,
role: adminUserBrands.adminUserId, // Role comes from admin_users table, not admin_user_brands
}) role: adminUsers.role,
.from(adminUserBrands) })
.innerJoin(brands, eq(brands.id, adminUserBrands.brandId)) .from(adminUserBrands)
.where(eq(adminUserBrands.adminUserId, user.id)) .innerJoin(brands, eq(brands.id, adminUserBrands.brandId))
.limit(1); .innerJoin(adminUsers, eq(adminUsers.id, adminUserBrands.adminUserId))
.where(eq(adminUserBrands.adminUserId, user.id))
.limit(1);
if (membershipRows.length === 0) { if (membershipRows.length === 0) {
// Signed in but not provisioned for any brand. // Signed in but not provisioned for any brand.
return null; return null;
} }
const m = membershipRows[0]; const m = membershipRows[0];
const role = user.role as AdminRole; const role = user.role as AdminRole;
return buildAdminUser({ return buildAdminUser({
id: user.id, id: user.id,
email: user.email, email: user.email,
displayName: user.name, displayName: user.name,
brandId: m.brandId, brandId: m.brandId,
brandName: m.brandName, brandName: m.brandName,
brandSlug: m.brandSlug, brandSlug: m.brandSlug,
role, role,
active: true, active: true,
});
}); });
}); } catch (err) {
console.error("[admin-permissions] Database query failed:", err);
return null;
}
} }
/** /**
+39 -27
View File
@@ -6,58 +6,68 @@
*/ */
import { describe, it, expect, vi, beforeEach } from "vitest"; 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<unknown>) => {
return fn(mockDb);
}),
mockGetSession: vi.fn(),
mockCookies: vi.fn(),
};
});
// Stub the server-only guard so the module can be imported under vitest. // Stub the server-only guard so the module can be imported under vitest.
vi.mock("server-only", () => ({})); vi.mock("server-only", () => ({}));
// Mock the Drizzle client wrapper so we don't need a real DB. // Mock the Drizzle client wrapper
type MockFn = (arg: unknown) => Promise<unknown>;
const mockSelect = vi.fn();
const mockWithPlatformAdmin = vi.fn(async (fn: MockFn) => fn({ select: mockSelect }));
vi.mock("@/db/client", () => ({ vi.mock("@/db/client", () => ({
withPlatformAdmin: (fn: MockFn) => mockWithPlatformAdmin(fn), withPlatformAdmin: mockWithPlatformAdmin,
})); }));
// Mock the getSession() function. The default mock returns null (no session). // Mock the getSession() function
const getSessionMock = vi.fn();
vi.mock("@/lib/auth", () => ({ vi.mock("@/lib/auth", () => ({
getSession: getSessionMock, getSession: mockGetSession,
})); }));
// Mock cookies() so we don't read a real cookie store. // Mock cookies() so we don't read a real cookie store
const cookieStoreGet = vi.fn();
vi.mock("next/headers", () => ({ vi.mock("next/headers", () => ({
cookies: () => cookies: mockCookies,
Promise.resolve({
get: (name: string) => cookieStoreGet(name),
}),
})); }));
import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions"; import { getAdminUser, buildDevAdmin, permissionsForRole } from "@/lib/admin-permissions";
const cookieStore = { get: vi.fn() };
beforeEach(() => { beforeEach(() => {
mockSelect.mockReset(); mockDb.select.mockReset();
getSessionMock.mockReset(); mockWithPlatformAdmin.mockReset();
cookieStoreGet.mockReset(); mockGetSession.mockReset();
mockCookies.mockImplementation(() => Promise.resolve(cookieStore));
cookieStore.get.mockReturnValue(undefined);
}); });
describe("getAdminUser()", () => { describe("getAdminUser()", () => {
it("returns null when there is no Neon Auth session", async () => { it("returns null when there is no Neon Auth session", async () => {
getSessionMock.mockResolvedValue({ data: null }); mockGetSession.mockResolvedValue({ data: null });
cookieStoreGet.mockReturnValue(undefined);
const u = await getAdminUser(); const u = await getAdminUser();
expect(u).toBeNull(); expect(u).toBeNull();
}); });
it("returns null when the session has no email", async () => { 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(); const u = await getAdminUser();
expect(u).toBeNull(); expect(u).toBeNull();
}); });
it("returns null when the email is not in the users table", async () => { 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. // First select: users. Returns empty.
mockSelect.mockReturnValueOnce({ mockDb.select.mockReturnValueOnce({
from: () => ({ from: () => ({
where: () => ({ where: () => ({
limit: async () => [], limit: async () => [],
@@ -69,9 +79,9 @@ describe("getAdminUser()", () => {
}); });
it("returns null when the user exists but has no admin_user_brands row", async () => { 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 // First select: users — returns the user
mockSelect mockDb.select
.mockReturnValueOnce({ .mockReturnValueOnce({
from: () => ({ from: () => ({
where: () => ({ where: () => ({
@@ -80,6 +90,7 @@ describe("getAdminUser()", () => {
id: "user-1", id: "user-1",
email: "no-brand@example.com", email: "no-brand@example.com",
name: "No Brand", name: "No Brand",
role: "brand_admin",
}, },
], ],
}), }),
@@ -100,8 +111,8 @@ describe("getAdminUser()", () => {
}); });
it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => { it("returns a fully-populated AdminUser for a provisioned brand_admin", async () => {
getSessionMock.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } }); mockGetSession.mockResolvedValue({ data: { user: { email: "admin@tuxedo.example" } } });
mockSelect mockDb.select
.mockReturnValueOnce({ .mockReturnValueOnce({
from: () => ({ from: () => ({
where: () => ({ where: () => ({
@@ -110,6 +121,7 @@ describe("getAdminUser()", () => {
id: "user-tux", id: "user-tux",
email: "admin@tuxedo.example", email: "admin@tuxedo.example",
name: "Tux Admin", name: "Tux Admin",
role: "brand_admin",
}, },
], ],
}), }),
@@ -185,4 +197,4 @@ describe("permissionsForRole()", () => {
expect(p.can_manage_products).toBe(false); expect(p.can_manage_products).toBe(false);
expect(p.can_manage_billing).toBe(false); expect(p.can_manage_billing).toBe(false);
}); });
}); });