diff --git a/src/routes/(protected)/admin/api-keys/+page.server.ts b/src/routes/(protected)/admin/api-keys/+page.server.ts index 22bba2b..49a5628 100644 --- a/src/routes/(protected)/admin/api-keys/+page.server.ts +++ b/src/routes/(protected)/admin/api-keys/+page.server.ts @@ -2,12 +2,10 @@ import type { PageServerLoad } from './$types'; import { error } from '@sveltejs/kit'; import { prisma } from '$lib/db'; import { rotateApiKey } from '$lib/server/api-key'; +import { isSuper } from '$lib/server/roles'; -const SUPER = 1; - -export const load: PageServerLoad = async ({ parent }) => { - const { adminUser } = await parent(); - if (adminUser.roleId > SUPER) { +export const load: PageServerLoad = async ({ locals }) => { + if (!isSuper(locals)) { throw error(403, 'Only Super admins can manage API keys'); } @@ -39,18 +37,9 @@ export const load: PageServerLoad = async ({ parent }) => { }; }; -async function viewerRoleId(locals: App.Locals): Promise { - if (!locals.user?.id) return 4; - const user = await prisma.user.findUnique({ - where: { id: locals.user.id }, - select: { roleId: true } - }); - return user?.roleId ?? 4; -} - export const actions = { rotate: async ({ locals }) => { - if ((await viewerRoleId(locals)) > SUPER) { + if (!isSuper(locals)) { return { success: false, error: 'Not authorized' }; } @@ -63,7 +52,7 @@ export const actions = { }; }, rotateEmergency: async ({ locals }) => { - if ((await viewerRoleId(locals)) > SUPER) { + if (!isSuper(locals)) { return { success: false, error: 'Not authorized' }; } diff --git a/src/routes/(protected)/admin/license-keys/+page.server.ts b/src/routes/(protected)/admin/license-keys/+page.server.ts index b4fc3f1..a62bc4f 100644 --- a/src/routes/(protected)/admin/license-keys/+page.server.ts +++ b/src/routes/(protected)/admin/license-keys/+page.server.ts @@ -1,6 +1,7 @@ import type { PageServerLoad } from './$types'; import { prisma } from '$lib/db'; import { sendMail } from '$lib/server/resend'; +import { isStaff } from '$lib/server/roles'; import { error } from '@sveltejs/kit'; function generateLicenseKey(): string { @@ -18,7 +19,7 @@ export const load: PageServerLoad = async () => { export const actions = { createKey: async ({ request, locals }) => { - if (!locals.user) throw error(401, 'Unauthorized'); + if (!isStaff(locals)) throw error(403, 'Not authorized'); const formData = await request.formData(); const email = formData.get('email')?.toString()?.trim().toLowerCase(); @@ -60,7 +61,7 @@ export const actions = { return { success: true, action: 'createKey' }; }, replace: async ({ request, locals }) => { - if (!locals.user) throw error(401, 'Unauthorized'); + if (!isStaff(locals)) throw error(403, 'Not authorized'); const formData = await request.formData(); const keyId = formData.get('id')?.toString(); diff --git a/src/routes/(protected)/admin/license-keys/page.server.spec.ts b/src/routes/(protected)/admin/license-keys/page.server.spec.ts index 61ca3ef..e77e1bf 100644 --- a/src/routes/(protected)/admin/license-keys/page.server.spec.ts +++ b/src/routes/(protected)/admin/license-keys/page.server.spec.ts @@ -48,11 +48,11 @@ describe('admin license-keys createKey action', () => { const req = createFormRequest({ email: 'jane@example.com' }); const err = await actions.createKey({ request: req, locals } as any).catch((e) => e); - expect(isHttpError(err, 401)).toBe(true); + expect(isHttpError(err, 403)).toBe(true); }); it('returns error when email is missing', async () => { - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 3 }) as any); const req = createFormRequest({}); const result = await actions.createKey({ request: req, locals } as any); @@ -65,7 +65,7 @@ describe('admin license-keys createKey action', () => { mockLicenseKeyCreate.mockResolvedValue({}); sendMail.mockResolvedValue({ id: 'email-1' }); - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 3 }) as any); const req = createFormRequest({ email: 'nobody@example.com' }); const result = await actions.createKey({ request: req, locals } as any); @@ -86,7 +86,7 @@ describe('admin license-keys createKey action', () => { }); it('creates a license key and sends email with license_key variable', async () => { - const user = mockUser(); + const user = mockUser({ roleId: 3 }); const profile = { firstName: 'Jane' }; mockFindUnique.mockResolvedValue(user); @@ -117,7 +117,7 @@ describe('admin license-keys createKey action', () => { }); it('sends email with license_key even when profile is missing', async () => { - const user = mockUser(); + const user = mockUser({ roleId: 3 }); mockFindUnique.mockResolvedValue(user); mockProfileFindFirst.mockResolvedValue(null); @@ -136,7 +136,7 @@ describe('admin license-keys createKey action', () => { }); it('still returns success when email sending fails', async () => { - const user = mockUser(); + const user = mockUser({ roleId: 3 }); mockFindUnique.mockResolvedValue(user); mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' }); @@ -166,11 +166,11 @@ describe('admin license-keys replace action', () => { const req = createFormRequest({ id: 'k1' }); const err = await actions.replace({ request: req, locals } as any).catch((e) => e); - expect(isHttpError(err, 401)).toBe(true); + expect(isHttpError(err, 403)).toBe(true); }); it('returns error when id is missing', async () => { - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 3 }) as any); const req = createFormRequest({}); const result = await actions.replace({ request: req, locals } as any); @@ -180,7 +180,7 @@ describe('admin license-keys replace action', () => { it('returns error when the key does not exist', async () => { mockLicenseKeyFindUnique.mockResolvedValue(null); - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 3 }) as any); const req = createFormRequest({ id: 'missing' }); const result = await actions.replace({ request: req, locals } as any); @@ -197,7 +197,7 @@ describe('admin license-keys replace action', () => { mockLicenseKeyDelete.mockResolvedValue({}); mockLicenseKeyCreate.mockResolvedValue({}); - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 3 }) as any); const req = createFormRequest({ id: 'k1' }); const result = await actions.replace({ request: req, locals } as any); diff --git a/src/routes/(protected)/admin/proclamation-categories/+page.server.ts b/src/routes/(protected)/admin/proclamation-categories/+page.server.ts index 365b801..75bf99e 100644 --- a/src/routes/(protected)/admin/proclamation-categories/+page.server.ts +++ b/src/routes/(protected)/admin/proclamation-categories/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad } from './$types'; import { prisma } from '$lib/db'; +import { isAdmin, isStaff } from '$lib/server/roles'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => { }; export const actions = { - upsert: async ({ request }) => { + upsert: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, action: 'upsert', error: 'Not authorized' }; + } + const formData = await request.formData(); const form = await superValidate(formData, zod4(CategorySchema)); if (!form.valid) { @@ -58,7 +63,11 @@ export const actions = { return { success: true, action: 'upsert' }; }, - delete: async ({ request }) => { + delete: async ({ request, locals }) => { + if (!isAdmin(locals)) { + return { success: false, error: 'Not authorized' }; + } + const formData = await request.formData(); const rawId = formData.get('id')?.toString(); if (!rawId) return { success: false, error: 'Missing id' }; @@ -77,7 +86,11 @@ export const actions = { return { success: true, action: 'delete' }; }, - reorder: async ({ request }) => { + reorder: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, error: 'Not authorized' }; + } + const formData = await request.formData(); const raw = formData.get('ids')?.toString(); if (!raw) return { success: false, error: 'Missing ids' }; diff --git a/src/routes/(protected)/admin/proclamation-groups/+page.server.ts b/src/routes/(protected)/admin/proclamation-groups/+page.server.ts index a889ac3..39289c5 100644 --- a/src/routes/(protected)/admin/proclamation-groups/+page.server.ts +++ b/src/routes/(protected)/admin/proclamation-groups/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad } from './$types'; import { prisma } from '$lib/db'; +import { isAdmin, isStaff } from '$lib/server/roles'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => { }; export const actions = { - upsert: async ({ request }) => { + upsert: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, action: 'upsert', error: 'Not authorized' }; + } + const formData = await request.formData(); const form = await superValidate(formData, zod4(GroupSchema)); if (!form.valid) { @@ -58,7 +63,11 @@ export const actions = { return { success: true, action: 'upsert' }; }, - delete: async ({ request }) => { + delete: async ({ request, locals }) => { + if (!isAdmin(locals)) { + return { success: false, error: 'Not authorized' }; + } + const formData = await request.formData(); const rawId = formData.get('id')?.toString(); if (!rawId) return { success: false, error: 'Missing id' }; @@ -77,7 +86,11 @@ export const actions = { return { success: true, action: 'delete' }; }, - reorder: async ({ request }) => { + reorder: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, error: 'Not authorized' }; + } + const formData = await request.formData(); const raw = formData.get('ids')?.toString(); if (!raw) return { success: false, error: 'Missing ids' }; diff --git a/src/routes/(protected)/admin/proclamations/+page.server.ts b/src/routes/(protected)/admin/proclamations/+page.server.ts index fb82252..3693bc3 100644 --- a/src/routes/(protected)/admin/proclamations/+page.server.ts +++ b/src/routes/(protected)/admin/proclamations/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad } from './$types'; import { prisma } from '$lib/db'; +import { isSuper } from '$lib/server/roles'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -13,18 +14,6 @@ const ProclamationSchema = z.object({ content: z.string().optional().default('') }); -async function isSuper(locals: App.Locals): Promise { - if (!locals.user?.id) return false; - - // The Better Auth session user does not include `roleId`, so read the - // current role from the database. - const user = await prisma.user.findUnique({ - where: { id: locals.user.id }, - select: { roleId: true } - }); - return (user?.roleId ?? 4) <= 1; -} - export const load: PageServerLoad = async ({ locals }) => { const [proclamations, frequencies, categories, groups] = await Promise.all([ prisma.proclamation.findMany({ @@ -48,7 +37,7 @@ export const load: PageServerLoad = async ({ locals }) => { const records = proclamations as unknown as Record[]; // Strip content from non-Super users - if (!(await isSuper(locals))) { + if (!isSuper(locals)) { for (const record of records) { delete record.content; } @@ -63,7 +52,7 @@ export const actions = { return { success: false, action: 'upsert', error: 'Unauthorized' }; } - if (!(await isSuper(locals))) { + if (!isSuper(locals)) { return { success: false, action: 'upsert', @@ -106,7 +95,7 @@ export const actions = { return { success: true, action: 'upsert' }; }, delete: async ({ request, locals }) => { - if (!(await isSuper(locals))) { + if (!isSuper(locals)) { return { success: false, action: 'delete', diff --git a/src/routes/(protected)/admin/proclamations/page.server.spec.ts b/src/routes/(protected)/admin/proclamations/page.server.spec.ts index c3f45fc..07efe57 100644 --- a/src/routes/(protected)/admin/proclamations/page.server.spec.ts +++ b/src/routes/(protected)/admin/proclamations/page.server.spec.ts @@ -18,9 +18,6 @@ vi.mock('$lib/db', () => ({ }, proclamationGroup: { findMany: vi.fn() - }, - user: { - findUnique: vi.fn() } } })); @@ -36,7 +33,6 @@ const mockProclamationDelete = prisma.proclamation.delete as ReturnType; const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType; const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType; -const mockUserFindUnique = prisma.user.findUnique as ReturnType; const frequencies = [{ id: 1, name: 'UT', value: 396 }]; const categories = [{ id: 1, name: 'Healing' }]; @@ -56,8 +52,6 @@ function validRequest(overrides: Record = {}) { describe('proclamations admin page server', () => { beforeEach(() => { vi.clearAllMocks(); - // Default the caller to SUPER (1); override per test as needed. - mockUserFindUnique.mockResolvedValue({ roleId: 1 }); }); describe('load', () => { @@ -74,7 +68,6 @@ describe('proclamations admin page server', () => { }); it('strips content for non-Super users', async () => { - mockUserFindUnique.mockResolvedValue({ roleId: 4 }); const locals = createLocals(mockUser({ roleId: 4 }) as any); const result = await load({ locals } as any); @@ -116,7 +109,6 @@ describe('proclamations admin page server', () => { }); it('rejects non-Super users', async () => { - mockUserFindUnique.mockResolvedValue({ roleId: 4 }); const locals = createLocals(mockUser({ roleId: 4 }) as any); const req = createFormRequest({ name: 'test' }); @@ -157,21 +149,6 @@ describe('proclamations admin page server', () => { }); }); - it('allows a Super user whose session user has no roleId', async () => { - mockProclamationCreate.mockResolvedValue({ id: 'p1' }); - - // The real Better Auth session user does not carry `roleId`; the - // action must look the role up in the database instead. - const locals = createLocals(mockUser({ roleId: null }) as any); - const result = await actions.upsert({ request: validRequest(), locals } as any); - - expect(result).toEqual({ success: true, action: 'upsert' }); - expect(mockUserFindUnique).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - select: { roleId: true } - }); - }); - it('updates an existing proclamation for a Super user', async () => { mockProclamationFindUnique.mockResolvedValue(mockProclamation()); mockProclamationUpdate.mockResolvedValue({}); @@ -223,7 +200,6 @@ describe('proclamations admin page server', () => { describe('delete action', () => { it('rejects non-Super users', async () => { - mockUserFindUnique.mockResolvedValue({ roleId: 4 }); const locals = createLocals(mockUser({ roleId: 4 }) as any); const req = createFormRequest({ id: '1' }); diff --git a/src/routes/(protected)/admin/profiles/+page.server.ts b/src/routes/(protected)/admin/profiles/+page.server.ts index 8069e0c..2e9ea6f 100644 --- a/src/routes/(protected)/admin/profiles/+page.server.ts +++ b/src/routes/(protected)/admin/profiles/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad } from './$types'; import { prisma } from '$lib/db'; +import { isAdmin, isStaff } from '$lib/server/roles'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -45,7 +46,11 @@ export const load: PageServerLoad = async () => { }; export const actions = { - upsert: async ({ request }) => { + upsert: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, action: 'upsert', error: 'Not authorized' }; + } + const formData = await request.formData(); const form = await superValidate(formData, zod4(ProfileSchema)); if (!form.valid) { @@ -112,14 +117,22 @@ export const actions = { } return { success: true, action: 'upsert' }; }, - delete: async ({ request }) => { + delete: async ({ request, locals }) => { + if (!isAdmin(locals)) { + return { success: false, action: 'delete', error: 'Not authorized' }; + } + const formData = await request.formData(); const rawId = formData.get('id')?.toString(); if (!rawId) return { success: false, error: 'Missing id' }; await prisma.profile.delete({ where: { id: rawId } }); return { success: true, action: 'delete' }; }, - upsertAddress: async ({ request }) => { + upsertAddress: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, action: 'upsertAddress', addressError: 'Not authorized' }; + } + const formData = await request.formData(); const form = await superValidate(formData, zod4(AddressSchema)); if (!form.valid) { diff --git a/src/routes/(protected)/admin/profiles/page.server.spec.ts b/src/routes/(protected)/admin/profiles/page.server.spec.ts index 845c4da..6eb57ec 100644 --- a/src/routes/(protected)/admin/profiles/page.server.spec.ts +++ b/src/routes/(protected)/admin/profiles/page.server.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createLocals, createFormRequest } from '$lib/test-utils'; +import { createLocals, createFormRequest, mockUser } from '$lib/test-utils'; vi.mock('$lib/db', () => ({ prisma: { @@ -41,6 +41,9 @@ function makeProfileRequest(overrides: Record = {}) { return createFormRequest({ ...validFields, ...overrides }); } +const staff = () => createLocals(mockUser({ roleId: 3 }) as any); +const admin = () => createLocals(mockUser({ roleId: 2 }) as any); + describe('profiles admin page server', () => { beforeEach(() => { vi.clearAllMocks(); @@ -73,7 +76,10 @@ describe('profiles admin page server', () => { mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email }); mockProfileCreate.mockResolvedValue({ id: 'p-new' }); - const result = await actions.upsert({ request: makeProfileRequest() } as any); + const result = await actions.upsert({ + request: makeProfileRequest(), + locals: staff() + } as any); expect(result).toEqual({ success: true, action: 'upsert' }); expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } }); @@ -86,7 +92,10 @@ describe('profiles admin page server', () => { mockUserFindUnique.mockResolvedValue(null); mockProfileCreate.mockResolvedValue({ id: 'p-new' }); - const result = await actions.upsert({ request: makeProfileRequest() } as any); + const result = await actions.upsert({ + request: makeProfileRequest(), + locals: staff() + } as any); expect(result).toEqual({ success: true, action: 'upsert' }); expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields }); @@ -96,7 +105,10 @@ describe('profiles admin page server', () => { mockUserFindUnique.mockResolvedValue(null); mockProfileCreate.mockRejectedValue({ code: 'P2002' }); - const result = await actions.upsert({ request: makeProfileRequest() } as any); + const result = await actions.upsert({ + request: makeProfileRequest(), + locals: staff() + } as any); expect(result).toEqual({ success: false, @@ -119,7 +131,7 @@ describe('profiles admin page server', () => { mockProfileUpdate.mockResolvedValue({}); const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' }); - const result = await actions.upsert({ request } as any); + const result = await actions.upsert({ request, locals: staff() } as any); expect(result).toEqual({ success: true, action: 'upsert' }); expect(mockUserUpdate).toHaveBeenCalledWith({ @@ -142,7 +154,7 @@ describe('profiles admin page server', () => { mockProfileUpdate.mockResolvedValue({}); const request = makeProfileRequest({ id: 'p1' }); - const result = await actions.upsert({ request } as any); + const result = await actions.upsert({ request, locals: staff() } as any); expect(result).toEqual({ success: true, action: 'upsert' }); expect(mockUserFindFirst).not.toHaveBeenCalled(); @@ -159,7 +171,7 @@ describe('profiles admin page server', () => { mockUserFindFirst.mockResolvedValue({ id: 'user-2' }); const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' }); - const result = await actions.upsert({ request } as any); + const result = await actions.upsert({ request, locals: staff() } as any); expect(result).toEqual({ success: false, @@ -181,7 +193,7 @@ describe('profiles admin page server', () => { mockProfileUpdate.mockResolvedValue({}); const request = makeProfileRequest({ id: 'p1' }); - const result = await actions.upsert({ request } as any); + const result = await actions.upsert({ request, locals: staff() } as any); expect(result).toEqual({ success: true, action: 'upsert' }); expect(mockProfileUpdate).toHaveBeenCalledWith({ @@ -194,7 +206,7 @@ describe('profiles admin page server', () => { describe('upsert - validation', () => { it('returns field errors for invalid input', async () => { const request = makeProfileRequest({ email: 'not-an-email' }); - const result = await actions.upsert({ request } as any); + const result = await actions.upsert({ request, locals: staff() } as any); expect(result.success).toBe(false); expect(result.action).toBe('upsert'); @@ -205,7 +217,7 @@ describe('profiles admin page server', () => { mockProfileFindUnique.mockResolvedValue(null); const request = makeProfileRequest({ id: 'missing' }); - const result = await actions.upsert({ request } as any); + const result = await actions.upsert({ request, locals: staff() } as any); expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' }); }); @@ -215,7 +227,7 @@ describe('profiles admin page server', () => { it('deletes the profile by id', async () => { mockProfileDelete.mockResolvedValue({}); - const locals = createLocals(); + const locals = admin(); const req = createFormRequest({ id: 'p1' }); const result = await actions.delete({ request: req, locals } as any); @@ -224,7 +236,7 @@ describe('profiles admin page server', () => { }); it('returns an error when id is missing', async () => { - const locals = createLocals(); + const locals = admin(); const result = await actions.delete({ request: createFormRequest({}), locals } as any); expect(result).toEqual({ success: false, error: 'Missing id' }); diff --git a/src/routes/(protected)/admin/role-guards.spec.ts b/src/routes/(protected)/admin/role-guards.spec.ts new file mode 100644 index 0000000..52fbc82 --- /dev/null +++ b/src/routes/(protected)/admin/role-guards.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createLocals, createFormRequest, mockUser } from '$lib/test-utils'; + +vi.mock('$lib/db', () => ({ + prisma: { + userRole: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, + solfeggioFrequency: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, + proclamationCategory: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, + proclamationGroup: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() } + } +})); + +import { prisma } from '$lib/db'; +import { actions as userRoles } from './user-roles/+page.server'; +import { actions as frequencies } from './solfeggio-frequencies/+page.server'; +import { actions as categories } from './proclamation-categories/+page.server'; +import { actions as groups } from './proclamation-groups/+page.server'; + +const staff = () => createLocals(mockUser({ roleId: 3 }) as any); +const admin = () => createLocals(mockUser({ roleId: 2 }) as any); +const user = () => createLocals(mockUser({ roleId: 4 }) as any); +const anonymous = () => createLocals(); + +type CrudActions = { + upsert: (event: unknown) => Promise; + delete: (event: unknown) => Promise; +}; + +const pages: Array<[string, CrudActions]> = [ + ['user-roles', userRoles as unknown as CrudActions], + ['solfeggio-frequencies', frequencies as unknown as CrudActions], + ['proclamation-categories', categories as unknown as CrudActions], + ['proclamation-groups', groups as unknown as CrudActions] +]; + +describe('admin CRUD action role guards', () => { + for (const [name, actions] of pages) { + it(`${name}: upsert rejects an anonymous request`, async () => { + const result = await actions.upsert({ + request: createFormRequest({ name: 'x' }), + locals: anonymous() + }); + expect(result).toEqual({ success: false, action: 'upsert', error: 'Not authorized' }); + }); + + it(`${name}: upsert rejects a regular user`, async () => { + const result = await actions.upsert({ + request: createFormRequest({ name: 'x' }), + locals: user() + }); + expect(result).toEqual({ success: false, action: 'upsert', error: 'Not authorized' }); + }); + + it(`${name}: delete rejects a staff (non-admin) user`, async () => { + const result = await actions.delete({ + request: createFormRequest({ id: '1' }), + locals: staff() + }); + expect(result).toEqual({ success: false, error: 'Not authorized' }); + }); + } +}); + +describe('admin CRUD action role guards: authorized users still work', () => { + it('staff can create a user role', async () => { + (prisma.userRole.create as ReturnType).mockResolvedValue({ id: 5 }); + const result = await userRoles.upsert({ + request: createFormRequest({ name: 'New Role' }), + locals: staff() + } as any); + expect(result).toEqual({ success: true, action: 'upsert' }); + expect(prisma.userRole.create).toHaveBeenCalledWith({ data: { name: 'New Role' } }); + }); + + it('admin can delete a user role', async () => { + (prisma.userRole.delete as ReturnType).mockResolvedValue({}); + const result = await userRoles.delete({ + request: createFormRequest({ id: '5' }), + locals: admin() + } as any); + expect(result).toEqual({ success: true, action: 'delete' }); + expect(prisma.userRole.delete).toHaveBeenCalledWith({ where: { id: 5 } }); + }); +}); diff --git a/src/routes/(protected)/admin/solfeggio-frequencies/+page.server.ts b/src/routes/(protected)/admin/solfeggio-frequencies/+page.server.ts index 176dbb4..57d6c62 100644 --- a/src/routes/(protected)/admin/solfeggio-frequencies/+page.server.ts +++ b/src/routes/(protected)/admin/solfeggio-frequencies/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad } from './$types'; import { prisma } from '$lib/db'; +import { isAdmin, isStaff } from '$lib/server/roles'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -16,7 +17,11 @@ export const load: PageServerLoad = async () => { }; export const actions = { - upsert: async ({ request }) => { + upsert: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, action: 'upsert', error: 'Not authorized' }; + } + const formData = await request.formData(); const form = await superValidate(formData, zod4(SolfeggioSchema)); if (!form.valid) { @@ -48,7 +53,11 @@ export const actions = { return { success: true, action: 'upsert' }; }, - delete: async ({ request }) => { + delete: async ({ request, locals }) => { + if (!isAdmin(locals)) { + return { success: false, error: 'Not authorized' }; + } + const formData = await request.formData(); const rawId = formData.get('id')?.toString(); if (!rawId) return { success: false, error: 'Missing id' }; diff --git a/src/routes/(protected)/admin/user-roles/+page.server.ts b/src/routes/(protected)/admin/user-roles/+page.server.ts index ac46bfc..2519d02 100644 --- a/src/routes/(protected)/admin/user-roles/+page.server.ts +++ b/src/routes/(protected)/admin/user-roles/+page.server.ts @@ -1,5 +1,6 @@ import type { PageServerLoad } from './$types'; import { prisma } from '$lib/db'; +import { isAdmin, isStaff } from '$lib/server/roles'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -15,7 +16,11 @@ export const load: PageServerLoad = async () => { }; export const actions = { - upsert: async ({ request }) => { + upsert: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, action: 'upsert', error: 'Not authorized' }; + } + const formData = await request.formData(); const form = await superValidate(formData, zod4(UserRoleSchema)); if (!form.valid) { @@ -43,7 +48,11 @@ export const actions = { return { success: true, action: 'upsert' }; }, - delete: async ({ request }) => { + delete: async ({ request, locals }) => { + if (!isAdmin(locals)) { + return { success: false, error: 'Not authorized' }; + } + const formData = await request.formData(); const rawId = formData.get('id')?.toString(); if (!rawId) return { success: false, error: 'Missing id' }; diff --git a/src/routes/(protected)/admin/users/+page.server.ts b/src/routes/(protected)/admin/users/+page.server.ts index 490aa03..cce136f 100644 --- a/src/routes/(protected)/admin/users/+page.server.ts +++ b/src/routes/(protected)/admin/users/+page.server.ts @@ -1,6 +1,7 @@ import type { PageServerLoad, Actions } from './$types'; import { prisma } from '$lib/db'; import { sendRegistrationInvite } from '$lib/server/registration-invite'; +import { isAdmin, isStaff, roleId as viewerRoleId } from '$lib/server/roles'; import { superValidate } from 'sveltekit-superforms/server'; import { zod4 } from 'sveltekit-superforms/adapters'; import { z } from 'zod'; @@ -12,16 +13,8 @@ const UserSchema = z.object({ roleId: z.coerce.number().int().min(1) }); -async function myRoleId(locals: App.Locals): Promise { - const user = await prisma.user.findUnique({ - where: { id: locals.user?.id }, - select: { roleId: true } - }); - return user?.roleId ?? 4; -} - export const load: PageServerLoad = async ({ locals }) => { - const roleId = await myRoleId(locals); + const myRole = viewerRoleId(locals); const [users, roles] = await Promise.all([ prisma.user.findMany({ @@ -31,7 +24,7 @@ export const load: PageServerLoad = async ({ locals }) => { prisma.userRole.findMany({ orderBy: { id: 'asc' } }) ]); - const roleOptions = roles.filter((role) => role.id >= roleId); + const roleOptions = roles.filter((role) => role.id >= myRole); return { records: users as unknown as Record[], @@ -41,6 +34,10 @@ export const load: PageServerLoad = async ({ locals }) => { export const actions: Actions = { upsert: async ({ request, locals }) => { + if (!isStaff(locals)) { + return { success: false, action: 'upsert', error: 'Not authorized' }; + } + const formData = await request.formData(); const form = await superValidate(formData, zod4(UserSchema)); if (!form.valid) { @@ -57,7 +54,7 @@ export const actions: Actions = { roleId: form.data.roleId }; - const myRole = await myRoleId(locals); + const myRole = viewerRoleId(locals); if (myRole > 1 && data.roleId < myRole) { return { success: false, @@ -104,7 +101,11 @@ export const actions: Actions = { return { success: true, action: 'upsert' }; }, - delete: async ({ request }) => { + delete: async ({ request, locals }) => { + if (!isAdmin(locals)) { + return { success: false, action: 'delete', error: 'Not authorized' }; + } + const formData = await request.formData(); const rawId = formData.get('id')?.toString(); if (!rawId) return { success: false, error: 'Missing id' }; diff --git a/src/routes/(protected)/admin/users/page.server.spec.ts b/src/routes/(protected)/admin/users/page.server.spec.ts index 72c40e7..4649426 100644 --- a/src/routes/(protected)/admin/users/page.server.spec.ts +++ b/src/routes/(protected)/admin/users/page.server.spec.ts @@ -25,7 +25,6 @@ vi.mock('$lib/server/registration-invite', () => ({ import { prisma } from '$lib/db'; import { actions } from './+page.server'; -const userFindUnique = prisma.user.findUnique as ReturnType; const userCreate = prisma.user.create as ReturnType; const userUpdate = prisma.user.update as ReturnType; @@ -44,14 +43,13 @@ function upsertRequest(overrides: Record = {}) { describe('users admin page server upsert', () => { beforeEach(() => { vi.clearAllMocks(); - userFindUnique.mockResolvedValue({ roleId: 1 }); userCreate.mockResolvedValue({ id: 'new-user' }); userUpdate.mockResolvedValue({ id: 'user-1' }); sendRegistrationInvite.mockResolvedValue(undefined); }); it('marks a created user verified and emails a set-password link', async () => { - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 1 }) as any); const result = await invokeUpsert(upsertRequest(), locals); expect(result).toEqual({ success: true, action: 'upsert' }); @@ -66,7 +64,7 @@ describe('users admin page server upsert', () => { }); it('does not email a registration link when updating an existing user', async () => { - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 1 }) as any); const result = await invokeUpsert(upsertRequest({ id: 'user-9' }), locals); expect(result).toEqual({ success: true, action: 'upsert' }); @@ -78,7 +76,7 @@ describe('users admin page server upsert', () => { it('still succeeds when the registration email fails to send', async () => { sendRegistrationInvite.mockRejectedValue(new Error('resend down')); - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 1 }) as any); const result = await invokeUpsert(upsertRequest(), locals); expect(result).toEqual({ success: true, action: 'upsert' }); @@ -86,9 +84,7 @@ describe('users admin page server upsert', () => { }); it('blocks assigning a role with more privileges than the actor', async () => { - userFindUnique.mockResolvedValue({ roleId: 2 }); - - const locals = createLocals(mockUser() as any); + const locals = createLocals(mockUser({ roleId: 2 }) as any); const result = await invokeUpsert(upsertRequest({ roleId: '1' }), locals); expect(result).toEqual({