fix: select role from admin_users instead of admin_user_brands
Deploy to route.crispygoat.com / deploy (push) Has been cancelled
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:
@@ -47,45 +47,52 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<unknown>) => {
|
||||
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<unknown>;
|
||||
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",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user