From 2d063870fe9fd55bcd4433c1e6a935e31a6e60cb Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Wed, 18 Mar 2026 16:23:58 +0800 Subject: [PATCH] fix login and register --- frontend/src/pages/__tests__/login.spec.ts | 247 ++++++++++++++++++ frontend/src/pages/__tests__/register.spec.ts | 190 ++++++++++++++ frontend/src/pages/login.vue | 2 +- frontend/src/pages/register.vue | 2 +- frontend/src/stores/__tests__/user.spec.ts | 35 ++- frontend/src/stores/user.ts | 9 +- 6 files changed, 480 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/__tests__/login.spec.ts create mode 100644 frontend/src/pages/__tests__/register.spec.ts diff --git a/frontend/src/pages/__tests__/login.spec.ts b/frontend/src/pages/__tests__/login.spec.ts new file mode 100644 index 0000000..8a8e080 --- /dev/null +++ b/frontend/src/pages/__tests__/login.spec.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { message } from 'ant-design-vue' +import { ApiError } from '@/types/api' +import { useUserStore } from '@/stores/user' + +// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要 +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +const mockPush = vi.fn() +const mockRoute = { query: {} } + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush }), + useRoute: () => mockRoute, +})) + +// Stub Brand component to avoid asset loading +vi.mock('@/components/Brand.vue', () => ({ + default: { template: '
' }, +})) + +describe('Login Page', () => { + let wrapper: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.restoreAllMocks() + mockPush.mockReset() + mockRoute.query = {} + vi.useFakeTimers() + }) + + afterEach(() => { + wrapper?.unmount() + vi.useRealTimers() + }) + + async function mountPage() { + const { default: LoginPage } = await import('../login.vue') + + wrapper = mount(LoginPage, { + attachTo: document.body, + global: { + stubs: { + UserOutlined: { template: '' }, + LockOutlined: { template: '' }, + }, + }, + }) + await flushPromises() + await nextTick() + return wrapper + } + + it('renders login form with username, password inputs and submit button', async () => { + await mountPage() + const html = wrapper.html() + // 验证输入框存在 + expect(html).toContain('请输入用户名') + expect(html).toContain('请输入密码') + // 验证提交按钮存在 + expect(html).toContain('登录') + }) + + it('calls /api/v1/login, sets token/user and redirects to /', async () => { + const { api } = await import('@/utils/request') + const mockResponse = { + access_token: 'a.b.c', + refresh_token: 'r.r.r', + user: { id: 1, username: 'admin', email: 'a@b.com', role: 'admin', status: 1 }, + } + vi.mocked(api.post).mockResolvedValueOnce(mockResponse) + + await mountPage() + const store = useUserStore() + + // 直接调用 handleSubmit 来绕过表单验证 + // 通过组件 vm 访问 setup 暴露的方法 + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'admin' + vm.formState.password = 'password' + vm.formRef = { value: { validate: vi.fn().mockResolvedValue(true) } } + // mock formRef.validate + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(api.post).toHaveBeenCalledWith('/api/v1/login', { + username: 'admin', + password: 'password', + }) + expect(store.token).toBe('a.b.c') + expect(store.user).toEqual(mockResponse.user) + + vi.advanceTimersByTime(500) + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('passes remember=false to setToken when unchecked', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockResolvedValueOnce({ + access_token: 'a.b.c', + refresh_token: 'r.r.r', + user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, + }) + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'user' + vm.formState.password = 'pass' + vm.formState.remember = false + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('user')).toBeNull() + }) + + it('redirects to safe path from query param', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockResolvedValueOnce({ + access_token: 'a.b.c', + refresh_token: 'r.r.r', + user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, + }) + mockRoute.query = { redirect: '/dashboard' } + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'user' + vm.formState.password = 'pass' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + vi.advanceTimersByTime(500) + expect(mockPush).toHaveBeenCalledWith('/dashboard') + }) + + it('blocks //evil redirect and falls back to /', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockResolvedValueOnce({ + access_token: 'a.b.c', + refresh_token: 'r.r.r', + user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, + }) + mockRoute.query = { redirect: '//evil.com' } + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'user' + vm.formState.password = 'pass' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + vi.advanceTimersByTime(500) + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('blocks http:// redirect and falls back to /', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockResolvedValueOnce({ + access_token: 'a.b.c', + refresh_token: 'r.r.r', + user: { id: 1, username: 'u', email: 'u@u.com', role: 'user', status: 1 }, + }) + mockRoute.query = { redirect: 'http://evil.com' } + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'user' + vm.formState.password = 'pass' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + vi.advanceTimersByTime(500) + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('shows error message on ApiError', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockRejectedValueOnce(new ApiError('用户名或密码错误', 401)) + const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any) + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'user' + vm.formState.password = 'wrong' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith('用户名或密码错误') + errorSpy.mockRestore() + }) + + it('shows network error message on generic error', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockRejectedValueOnce(new Error('Network failure')) + const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any) + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'user' + vm.formState.password = 'pass' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith('登录失败,请检查网络连接') + errorSpy.mockRestore() + }) + + it('navigates to register page', async () => { + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.goToRegister() + + expect(mockPush).toHaveBeenCalledWith('/register') + }) +}) diff --git a/frontend/src/pages/__tests__/register.spec.ts b/frontend/src/pages/__tests__/register.spec.ts new file mode 100644 index 0000000..6cafc1c --- /dev/null +++ b/frontend/src/pages/__tests__/register.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { mount, flushPromises } from '@vue/test-utils' +import { nextTick } from 'vue' +import { message } from 'ant-design-vue' +import { ApiError } from '@/types/api' +import { useUserStore } from '@/stores/user' + +// jsdom 不支持 matchMedia,Ant Design Vue 响应式布局需要 +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}) + +vi.mock('@/utils/request', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +const mockPush = vi.fn() + +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush }), + useRoute: () => ({ query: {} }), +})) + +vi.mock('@/components/Brand.vue', () => ({ + default: { template: '
' }, +})) + +describe('Register Page', () => { + let wrapper: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.restoreAllMocks() + mockPush.mockReset() + vi.useFakeTimers() + }) + + afterEach(() => { + wrapper?.unmount() + vi.useRealTimers() + }) + + async function mountPage() { + const { default: RegisterPage } = await import('../register.vue') + + wrapper = mount(RegisterPage, { + attachTo: document.body, + global: { + stubs: { + UserOutlined: { template: '' }, + LockOutlined: { template: '' }, + MailOutlined: { template: '' }, + }, + }, + }) + await flushPromises() + await nextTick() + return wrapper + } + + it('renders register form with all fields and submit button', async () => { + await mountPage() + const html = wrapper.html() + // 验证所有输入框的 placeholder 存在 + expect(html).toContain('请输入用户名') + expect(html).toContain('请输入邮箱地址') + expect(html).toContain('请输入密码') + expect(html).toContain('请再次输入密码') + // 验证注册按钮存在 + expect(html).toContain('注册') + }) + + it('calls /api/v1/register and redirects to /login on success without token', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockResolvedValueOnce({}) + const successSpy = vi.spyOn(message, 'success').mockImplementation(() => ({}) as any) + + await mountPage() + const store = useUserStore() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'newuser' + vm.formState.email = 'new@test.com' + vm.formState.password = 'password123' + vm.formState.confirmPassword = 'password123' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(api.post).toHaveBeenCalledWith('/api/v1/register', { + username: 'newuser', + email: 'new@test.com', + password: 'password123', + }) + expect(store.token).toBeNull() + expect(successSpy).toHaveBeenCalled() + + vi.advanceTimersByTime(1500) + expect(mockPush).toHaveBeenCalledWith('/login') + successSpy.mockRestore() + }) + + it('sets token and user when backend returns them on register', async () => { + const { api } = await import('@/utils/request') + const mockResponse = { + access_token: 'a.b.c', + refresh_token: 'r.r.r', + user: { id: 2, username: 'newuser', email: 'new@test.com', role: 'user', status: 1 }, + } + vi.mocked(api.post).mockResolvedValueOnce(mockResponse) + vi.spyOn(message, 'success').mockImplementation(() => ({}) as any) + + await mountPage() + const store = useUserStore() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'newuser' + vm.formState.email = 'new@test.com' + vm.formState.password = 'password123' + vm.formState.confirmPassword = 'password123' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(store.token).toBe('a.b.c') + expect(store.user).toEqual(mockResponse.user) + + vi.advanceTimersByTime(1500) + expect(mockPush).toHaveBeenCalledWith('/login') + }) + + it('shows error message on ApiError', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockRejectedValueOnce(new ApiError('用户名已存在', 409)) + const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any) + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'existing' + vm.formState.email = 'e@test.com' + vm.formState.password = 'password123' + vm.formState.confirmPassword = 'password123' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith('用户名已存在') + errorSpy.mockRestore() + }) + + it('shows network error message on generic error', async () => { + const { api } = await import('@/utils/request') + vi.mocked(api.post).mockRejectedValueOnce(new Error('Network failure')) + const errorSpy = vi.spyOn(message, 'error').mockImplementation(() => ({}) as any) + + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.formState.username = 'user' + vm.formState.email = 'u@test.com' + vm.formState.password = 'password123' + vm.formState.confirmPassword = 'password123' + vm.formRef = { validate: vi.fn().mockResolvedValue(true) } + await vm.handleSubmit() + await flushPromises() + + expect(errorSpy).toHaveBeenCalledWith('注册失败,请检查网络连接') + errorSpy.mockRestore() + }) + + it('navigates to login page', async () => { + await mountPage() + const vm = wrapper.getCurrentComponent().setupState + vm.goToLogin() + + expect(mockPush).toHaveBeenCalledWith('/login') + }) +}) diff --git a/frontend/src/pages/login.vue b/frontend/src/pages/login.vue index 0526927..faa7442 100644 --- a/frontend/src/pages/login.vue +++ b/frontend/src/pages/login.vue @@ -36,7 +36,7 @@ const handleSubmit = async () => { access_token: string refresh_token: string user: UserInfo - }>('/api/auth/login', { + }>('/api/v1/login', { username: formState.username, password: formState.password, }) diff --git a/frontend/src/pages/register.vue b/frontend/src/pages/register.vue index 8a3ac99..ac3af2e 100644 --- a/frontend/src/pages/register.vue +++ b/frontend/src/pages/register.vue @@ -54,7 +54,7 @@ const handleSubmit = async () => { access_token?: string refresh_token?: string user?: UserInfo - }>('/api/auth/register', { + }>('/api/v1/register', { username: formState.username, email: formState.email, password: formState.password, diff --git a/frontend/src/stores/__tests__/user.spec.ts b/frontend/src/stores/__tests__/user.spec.ts index c6ac748..dd85a5f 100644 --- a/frontend/src/stores/__tests__/user.spec.ts +++ b/frontend/src/stores/__tests__/user.spec.ts @@ -73,6 +73,28 @@ describe('useUserStore', () => { }) }) + describe('setToken with remember=false', () => { + it('does not persist tokens to localStorage', () => { + const store = useUserStore() + + store.setToken('h.p.s', 'r.p.s', false) + + expect(store.token).toBe('h.p.s') + expect(store.refreshToken).toBe('r.p.s') + expect(localStorage.getItem('access_token')).toBeNull() + expect(localStorage.getItem('refresh_token')).toBeNull() + }) + + it('clears existing user from localStorage', () => { + localStorage.setItem('user', JSON.stringify({ id: 1, username: 'old' })) + + const store = useUserStore() + store.setToken('h.p.s', 'r.p.s', false) + + expect(localStorage.getItem('user')).toBeNull() + }) + }) + describe('setUser', () => { it('sets user info in store and localStorage', () => { const store = useUserStore() @@ -85,6 +107,17 @@ describe('useUserStore', () => { expect(store.isAdmin).toBe(false) expect(JSON.parse(localStorage.getItem('user')!)).toEqual(userInfo) }) + + it('does not write to localStorage when remember=false', () => { + const store = useUserStore() + store.setToken('h.p.s', 'r.p.s', false) + + const userInfo = { id: 1, username: 'test', email: 't@t.com', role: 'user', status: 1 } + store.setUser(userInfo) + + expect(store.user).toEqual(userInfo) + expect(localStorage.getItem('user')).toBeNull() + }) }) describe('logout', () => { @@ -114,7 +147,7 @@ describe('useUserStore', () => { const store = useUserStore() const result = await store.fetchCurrentUser() - expect(api.get).toHaveBeenCalledWith('/api/v1/auth/me') + expect(api.get).toHaveBeenCalledWith('/api/v1/me') expect(result).toEqual(mockUser) expect(store.user).toEqual(mockUser) expect(store.isAdmin).toBe(true) diff --git a/frontend/src/stores/user.ts b/frontend/src/stores/user.ts index 8b37d42..1b9660b 100644 --- a/frontend/src/stores/user.ts +++ b/frontend/src/stores/user.ts @@ -12,6 +12,7 @@ export const useUserStore = defineStore('user', () => { const token = ref(localStorage.getItem('access_token')) const refreshToken = ref(localStorage.getItem('refresh_token')) const user = ref(null) + const _remember = ref(true) // 基本 JWT 格式校验(三段式),防止垃圾值绕过路由守卫 const isLoggedIn = computed(() => { @@ -24,6 +25,7 @@ export const useUserStore = defineStore('user', () => { function setToken(accessToken: string, newRefreshToken: string, remember = true) { token.value = accessToken refreshToken.value = newRefreshToken + _remember.value = remember if (remember) { localStorage.setItem('access_token', accessToken) localStorage.setItem('refresh_token', newRefreshToken) @@ -31,16 +33,19 @@ export const useUserStore = defineStore('user', () => { // 不记住:清除持久化,token 仅存于内存,关闭标签页即失效 localStorage.removeItem('access_token') localStorage.removeItem('refresh_token') + localStorage.removeItem('user') } } function setUser(info: UserInfo) { user.value = info - localStorage.setItem('user', JSON.stringify(info)) + if (_remember.value) { + localStorage.setItem('user', JSON.stringify(info)) + } } async function fetchCurrentUser() { - const data = await api.get('/api/v1/auth/me') + const data = await api.get('/api/v1/me') setUser(data) return data }