authProtocol.test.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { bootstrap, changeCredentials, login } from '@/api/auth'
  3. import {
  4. apiRequest,
  5. clearApiSecurityContext,
  6. onApiUnauthorized,
  7. setApiCsrfToken,
  8. } from '@/api/client'
  9. afterEach(() => {
  10. clearApiSecurityContext()
  11. vi.unstubAllGlobals()
  12. })
  13. describe('authentication and CSRF protocol', () => {
  14. it('keeps credentials in cookies and forwards the bootstrap CSRF token on login', async () => {
  15. const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
  16. const path = new URL(String(input), 'http://localhost').pathname
  17. const body = path.endsWith('/bootstrap')
  18. ? { authRequired: true, authenticated: false, profiles: [], csrfToken: 'csrf-from-bootstrap' }
  19. : {
  20. authRequired: true,
  21. authenticated: true,
  22. user: { user_id: 'user-1', display_name: '夭夭' },
  23. profiles: [{ name: 'yaoyao', is_default: true }],
  24. csrfToken: 'csrf-after-login',
  25. }
  26. return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } })
  27. })
  28. vi.stubGlobal('fetch', fetchMock)
  29. const initial = await bootstrap()
  30. expect(initial.user).toBeUndefined()
  31. const authenticated = await login({ username: 'name', password: 'secret' })
  32. expect(authenticated.user).toMatchObject({ id: 'user-1', username: '夭夭' })
  33. const loginInit = fetchMock.mock.calls[1][1]!
  34. expect(loginInit.credentials).toBe('include')
  35. expect(new Headers(loginInit.headers).get('X-CSRF-Token')).toBe('csrf-from-bootstrap')
  36. })
  37. it('keeps the CSRF context after a rejected password so login can be retried', async () => {
  38. let loginAttempts = 0
  39. const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
  40. const path = new URL(String(input), 'http://localhost').pathname
  41. if (path.endsWith('/bootstrap')) {
  42. return new Response(JSON.stringify({ authRequired: true, profiles: [], csrfToken: 'retry-token' }), {
  43. status: 200, headers: { 'content-type': 'application/json' },
  44. })
  45. }
  46. loginAttempts += 1
  47. if (loginAttempts === 1) {
  48. return new Response(JSON.stringify({ error: 'Invalid credentials', code: 'login_failed' }), {
  49. status: 401, headers: { 'content-type': 'application/json' },
  50. })
  51. }
  52. expect(new Headers(init?.headers).get('X-CSRF-Token')).toBe('retry-token')
  53. return new Response(JSON.stringify({
  54. authRequired: true,
  55. user: { user_id: 'user-1', display_name: '夭夭' },
  56. profiles: [{ name: 'yaoyao', is_default: true }],
  57. csrfToken: 'after-retry',
  58. }), { status: 200, headers: { 'content-type': 'application/json' } })
  59. })
  60. vi.stubGlobal('fetch', fetchMock)
  61. await bootstrap()
  62. await expect(login({ username: 'name', password: 'wrong' })).rejects.toMatchObject({ status: 401 })
  63. await expect(login({ username: 'name', password: 'correct' })).resolves.toMatchObject({ user: { id: 'user-1' } })
  64. })
  65. it('keeps the login session for an authenticated 403 permission error', async () => {
  66. let expirations = 0
  67. const unsubscribe = onApiUnauthorized(() => { expirations += 1 })
  68. setApiCsrfToken('csrf-permission')
  69. vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
  70. message: '系统升级默认只允许在本机执行',
  71. code: 'remote_system_update_disabled',
  72. }), { status: 403, headers: { 'content-type': 'application/json' } })))
  73. try {
  74. await expect(apiRequest('/api/app/system/update/apply', {
  75. method: 'POST',
  76. body: { targetVersion: '0.3.0' },
  77. })).rejects.toMatchObject({
  78. status: 403,
  79. code: 'remote_system_update_disabled',
  80. message: '系统升级默认只允许在本机执行',
  81. })
  82. expect(expirations).toBe(0)
  83. } finally {
  84. unsubscribe()
  85. }
  86. })
  87. it('does not expire the session when the current password is rejected', async () => {
  88. let expirations = 0
  89. const unsubscribe = onApiUnauthorized(() => { expirations += 1 })
  90. setApiCsrfToken('csrf-account')
  91. vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
  92. message: '当前密码错误',
  93. code: 'invalid_current_password',
  94. }), { status: 401, headers: { 'content-type': 'application/json' } })))
  95. try {
  96. await expect(changeCredentials({
  97. currentPassword: 'wrong',
  98. newPassword: 'new-password',
  99. })).rejects.toMatchObject({ status: 401, code: 'invalid_current_password' })
  100. expect(expirations).toBe(0)
  101. } finally {
  102. unsubscribe()
  103. }
  104. })
  105. it.each(['user-1', 'user-2'])('ignores a late 401 after a fresh login as %s', async accountId => {
  106. setApiCsrfToken('old-token', 'user-1')
  107. let finishOldRequest!: (value: Response) => void
  108. vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>(resolve => { finishOldRequest = resolve })))
  109. const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
  110. try {
  111. const oldRequest = apiRequest('/api/app/profiles')
  112. clearApiSecurityContext()
  113. setApiCsrfToken('new-token', accountId)
  114. finishOldRequest(Response.json({ code: 'authentication_required' }, { status: 401 }))
  115. await expect(oldRequest).rejects.toMatchObject({ status: 401 })
  116. expect(expired).not.toHaveBeenCalled()
  117. } finally { unsubscribe() }
  118. })
  119. it('still expires the current session when its own request requires login', async () => {
  120. setApiCsrfToken('current-token', 'user-1')
  121. vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 'authentication_required' }, { status: 401 })))
  122. const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
  123. try {
  124. await expect(apiRequest('/api/app/profiles')).rejects.toMatchObject({ status: 401 })
  125. expect(expired).toHaveBeenCalledOnce()
  126. } finally { unsubscribe() }
  127. })
  128. it('preserves login when the upstream service cannot authenticate', async () => {
  129. setApiCsrfToken('current-token', 'user-1')
  130. vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 'upstream_auth_unavailable' }, { status: 502 })))
  131. const expired = vi.fn(), unsubscribe = onApiUnauthorized(expired)
  132. try {
  133. await expect(apiRequest('/api/app/models')).rejects.toMatchObject({ status: 502, code: 'upstream_auth_unavailable' })
  134. expect(expired).not.toHaveBeenCalled()
  135. } finally { unsubscribe() }
  136. })
  137. it('surfaces the first structured upstream validation error', async () => {
  138. setApiCsrfToken('csrf-validation')
  139. vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
  140. detail: [{ loc: ['body', 'avatar'], msg: 'Value error, unsupported team avatar', type: 'value_error' }],
  141. }), { status: 422, headers: { 'content-type': 'application/json' } })))
  142. await expect(apiRequest('/api/app/groups/rooms', {
  143. method: 'POST',
  144. body: { avatar: 'invalid' },
  145. })).rejects.toMatchObject({
  146. status: 422,
  147. message: 'body.avatar:Value error, unsupported team avatar',
  148. })
  149. })
  150. })