From 089041c7f1b71a2f75e200d87d295a2ad3cd1910 Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Fri, 3 Apr 2026 15:11:51 +0800 Subject: [PATCH] implement permissions --- .../constants/__tests__/permissions.spec.ts | 47 +++++++++++++++++++ frontend/src/constants/permissions.ts | 25 ++++++++++ .../src/stores/__tests__/permission.spec.ts | 9 +++- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 frontend/src/constants/__tests__/permissions.spec.ts create mode 100644 frontend/src/constants/permissions.ts diff --git a/frontend/src/constants/__tests__/permissions.spec.ts b/frontend/src/constants/__tests__/permissions.spec.ts new file mode 100644 index 0000000..8b9f016 --- /dev/null +++ b/frontend/src/constants/__tests__/permissions.spec.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { ADMIN_ONLY_PATH_PREFIXES, isAdminOnlyPath } from '../permissions' + +describe('ADMIN_ONLY_PATH_PREFIXES', () => { + it('contains all 7 admin-only paths', () => { + expect(ADMIN_ONLY_PATH_PREFIXES).toHaveLength(7) + expect(ADMIN_ONLY_PATH_PREFIXES).toContain('/users') + expect(ADMIN_ONLY_PATH_PREFIXES).toContain('/roles') + expect(ADMIN_ONLY_PATH_PREFIXES).toContain('/route-groups') + expect(ADMIN_ONLY_PATH_PREFIXES).toContain('/mq-status') + expect(ADMIN_ONLY_PATH_PREFIXES).toContain('/failed-messages') + expect(ADMIN_ONLY_PATH_PREFIXES).toContain('/logs/requests') + expect(ADMIN_ONLY_PATH_PREFIXES).toContain('/logs/operations') + }) +}) + +describe('isAdminOnlyPath', () => { + it('matches exact admin-only paths', () => { + for (const path of ADMIN_ONLY_PATH_PREFIXES) { + expect(isAdminOnlyPath(path)).toBe(true) + } + }) + + it('matches sub-paths via prefix matching', () => { + expect(isAdminOnlyPath('/users/123')).toBe(true) + expect(isAdminOnlyPath('/users/123/edit')).toBe(true) + expect(isAdminOnlyPath('/roles/5')).toBe(true) + expect(isAdminOnlyPath('/logs/requests/detail')).toBe(true) + expect(isAdminOnlyPath('/logs/operations/42')).toBe(true) + expect(isAdminOnlyPath('/failed-messages/retry')).toBe(true) + }) + + it('does not match non-admin paths', () => { + expect(isAdminOnlyPath('/')).toBe(false) + expect(isAdminOnlyPath('/login')).toBe(false) + expect(isAdminOnlyPath('/products')).toBe(false) + expect(isAdminOnlyPath('/orders')).toBe(false) + expect(isAdminOnlyPath('/dashboard')).toBe(false) + }) + + it('does not match prefix-similar but distinct paths', () => { + expect(isAdminOnlyPath('/users-export')).toBe(false) + expect(isAdminOnlyPath('/roles-backup')).toBe(false) + expect(isAdminOnlyPath('/route-groups-old')).toBe(false) + expect(isAdminOnlyPath('/mq-status-history')).toBe(false) + }) +}) diff --git a/frontend/src/constants/permissions.ts b/frontend/src/constants/permissions.ts new file mode 100644 index 0000000..f710e9c --- /dev/null +++ b/frontend/src/constants/permissions.ts @@ -0,0 +1,25 @@ +/** + * 仅管理员可访问的路径前缀列表。 + * 路由守卫使用前缀匹配:以列表中任意项开头的路径都会被拦截。 + * + * 同步要求:此列表必须与 MainLayout.vue 中 adminOnly: true 的菜单项保持一致。 + */ +export const ADMIN_ONLY_PATH_PREFIXES: readonly string[] = [ + '/users', + '/roles', + '/route-groups', + '/mq-status', + '/failed-messages', + '/logs/requests', + '/logs/operations', +] as const + +/** + * 判断给定路径是否属于 admin-only 路径(前缀匹配)。 + * 例如 '/users/123' 会匹配 '/users' 前缀。 + */ +export function isAdminOnlyPath(path: string): boolean { + return ADMIN_ONLY_PATH_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(prefix + '/'), + ) +} diff --git a/frontend/src/stores/__tests__/permission.spec.ts b/frontend/src/stores/__tests__/permission.spec.ts index 6ac287f..89b1cdf 100644 --- a/frontend/src/stores/__tests__/permission.spec.ts +++ b/frontend/src/stores/__tests__/permission.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useUserStore } from '../user' +import { fakeJwtWithRole } from '@/test/helpers' describe('Permission: user store 权限判断', () => { beforeEach(() => { @@ -11,6 +12,7 @@ describe('Permission: user store 权限判断', () => { describe('isAdmin', () => { it('administrator 角色返回 true', () => { const store = useUserStore() + store.setToken(fakeJwtWithRole('administrator'), 'rt') store.setUser({ id: 1, username: 'admin', email: 'a@a.com', role: 'administrator', status: 1 }) expect(store.isAdmin).toBe(true) @@ -40,6 +42,7 @@ describe('Permission: user store 权限判断', () => { describe('hasPermission', () => { it('admin 对 write 操作返回 true', () => { const store = useUserStore() + store.setToken(fakeJwtWithRole('administrator'), 'rt') store.setUser({ id: 1, username: 'admin', email: 'a@a.com', role: 'administrator', status: 1 }) expect(store.hasPermission('write')).toBe(true) @@ -47,6 +50,7 @@ describe('Permission: user store 权限判断', () => { it('admin 对 read 操作返回 true', () => { const store = useUserStore() + store.setToken(fakeJwtWithRole('administrator'), 'rt') store.setUser({ id: 1, username: 'admin', email: 'a@a.com', role: 'administrator', status: 1 }) expect(store.hasPermission('read')).toBe(true) @@ -80,17 +84,20 @@ describe('Permission: user store 权限判断', () => { }) describe('角色切换响应', () => { - it('用户角色从 accessor 切换为 administrator 后 isAdmin 变为 true', () => { + it('用户 token 从 accessor 切换为 administrator 后 isAdmin 变为 true', () => { const store = useUserStore() + store.setToken(fakeJwtWithRole('accessor'), 'rt') store.setUser({ id: 1, username: 'u', email: 'u@u.com', role: 'accessor', status: 1 }) expect(store.isAdmin).toBe(false) + store.setToken(fakeJwtWithRole('administrator'), 'rt') store.setUser({ id: 1, username: 'u', email: 'u@u.com', role: 'administrator', status: 1 }) expect(store.isAdmin).toBe(true) }) it('logout 后 isAdmin 恢复为 false', () => { const store = useUserStore() + store.setToken(fakeJwtWithRole('administrator'), 'rt') store.setUser({ id: 1, username: 'a', email: 'a@a.com', role: 'administrator', status: 1 }) expect(store.isAdmin).toBe(true)