systemManagementDialog.test.ts 11 KB

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