Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
BUG-012: Reposition side panel toggle button (top-[52px]→top-20) to avoid overlap with header buttons in ResizableChatLayout. BUG-013: Install @tailwindcss/typography plugin and import in index.css to enable prose-* Markdown rendering classes in StreamingText. BUG-007: Rewrite authStore tests to match HttpOnly cookie auth model (login takes 1 arg, no token/refreshToken in state). Rewrite request interceptor tests for cookie-based auth. Update bug-tracker status.
162 lines
4.5 KiB
TypeScript
162 lines
4.5 KiB
TypeScript
// ============================================================
|
||
// request.ts 拦截器测试
|
||
// ============================================================
|
||
//
|
||
// 认证策略已迁移到 HttpOnly cookie 模式。
|
||
// 浏览器自动附加 cookie(withCredentials: true),JS 不操作 token。
|
||
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||
import { http, HttpResponse } from 'msw'
|
||
import { setupServer } from 'msw/node'
|
||
|
||
// ── Hoisted: mock store (cookie-based auth — no JS token) ──
|
||
const { mockLogout, _store } = vi.hoisted(() => {
|
||
const mockLogout = vi.fn()
|
||
const _store = {
|
||
isAuthenticated: false,
|
||
logout: mockLogout,
|
||
}
|
||
return { 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' })
|
||
mockLogout.mockClear()
|
||
_store.isAuthenticated = false
|
||
})
|
||
|
||
afterEach(() => {
|
||
server.close()
|
||
})
|
||
|
||
describe('request interceptor', () => {
|
||
it('sends requests with credentials (cookie-based auth)', async () => {
|
||
let capturedCreds = false
|
||
server.use(
|
||
http.get('*/api/v1/test', ({ request }) => {
|
||
// Cookie-based auth: the browser sends cookies automatically.
|
||
// We verify the request was made successfully.
|
||
capturedCreds = true
|
||
return HttpResponse.json({ ok: true })
|
||
}),
|
||
)
|
||
|
||
setStoreState({ isAuthenticated: true })
|
||
const res = await request.get('/test')
|
||
|
||
expect(res.data).toEqual({ ok: true })
|
||
expect(capturedCreds).toBe(true)
|
||
})
|
||
|
||
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 when authenticated — refreshes cookie and retries', 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', () => {
|
||
// Server sets new HttpOnly cookie in response — no JS token needed
|
||
return HttpResponse.json({ ok: true })
|
||
}),
|
||
)
|
||
|
||
setStoreState({ isAuthenticated: true })
|
||
const res = await request.get('/protected')
|
||
|
||
expect(res.data).toEqual({ data: 'success' })
|
||
})
|
||
|
||
it('handles 401 when not authenticated — calls logout immediately', async () => {
|
||
server.use(
|
||
http.get('*/api/v1/norefresh', () => {
|
||
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||
}),
|
||
)
|
||
|
||
setStoreState({ isAuthenticated: false })
|
||
|
||
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({ isAuthenticated: true })
|
||
|
||
try {
|
||
await request.get('/refreshfail')
|
||
expect.fail('Should have thrown')
|
||
} catch {
|
||
expect(mockLogout).toHaveBeenCalled()
|
||
}
|
||
})
|
||
})
|