systemManagementDialog.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
  2. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  3. import { nextTick } from 'vue'
  4. import SystemManagementDialog from '@/components/app/SystemManagementDialog.vue'
  5. import { listUsers } from '@/api/admin'
  6. import { getPushSystemStatus, saveFCMPushSystemConfig, savePushSystemConfig, type PushSystemStatus } from '@/api/push'
  7. vi.mock('@/api/admin', () => ({
  8. createUser: vi.fn(async () => undefined),
  9. deleteUser: vi.fn(async () => undefined),
  10. listUsers: vi.fn(async () => []),
  11. setUpstreamCredentials: vi.fn(async () => undefined),
  12. updateUser: vi.fn(async () => undefined),
  13. }))
  14. vi.mock('@/api/push', () => ({
  15. getPushSystemStatus: vi.fn(),
  16. saveFCMPushSystemConfig: vi.fn(),
  17. savePushSystemConfig: vi.fn(),
  18. }))
  19. function pushStatus(overrides: Partial<PushSystemStatus> = {}): PushSystemStatus {
  20. return {
  21. configured: false,
  22. healthy: false,
  23. topic: 'cn.samien.yaoyao.hermes',
  24. registrationCount: 0,
  25. pendingCount: 0,
  26. source: 'none',
  27. editable: true,
  28. managementAvailable: true,
  29. environments: ['development', 'production'],
  30. warnings: [],
  31. ...overrides,
  32. }
  33. }
  34. async function mountOpen(): Promise<VueWrapper> {
  35. const wrapper = mount(SystemManagementDialog, {
  36. attachTo: document.body,
  37. props: { open: false },
  38. global: { stubs: { AppIcon: true } },
  39. })
  40. await wrapper.setProps({ open: true })
  41. await flushPromises()
  42. return wrapper
  43. }
  44. function input(name: string): HTMLInputElement {
  45. return document.querySelector<HTMLInputElement>(`input[name="${name}"]`)!
  46. }
  47. function enter(name: string, value: string) {
  48. const element = input(name)
  49. element.value = value
  50. element.dispatchEvent(new Event('input'))
  51. }
  52. function button(label: string): HTMLButtonElement | undefined {
  53. return [...document.querySelectorAll<HTMLButtonElement>('button')]
  54. .find(element => element.textContent?.includes(label))
  55. }
  56. beforeEach(() => {
  57. vi.mocked(listUsers).mockResolvedValue([])
  58. vi.mocked(getPushSystemStatus).mockResolvedValue(pushStatus())
  59. vi.mocked(savePushSystemConfig).mockResolvedValue(pushStatus({ configured: true, healthy: true, source: 'file' }))
  60. vi.mocked(saveFCMPushSystemConfig).mockResolvedValue(pushStatus())
  61. })
  62. afterEach(() => {
  63. document.body.innerHTML = ''
  64. vi.clearAllMocks()
  65. })
  66. describe('System management APNs configuration', () => {
  67. it('includes the target username in every row action accessible name', async () => {
  68. vi.mocked(listUsers).mockResolvedValueOnce([{
  69. id: 'user-1',
  70. username: 'member',
  71. role: 'user',
  72. enabled: true,
  73. mustChangePassword: false,
  74. createdAt: 1,
  75. updatedAt: 1,
  76. }])
  77. const wrapper = await mountOpen()
  78. expect(document.querySelector('[aria-label="为用户 member 重置密码"]')).not.toBeNull()
  79. expect(document.querySelector('[aria-label="禁用用户 member"]')).not.toBeNull()
  80. expect(document.querySelector('[aria-label="删除用户 member"]')).not.toBeNull()
  81. wrapper.unmount()
  82. })
  83. it('validates and saves an FCM service-account path without exposing secret contents', async () => {
  84. const fcm = {
  85. configured: false,
  86. healthy: false,
  87. registrationCount: 0,
  88. pendingCount: 0,
  89. source: 'none' as const,
  90. editable: true,
  91. managementAvailable: true,
  92. packageName: 'cn.samien.yaoyao.hermes',
  93. warnings: [],
  94. }
  95. vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({ providers: { fcm } }))
  96. vi.mocked(saveFCMPushSystemConfig).mockResolvedValueOnce(pushStatus({
  97. providers: { fcm: { ...fcm, configured: true, healthy: true, source: 'file' } },
  98. }))
  99. const wrapper = await mountOpen()
  100. enter('fcm-service-account-file', ' /srv/secrets/firebase-service-account.json ')
  101. enter('fcm-project-id', ' yaoyao-test-project ')
  102. enter('fcm-package-name', ' cn.samien.yaoyao.hermes ')
  103. await nextTick()
  104. const form = input('fcm-service-account-file').closest('form')!
  105. form.dispatchEvent(new Event('submit'))
  106. await flushPromises()
  107. expect(saveFCMPushSystemConfig).toHaveBeenCalledWith({
  108. serviceAccountFile: '/srv/secrets/firebase-service-account.json',
  109. projectId: 'yaoyao-test-project',
  110. packageName: 'cn.samien.yaoyao.hermes',
  111. })
  112. expect(document.body.textContent).toContain('Firebase 权限与项目验证通过,FCM 已启用')
  113. expect(document.body.textContent).toContain('FCM 推送服务正常')
  114. wrapper.unmount()
  115. })
  116. it('sends a server-local path and both environments through validation and enablement', async () => {
  117. vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({ environments: [] }))
  118. vi.mocked(savePushSystemConfig).mockResolvedValueOnce(pushStatus({
  119. configured: true,
  120. healthy: true,
  121. source: 'file',
  122. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  123. keyId: 'KEY123',
  124. teamId: 'TEAM123',
  125. warnings: [{
  126. code: 'apns_key_permissions',
  127. message: 'APNs 已启用,但密钥文件权限为 0644;建议调整为 0600,此提示不会影响当前推送。',
  128. actualMode: '0644',
  129. recommendedMode: '0600',
  130. }],
  131. }))
  132. const wrapper = await mountOpen()
  133. enter('push-key-file', ' /srv/secrets/AuthKey_TEST.p8 ')
  134. enter('push-key-id', ' KEY123 ')
  135. enter('push-team-id', ' TEAM123 ')
  136. enter('push-topic', ' cn.samien.yaoyao.hermes ')
  137. await nextTick()
  138. const save = button('验证并启用')!
  139. expect(save.disabled).toBe(false)
  140. save.click()
  141. await flushPromises()
  142. expect(savePushSystemConfig).toHaveBeenCalledWith({
  143. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  144. keyId: 'KEY123',
  145. teamId: 'TEAM123',
  146. topic: 'cn.samien.yaoyao.hermes',
  147. environments: ['development', 'production'],
  148. })
  149. expect(document.body.textContent).toContain('Sandbox 与 Production 验证通过,APNs 已启用')
  150. expect(document.querySelector('.push-warning')?.textContent).toContain('权限为 0644')
  151. expect(button('验证并启用')?.disabled).toBe(false)
  152. wrapper.unmount()
  153. })
  154. it('shows environment-managed configuration as read-only', async () => {
  155. vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
  156. configured: true,
  157. healthy: true,
  158. source: 'environment',
  159. editable: false,
  160. keyFile: '/Users/test/.hermes/secrets/AuthKey_TEST.p8',
  161. keyId: 'KEY123',
  162. teamId: 'TEAM123',
  163. }))
  164. const wrapper = await mountOpen()
  165. expect(document.body.textContent).toContain('由服务环境变量管理,Web 仅可查看')
  166. expect(input('push-key-file').readOnly).toBe(true)
  167. expect(document.querySelector<HTMLFieldSetElement>('.push-config-form fieldset')?.disabled).toBe(true)
  168. expect(button('验证并启用')).toBeUndefined()
  169. document.querySelector<HTMLFormElement>('.push-config-form')!.dispatchEvent(new Event('submit'))
  170. await nextTick()
  171. expect(savePushSystemConfig).not.toHaveBeenCalled()
  172. wrapper.unmount()
  173. })
  174. it('surfaces an invalid startup configuration instead of calling it unconfigured', async () => {
  175. vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
  176. configured: false,
  177. healthy: false,
  178. source: 'environment',
  179. editable: false,
  180. lastError: 'APNs key file must contain an ES256 private key',
  181. }))
  182. const wrapper = await mountOpen()
  183. expect(document.body.textContent).toContain('APNs 配置无效:APNs key file must contain an ES256 private key')
  184. wrapper.unmount()
  185. })
  186. it('keeps an old status-only response safe and does not expose editing controls', async () => {
  187. vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
  188. configured: true,
  189. healthy: true,
  190. managementAvailable: false,
  191. editable: false,
  192. }))
  193. const wrapper = await mountOpen()
  194. expect(document.body.textContent).toContain('APNs 推送服务正常')
  195. expect(document.body.textContent).toContain('当前服务版本只提供推送状态')
  196. expect(document.querySelector('.push-config-form')).toBeNull()
  197. expect(savePushSystemConfig).not.toHaveBeenCalled()
  198. wrapper.unmount()
  199. })
  200. it('rejects an empty environment set locally and preserves input after a server error', async () => {
  201. vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
  202. source: 'file',
  203. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  204. keyId: 'KEY123',
  205. teamId: 'TEAM123',
  206. environments: ['development'],
  207. }))
  208. const wrapper = await mountOpen()
  209. const development = document.querySelector<HTMLInputElement>('input[type="checkbox"][value="development"]')!
  210. development.checked = false
  211. development.dispatchEvent(new Event('change'))
  212. await nextTick()
  213. document.querySelector<HTMLFormElement>('.push-config-form')!.dispatchEvent(new Event('submit'))
  214. await nextTick()
  215. expect(document.querySelector('[role="alert"]')?.textContent).toContain('至少选择 Sandbox 或 Production')
  216. expect(savePushSystemConfig).not.toHaveBeenCalled()
  217. development.checked = true
  218. development.dispatchEvent(new Event('change'))
  219. await nextTick()
  220. vi.mocked(savePushSystemConfig).mockRejectedValueOnce(new Error('8800 无法读取该密钥文件,请检查路径和服务账户权限'))
  221. button('验证并启用')!.click()
  222. await flushPromises()
  223. expect(document.querySelector('[role="alert"]')?.textContent).toContain('8800 无法读取该密钥文件')
  224. expect(input('push-key-file').value).toBe('/srv/secrets/AuthKey_TEST.p8')
  225. wrapper.unmount()
  226. })
  227. it('preserves an unsaved FCM draft while APNs is saved and locks the sibling form during the request', async () => {
  228. const fcm = {
  229. configured: true,
  230. healthy: true,
  231. registrationCount: 0,
  232. pendingCount: 0,
  233. source: 'file' as const,
  234. editable: true,
  235. managementAvailable: true,
  236. serviceAccountFile: '/srv/secrets/firebase.json',
  237. projectId: 'server-project',
  238. packageName: 'cn.samien.yaoyao.hermes',
  239. warnings: [],
  240. }
  241. vi.mocked(getPushSystemStatus).mockResolvedValueOnce(pushStatus({
  242. source: 'file',
  243. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  244. keyId: 'KEY123',
  245. teamId: 'TEAM123',
  246. providers: { fcm },
  247. }))
  248. let resolveSave: ((status: PushSystemStatus) => void) | undefined
  249. vi.mocked(savePushSystemConfig).mockReturnValueOnce(new Promise(resolve => { resolveSave = resolve }))
  250. const wrapper = await mountOpen()
  251. enter('fcm-project-id', 'local-unsaved-project')
  252. await nextTick()
  253. button('验证并启用')!.click()
  254. await nextTick()
  255. expect(input('fcm-project-id').readOnly).toBe(true)
  256. resolveSave?.(pushStatus({
  257. configured: true,
  258. healthy: true,
  259. source: 'file',
  260. keyFile: '/srv/secrets/AuthKey_TEST.p8',
  261. keyId: 'KEY123',
  262. teamId: 'TEAM123',
  263. providers: { fcm: { ...fcm, projectId: 'server-response-project' } },
  264. }))
  265. await flushPromises()
  266. expect(input('fcm-project-id').value).toBe('local-unsaved-project')
  267. expect(input('fcm-project-id').readOnly).toBe(false)
  268. wrapper.unmount()
  269. })
  270. })