Enforce roleId checks on all admin page actions
This commit is contained in:
@@ -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<number> {
|
||||
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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<string, unknown>[];
|
||||
|
||||
// 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',
|
||||
|
||||
@@ -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<typeof v
|
||||
const mockFreqFindMany = prisma.solfeggioFrequency.findMany as ReturnType<typeof vi.fn>;
|
||||
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
|
||||
const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
|
||||
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
const frequencies = [{ id: 1, name: 'UT', value: 396 }];
|
||||
const categories = [{ id: 1, name: 'Healing' }];
|
||||
@@ -56,8 +52,6 @@ function validRequest(overrides: Record<string, string> = {}) {
|
||||
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' });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, string> = {}) {
|
||||
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' });
|
||||
|
||||
@@ -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<unknown>;
|
||||
delete: (event: unknown) => Promise<unknown>;
|
||||
};
|
||||
|
||||
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<typeof vi.fn>).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<typeof vi.fn>).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 } });
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<number> {
|
||||
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<string, unknown>[],
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
const userCreate = prisma.user.create as ReturnType<typeof vi.fn>;
|
||||
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -44,14 +43,13 @@ function upsertRequest(overrides: Record<string, string> = {}) {
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user