systemManagementDialog.test.ts 13 KB

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