Fix non-serializable accessFilter in admin load, add error boundary and tests
This commit is contained in:
@@ -3,4 +3,5 @@ export { ROLES, hasRole, canAccessAdmin } from './roles';
|
||||
export { fieldNameToLabel, resolveFieldValue } from './introspect';
|
||||
export { getDelegate, parseId } from './prisma-delegate';
|
||||
export { extractFormData } from './form-data';
|
||||
export { getModelConfig, getAllModelConfigs } from './registry';
|
||||
export { getModelConfig, getAllModelConfigs, getSerializableModelConfigs } from './registry';
|
||||
export type { SerializableModelConfig } from './registry';
|
||||
|
||||
@@ -267,3 +267,24 @@ export function getModelConfig(slug: string): AdminModelConfig | undefined {
|
||||
export function getAllModelConfigs(): AdminModelConfig[] {
|
||||
return models;
|
||||
}
|
||||
|
||||
export type SerializableModelConfig = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
listFields: string[];
|
||||
accessFilterResult: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
export function getSerializableModelConfigs(
|
||||
viewerRoleId: number,
|
||||
excludeSlugs?: string[]
|
||||
): SerializableModelConfig[] {
|
||||
return models
|
||||
.filter((m) => !excludeSlugs?.includes(m.slug))
|
||||
.map((m) => ({
|
||||
slug: m.slug,
|
||||
displayName: m.displayName,
|
||||
listFields: m.listFields,
|
||||
accessFilterResult: m.accessFilter?.(viewerRoleId)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import Window from '$lib/components/containers/Window.svelte';
|
||||
</script>
|
||||
|
||||
<Window>
|
||||
<div class="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Admin Error ({$page.status})
|
||||
</h1>
|
||||
<p class="max-w-md text-sm text-gray-600 dark:text-gray-400">
|
||||
{#if $page.error?.message}
|
||||
{$page.error.message}
|
||||
{:else}
|
||||
An unexpected error occurred while loading this admin page.
|
||||
{/if}
|
||||
</p>
|
||||
<a
|
||||
href="/admin"
|
||||
class="mt-2 border border-gray-200 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
Back to Admin
|
||||
</a>
|
||||
</div>
|
||||
</Window>
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
|
||||
import { getSerializableModelConfigs } from '$lib/svelte-admin/registry';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const models = getAllModelConfigs().filter((m) => m.slug !== 'license-keys');
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
|
||||
const models = getSerializableModelConfigs(viewerRoleId, ['license-keys']);
|
||||
return { models };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { getSerializableModelConfigs } from '$lib/svelte-admin/registry';
|
||||
import { createLocals, mockUser } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({}));
|
||||
|
||||
import { load } from './+page.server';
|
||||
|
||||
function localsWithRole(roleId: number) {
|
||||
return createLocals(mockUser({ roleId }) as any);
|
||||
}
|
||||
|
||||
describe('getSerializableModelConfigs', () => {
|
||||
it('returns only serializable fields (no functions)', () => {
|
||||
const models = getSerializableModelConfigs(1);
|
||||
const serialized = JSON.stringify(models);
|
||||
expect(serialized).toBeDefined();
|
||||
|
||||
for (const model of models) {
|
||||
expect(typeof model.slug).toBe('string');
|
||||
expect(typeof model.displayName).toBe('string');
|
||||
expect(Array.isArray(model.listFields)).toBe(true);
|
||||
expect(
|
||||
typeof model.accessFilterResult === 'object' || model.accessFilterResult === undefined
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('evaluates accessFilter for SUPER (roleId 1) — returns undefined for users', () => {
|
||||
const models = getSerializableModelConfigs(1);
|
||||
const users = models.find((m) => m.slug === 'users')!;
|
||||
expect(users.accessFilterResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('evaluates accessFilter for ADMIN (roleId 2) — restricts users to STAFF+', () => {
|
||||
const models = getSerializableModelConfigs(2);
|
||||
const users = models.find((m) => m.slug === 'users')!;
|
||||
expect(users.accessFilterResult).toEqual({ roleId: { gte: 3 } });
|
||||
});
|
||||
|
||||
it('evaluates accessFilter for STAFF (roleId 3) — restricts users to USER-only', () => {
|
||||
const models = getSerializableModelConfigs(3);
|
||||
const users = models.find((m) => m.slug === 'users')!;
|
||||
expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } });
|
||||
});
|
||||
|
||||
it('models without accessFilter have undefined accessFilterResult', () => {
|
||||
const models = getSerializableModelConfigs(1);
|
||||
const roles = models.find((m) => m.slug === 'user-roles')!;
|
||||
expect(roles.accessFilterResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('excludes specified slugs', () => {
|
||||
const models = getSerializableModelConfigs(1, ['license-keys', 'users']);
|
||||
expect(models.find((m) => m.slug === 'license-keys')).toBeUndefined();
|
||||
expect(models.find((m) => m.slug === 'users')).toBeUndefined();
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin index page load', () => {
|
||||
it('returns models with evaluated accessFilter results', async () => {
|
||||
const result = (await load({ locals: localsWithRole(1) } as any)) as any;
|
||||
expect(result.models).toBeDefined();
|
||||
expect(Array.isArray(result.models)).toBe(true);
|
||||
|
||||
const users = result.models.find((m: any) => m.slug === 'users');
|
||||
expect(users).toBeDefined();
|
||||
expect(users.accessFilterResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns different accessFilter results per role', async () => {
|
||||
const superResult = (await load({ locals: localsWithRole(1) } as any)) as any;
|
||||
const adminResult = (await load({ locals: localsWithRole(2) } as any)) as any;
|
||||
const staffResult = (await load({ locals: localsWithRole(3) } as any)) as any;
|
||||
|
||||
const superUsers = superResult.models.find((m: any) => m.slug === 'users');
|
||||
const adminUsers = adminResult.models.find((m: any) => m.slug === 'users');
|
||||
const staffUsers = staffResult.models.find((m: any) => m.slug === 'users');
|
||||
|
||||
expect(superUsers.accessFilterResult).toBeUndefined();
|
||||
expect(adminUsers.accessFilterResult).toEqual({ roleId: { gte: 3 } });
|
||||
expect(staffUsers.accessFilterResult).toEqual({ roleId: { gt: 3 } });
|
||||
});
|
||||
|
||||
it('excludes license-keys model', async () => {
|
||||
const result = (await load({ locals: localsWithRole(1) } as any)) as any;
|
||||
const licenseKeys = result.models.find((m: any) => m.slug === 'license-keys');
|
||||
expect(licenseKeys).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns fully JSON-serializable data (no functions)', async () => {
|
||||
const result = (await load({ locals: localsWithRole(2) } as any)) as any;
|
||||
const serialized = JSON.stringify(result);
|
||||
const parsed = JSON.parse(serialized);
|
||||
expect(parsed.models).toBeDefined();
|
||||
expect(parsed.models.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('defaults to roleId 4 when user has no roleId', async () => {
|
||||
const result = (await load({
|
||||
locals: createLocals(mockUser({ roleId: null }) as any)
|
||||
} as any)) as any;
|
||||
const users = result.models.find((m: any) => m.slug === 'users');
|
||||
expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user