authProtocol.test.ts 2.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { bootstrap, login } from '@/api/auth'
  3. import { clearApiSecurityContext } from '@/api/client'
  4. afterEach(() => {
  5. clearApiSecurityContext()
  6. vi.unstubAllGlobals()
  7. })
  8. describe('authentication and CSRF protocol', () => {
  9. it('keeps credentials in cookies and forwards the bootstrap CSRF token on login', async () => {
  10. const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
  11. const path = new URL(String(input), 'http://localhost').pathname
  12. const body = path.endsWith('/bootstrap')
  13. ? { authRequired: true, authenticated: false, profiles: [], csrfToken: 'csrf-from-bootstrap' }
  14. : {
  15. authRequired: true,
  16. authenticated: true,
  17. user: { user_id: 'user-1', display_name: '夭夭' },
  18. profiles: [{ name: 'yaoyao', is_default: true }],
  19. csrfToken: 'csrf-after-login',
  20. }
  21. return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
  22. })
  23. vi.stubGlobal('fetch', fetchMock)
  24. const initial = await bootstrap()
  25. expect(initial.user).toBeUndefined()
  26. const authenticated = await login({ username: 'name', password: 'secret' })
  27. expect(authenticated.user).toMatchObject({ id: 'user-1', username: '夭夭' })
  28. const loginInit = fetchMock.mock.calls[1][1]!
  29. expect(loginInit.credentials).toBe('include')
  30. expect(new Headers(loginInit.headers).get('X-CSRF-Token')).toBe('csrf-from-bootstrap')
  31. })
  32. it('keeps the CSRF context after a rejected password so login can be retried', async () => {
  33. let loginAttempts = 0
  34. const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
  35. const path = new URL(String(input), 'http://localhost').pathname
  36. if (path.endsWith('/bootstrap')) {
  37. return new Response(JSON.stringify({ authRequired: true, profiles: [], csrfToken: 'retry-token' }), {
  38. status: 200, headers: { 'content-type': 'application/json' },
  39. })
  40. }
  41. loginAttempts += 1
  42. if (loginAttempts === 1) {
  43. return new Response(JSON.stringify({ error: 'Invalid credentials', code: 'login_failed' }), {
  44. status: 401, headers: { 'content-type': 'application/json' },
  45. })
  46. }
  47. expect(new Headers(init?.headers).get('X-CSRF-Token')).toBe('retry-token')
  48. return new Response(JSON.stringify({
  49. authRequired: true,
  50. user: { user_id: 'user-1', display_name: '夭夭' },
  51. profiles: [{ name: 'yaoyao', is_default: true }],
  52. csrfToken: 'after-retry',
  53. }), { status: 200, headers: { 'content-type': 'application/json' } })
  54. })
  55. vi.stubGlobal('fetch', fetchMock)
  56. await bootstrap()
  57. await expect(login({ username: 'name', password: 'wrong' })).rejects.toMatchObject({ status: 401 })
  58. await expect(login({ username: 'name', password: 'correct' })).resolves.toMatchObject({ user: { id: 'user-1' } })
  59. })
  60. })