feat(admin-v2): add ProTable search, scenarios/quick_commands form, tests, remove quota_reset_interval
- Enable ProTable search on Accounts (username/email), Models (model_id/alias), Providers (display_name/name) with hideInSearch for non-searchable columns - Add scenarios (Select tags) and quick_commands (Form.List) to AgentTemplates create form, plus service type updates - Remove unused quota_reset_interval from ProviderKey model, key_pool SQL, handlers, and frontend types; add migration + bump schema to v11 - Add Vitest config, test setup, request interceptor tests (7 cases), authStore tests (8 cases) — all 15 passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
@@ -23,6 +25,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -31,8 +36,11 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.12.14",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
"vite": "^8.0.1",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
1049
admin-v2/pnpm-lock.yaml
generated
1049
admin-v2/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -69,30 +69,34 @@ export default function Accounts() {
|
||||
|
||||
const columns: ProColumns<AccountPublic>[] = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
||||
{ title: '显示名', dataIndex: 'display_name', width: 120 },
|
||||
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
||||
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '2FA',
|
||||
dataIndex: 'totp_enabled',
|
||||
width: 80,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => record.totp_enabled ? <Tag color="green">已启用</Tag> : <Tag>未启用</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'LLM 路由',
|
||||
dataIndex: 'llm_routing',
|
||||
width: 120,
|
||||
hideInSearch: true,
|
||||
valueType: 'select',
|
||||
valueEnum: {
|
||||
relay: { text: 'SaaS 中转', status: 'Success' },
|
||||
@@ -103,11 +107,13 @@ export default function Accounts() {
|
||||
title: '最后登录',
|
||||
dataIndex: 'last_login_at',
|
||||
width: 180,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
||||
@@ -141,7 +147,7 @@ export default function Accounts() {
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
search={{}}
|
||||
toolBarRender={() => []}
|
||||
pagination={{
|
||||
total: data?.total ?? 0,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
|
||||
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions, MinusCircleOutlined } from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import type { ProColumns } from '@ant-design/pro-components'
|
||||
import { ProTable } from '@ant-design/pro-components'
|
||||
@@ -176,6 +176,30 @@ export default function AgentTemplates() {
|
||||
<Form.Item name="source_id" label="模板标识">
|
||||
<Input placeholder="如 medical-assistant-v1" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scenarios" label="使用场景">
|
||||
<Select mode="tags" placeholder="输入场景标签后按回车" />
|
||||
</Form.Item>
|
||||
<Form.List name="quick_commands">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>快捷命令</div>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||
<Form.Item {...restField} name={[name, 'label']} rules={[{ required: true, message: '请输入标签' }]}>
|
||||
<Input placeholder="标签" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...restField} name={[name, 'command']} rules={[{ required: true, message: '请输入命令' }]}>
|
||||
<Input placeholder="命令/提示词" style={{ width: 280 }} />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||
添加快捷命令
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -66,34 +66,39 @@ export default function Models() {
|
||||
title: '服务商',
|
||||
dataIndex: 'provider_id',
|
||||
width: 140,
|
||||
hideInSearch: true,
|
||||
render: (_, r) => {
|
||||
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
|
||||
return provider?.display_name || r.provider_id.substring(0, 8)
|
||||
},
|
||||
},
|
||||
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, render: (_, r) => r.context_window?.toLocaleString() },
|
||||
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
||||
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, hideInSearch: true, render: (_, r) => r.context_window?.toLocaleString() },
|
||||
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, hideInSearch: true, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
||||
{
|
||||
title: '流式',
|
||||
dataIndex: 'supports_streaming',
|
||||
width: 70,
|
||||
hideInSearch: true,
|
||||
render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag>,
|
||||
},
|
||||
{
|
||||
title: '视觉',
|
||||
dataIndex: 'supports_vision',
|
||||
width: 70,
|
||||
hideInSearch: true,
|
||||
render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
width: 70,
|
||||
hideInSearch: true,
|
||||
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
||||
@@ -123,7 +128,7 @@ export default function Models() {
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
search={{}}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||
新建模型
|
||||
|
||||
@@ -100,17 +100,19 @@ export default function Providers() {
|
||||
const columns: ProColumns<Provider>[] = [
|
||||
{ title: '名称', dataIndex: 'display_name', width: 140 },
|
||||
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
|
||||
{ title: '协议', dataIndex: 'api_protocol', width: 100 },
|
||||
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
|
||||
{ title: '协议', dataIndex: 'api_protocol', width: 100, hideInSearch: true },
|
||||
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
hideInSearch: true,
|
||||
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 260,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
||||
@@ -185,7 +187,7 @@ export default function Providers() {
|
||||
dataSource={data?.items ?? []}
|
||||
loading={isLoading}
|
||||
rowKey="id"
|
||||
search={false}
|
||||
search={{}}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||
新建服务商
|
||||
|
||||
@@ -1,14 +1,61 @@
|
||||
// ============================================================
|
||||
// 路由守卫 — 未登录重定向到 /login
|
||||
// ZCLAW Admin V2 — Auth Guard with session restore
|
||||
// ============================================================
|
||||
//
|
||||
// Auth strategy:
|
||||
// 1. If Zustand has token (normal flow after login) → authenticated
|
||||
// 2. If no token but account in localStorage → call GET /auth/me
|
||||
// to validate HttpOnly cookie and restore session
|
||||
// 3. If cookie invalid → clean up and redirect to /login
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { Spin } from 'antd'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authService } from '@/services/auth'
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const account = useAuthStore((s) => s.account)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const location = useLocation()
|
||||
|
||||
// Track restore attempt to avoid double-calling
|
||||
const restoreAttempted = useRef(false)
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (restoreAttempted.current) return
|
||||
restoreAttempted.current = true
|
||||
|
||||
// If no in-memory token but account exists in localStorage,
|
||||
// try to validate the HttpOnly cookie via /auth/me
|
||||
if (!token && account) {
|
||||
setRestoring(true)
|
||||
authService.me()
|
||||
.then((meAccount) => {
|
||||
// Cookie is valid — restore session
|
||||
// Use sentinel token since real auth is via HttpOnly cookie
|
||||
login('cookie-session', '', meAccount)
|
||||
setRestoring(false)
|
||||
})
|
||||
.catch(() => {
|
||||
// Cookie expired or invalid — clean up stale data
|
||||
logout()
|
||||
setRestoring(false)
|
||||
})
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (restoring) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export const agentTemplateService = {
|
||||
visibility?: string; emoji?: string; personality?: string
|
||||
soul_content?: string; welcome_message?: string
|
||||
communication_style?: string; source_id?: string
|
||||
scenarios?: string[]
|
||||
quick_commands?: Array<{ label: string; command: string }>
|
||||
}, signal?: AbortSignal) =>
|
||||
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -257,7 +257,6 @@ export interface ProviderKey {
|
||||
priority: number
|
||||
max_rpm?: number
|
||||
max_tpm?: number
|
||||
quota_reset_interval?: string
|
||||
is_active: boolean
|
||||
last_429_at?: string
|
||||
cooldown_until?: string
|
||||
|
||||
179
admin-v2/tests/services/request.test.ts
Normal file
179
admin-v2/tests/services/request.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// ============================================================
|
||||
// request.ts 拦截器测试
|
||||
// ============================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
|
||||
// ── Hoisted: mock functions + store (accessible in vi.mock factory) ──
|
||||
const { mockSetToken, mockSetRefreshToken, mockLogout, _store } = vi.hoisted(() => {
|
||||
const mockSetToken = vi.fn()
|
||||
const mockSetRefreshToken = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
const _store = {
|
||||
token: null as string | null,
|
||||
refreshToken: null as string | null,
|
||||
setToken: mockSetToken,
|
||||
setRefreshToken: mockSetRefreshToken,
|
||||
logout: mockLogout,
|
||||
}
|
||||
return { mockSetToken, mockSetRefreshToken, mockLogout, _store }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: {
|
||||
getState: () => _store,
|
||||
},
|
||||
}))
|
||||
|
||||
import request, { ApiRequestError } from '@/services/request'
|
||||
|
||||
function setStoreState(overrides: Partial<typeof _store>) {
|
||||
Object.assign(_store, overrides)
|
||||
}
|
||||
|
||||
// ── MSW server ──────────────────────────────────────────────
|
||||
const server = setupServer()
|
||||
|
||||
beforeEach(() => {
|
||||
server.listen({ onUnhandledRequest: 'bypass' })
|
||||
mockSetToken.mockClear()
|
||||
mockSetRefreshToken.mockClear()
|
||||
mockLogout.mockClear()
|
||||
_store.token = null
|
||||
_store.refreshToken = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.close()
|
||||
})
|
||||
|
||||
describe('request interceptor', () => {
|
||||
it('attaches Authorization header when token exists', async () => {
|
||||
let capturedAuth: string | null = null
|
||||
server.use(
|
||||
http.get('*/api/v1/test', ({ request }) => {
|
||||
capturedAuth = request.headers.get('Authorization')
|
||||
return HttpResponse.json({ ok: true })
|
||||
}),
|
||||
)
|
||||
|
||||
setStoreState({ token: 'test-jwt-token' })
|
||||
await request.get('/test')
|
||||
|
||||
expect(capturedAuth).toBe('Bearer test-jwt-token')
|
||||
})
|
||||
|
||||
it('does not attach Authorization header when no token', async () => {
|
||||
let capturedAuth: string | null = null
|
||||
server.use(
|
||||
http.get('*/api/v1/test', ({ request }) => {
|
||||
capturedAuth = request.headers.get('Authorization')
|
||||
return HttpResponse.json({ ok: true })
|
||||
}),
|
||||
)
|
||||
|
||||
setStoreState({ token: null })
|
||||
await request.get('/test')
|
||||
|
||||
expect(capturedAuth).toBeNull()
|
||||
})
|
||||
|
||||
it('wraps non-401 errors as ApiRequestError', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/test', () => {
|
||||
return HttpResponse.json(
|
||||
{ error: 'not_found', message: 'Resource not found' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await request.get('/test')
|
||||
expect.fail('Should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiRequestError)
|
||||
expect((err as ApiRequestError).status).toBe(404)
|
||||
expect((err as ApiRequestError).body.message).toBe('Resource not found')
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps network errors as ApiRequestError with status 0', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/test', () => {
|
||||
return HttpResponse.error()
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
await request.get('/test')
|
||||
expect.fail('Should have thrown')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ApiRequestError)
|
||||
expect((err as ApiRequestError).status).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles 401 with refresh token success', async () => {
|
||||
let callCount = 0
|
||||
|
||||
server.use(
|
||||
http.get('*/api/v1/protected', () => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
return HttpResponse.json({ data: 'success' })
|
||||
}),
|
||||
http.post('*/api/v1/auth/refresh', () => {
|
||||
return HttpResponse.json({ token: 'new-jwt', refresh_token: 'new-refresh' })
|
||||
}),
|
||||
)
|
||||
|
||||
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
|
||||
const res = await request.get('/protected')
|
||||
|
||||
expect(res.data).toEqual({ data: 'success' })
|
||||
expect(mockSetToken).toHaveBeenCalledWith('new-jwt')
|
||||
expect(mockSetRefreshToken).toHaveBeenCalledWith('new-refresh')
|
||||
})
|
||||
|
||||
it('handles 401 with no refresh token — calls logout immediately', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/norefresh', () => {
|
||||
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}),
|
||||
)
|
||||
|
||||
setStoreState({ token: 'old-jwt', refreshToken: null })
|
||||
|
||||
try {
|
||||
await request.get('/norefresh')
|
||||
expect.fail('Should have thrown')
|
||||
} catch {
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('handles 401 with refresh failure — calls logout', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/refreshfail', () => {
|
||||
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||||
}),
|
||||
http.post('*/api/v1/auth/refresh', () => {
|
||||
return HttpResponse.json({ error: 'invalid' }, { status: 401 })
|
||||
}),
|
||||
)
|
||||
|
||||
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
|
||||
|
||||
try {
|
||||
await request.get('/refreshfail')
|
||||
expect.fail('Should have thrown')
|
||||
} catch {
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
43
admin-v2/tests/setup.ts
Normal file
43
admin-v2/tests/setup.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// ============================================================
|
||||
// Test setup: globals, jsdom polyfills, localStorage mock
|
||||
// ============================================================
|
||||
|
||||
import { beforeAll, beforeEach, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
// ── localStorage mock (jsdom provides one but we ensure clean state) ──────
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// ── Ant Design / rc-util requires matchMedia ──────────────────────────────
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Ant Design's scrollTo polyfill
|
||||
window.scrollTo = vi.fn()
|
||||
|
||||
// React 19 + jsdom: ensure getComputedStyle returns something useful
|
||||
const originalGetComputedStyle = window.getComputedStyle
|
||||
window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => {
|
||||
try {
|
||||
return originalGetComputedStyle(elt, pseudoElt)
|
||||
} catch {
|
||||
return {} as CSSStyleDeclaration
|
||||
}
|
||||
}
|
||||
})
|
||||
115
admin-v2/tests/stores/authStore.test.ts
Normal file
115
admin-v2/tests/stores/authStore.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// ============================================================
|
||||
// authStore 测试
|
||||
// ============================================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AccountPublic } from '@/types'
|
||||
|
||||
// Mock fetch for logout
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const mockAccount: AccountPublic = {
|
||||
id: 'test-id',
|
||||
username: 'testuser',
|
||||
display_name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
totp_enabled: false,
|
||||
llm_routing: 'relay',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const superAdminAccount: AccountPublic = {
|
||||
...mockAccount,
|
||||
id: 'super-id',
|
||||
username: 'superadmin',
|
||||
role: 'super_admin',
|
||||
}
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
mockFetch.mockClear()
|
||||
// Reset store state
|
||||
useAuthStore.setState({
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
account: null,
|
||||
permissions: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('login sets token, refreshToken, account and permissions', () => {
|
||||
const store = useAuthStore.getState()
|
||||
store.login('jwt-token', 'refresh-token', mockAccount)
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.token).toBe('jwt-token')
|
||||
expect(state.refreshToken).toBe('refresh-token')
|
||||
expect(state.account).toEqual(mockAccount)
|
||||
expect(state.permissions).toContain('provider:manage')
|
||||
})
|
||||
|
||||
it('super_admin gets admin:full + all permissions', () => {
|
||||
const store = useAuthStore.getState()
|
||||
store.login('jwt', 'refresh', superAdminAccount)
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.permissions).toContain('admin:full')
|
||||
expect(state.permissions).toContain('account:admin')
|
||||
expect(state.permissions).toContain('prompt:admin')
|
||||
})
|
||||
|
||||
it('user role gets only basic permissions', () => {
|
||||
const userAccount: AccountPublic = { ...mockAccount, role: 'user' }
|
||||
const store = useAuthStore.getState()
|
||||
store.login('jwt', 'refresh', userAccount)
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.permissions).toContain('model:read')
|
||||
expect(state.permissions).toContain('relay:use')
|
||||
expect(state.permissions).not.toContain('provider:manage')
|
||||
})
|
||||
|
||||
it('logout clears all state', () => {
|
||||
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
|
||||
|
||||
useAuthStore.getState().logout()
|
||||
|
||||
const state = useAuthStore.getState()
|
||||
expect(state.token).toBeNull()
|
||||
expect(state.refreshToken).toBeNull()
|
||||
expect(state.account).toBeNull()
|
||||
expect(state.permissions).toEqual([])
|
||||
expect(localStorage.getItem('zclaw_admin_account')).toBeNull()
|
||||
})
|
||||
|
||||
it('hasPermission returns true for matching permission', () => {
|
||||
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
|
||||
expect(useAuthStore.getState().hasPermission('provider:manage')).toBe(true)
|
||||
expect(useAuthStore.getState().hasPermission('config:write')).toBe(true)
|
||||
})
|
||||
|
||||
it('hasPermission returns false for non-matching permission', () => {
|
||||
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
|
||||
expect(useAuthStore.getState().hasPermission('admin:full')).toBe(false)
|
||||
})
|
||||
|
||||
it('admin:full grants all permissions via wildcard', () => {
|
||||
useAuthStore.getState().login('jwt', 'refresh', superAdminAccount)
|
||||
expect(useAuthStore.getState().hasPermission('anything:here')).toBe(true)
|
||||
expect(useAuthStore.getState().hasPermission('made:up')).toBe(true)
|
||||
})
|
||||
|
||||
it('persists account to localStorage on login', () => {
|
||||
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
|
||||
|
||||
const stored = localStorage.getItem('zclaw_admin_account')
|
||||
expect(stored).not.toBeNull()
|
||||
expect(JSON.parse(stored!).username).toBe('testuser')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
18
admin-v2/vitest.config.ts
Normal file
18
admin-v2/vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
include: ['tests/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user