fcmConfiguration.test.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. import { generateKeyPairSync } from 'node:crypto'
  2. import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import {
  7. FCMConfigurationManager,
  8. fcmConfigurationPath,
  9. loadFCMConfiguration,
  10. validateFCMConfiguration,
  11. } from '../../src/server/fcmConfiguration.js'
  12. import { HttpError } from '../../src/server/errors.js'
  13. const roots: string[] = []
  14. afterEach(() => {
  15. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  16. })
  17. function fixture(mode = 0o600) {
  18. const home = mkdtempSync(join(tmpdir(), 'yaoyao-fcm-config-'))
  19. roots.push(home)
  20. const { privateKey } = generateKeyPairSync('rsa', {
  21. modulusLength: 2048,
  22. privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  23. publicKeyEncoding: { type: 'spki', format: 'pem' },
  24. })
  25. const serviceAccountFile = join(home, 'service-account.json')
  26. writeFileSync(serviceAccountFile, JSON.stringify({
  27. type: 'service_account',
  28. project_id: 'yaoyao-test-project',
  29. client_email: 'push@yaoyao-test-project.iam.gserviceaccount.com',
  30. private_key: privateKey,
  31. token_uri: 'https://oauth2.googleapis.com/token',
  32. }), { mode })
  33. chmodSync(serviceAccountFile, mode)
  34. return { home, serviceAccountFile }
  35. }
  36. describe('FCM configuration', () => {
  37. it('validates the project, RSA credentials, package, and file permissions without copying secrets', async () => {
  38. const { home, serviceAccountFile } = fixture(0o644)
  39. const input = {
  40. serviceAccountFile,
  41. projectId: 'yaoyao-test-project',
  42. packageName: 'cn.samien.yaoyao.hermes',
  43. }
  44. const validated = validateFCMConfiguration(input)
  45. expect(validated).toMatchObject({
  46. source: 'file',
  47. editable: true,
  48. warnings: [{ code: 'fcm_service_account_permissions', actualMode: '0644', recommendedMode: '0600' }],
  49. })
  50. const manager = new FCMConfigurationManager(
  51. home,
  52. { source: 'none', editable: true, warnings: [] },
  53. { probe: async () => undefined },
  54. )
  55. await expect(manager.update(input, () => undefined)).resolves.toMatchObject({ source: 'file' })
  56. const stored = readFileSync(fcmConfigurationPath(home), 'utf8')
  57. expect(stored).toContain(serviceAccountFile)
  58. expect(stored).toContain('yaoyao-test-project')
  59. expect(stored).not.toContain('private_key')
  60. expect(statSync(fcmConfigurationPath(home)).mode & 0o777).toBe(0o600)
  61. })
  62. it('keeps environment configuration read-only and reports partial configuration safely', async () => {
  63. const { home, serviceAccountFile } = fixture()
  64. const loaded = loadFCMConfiguration(home, {
  65. HERMES_YAOYAO_FCM_SERVICE_ACCOUNT_FILE: serviceAccountFile,
  66. HERMES_YAOYAO_FCM_PROJECT_ID: 'yaoyao-test-project',
  67. })
  68. expect(loaded).toMatchObject({ source: 'environment', editable: false, config: { projectId: 'yaoyao-test-project' } })
  69. const manager = new FCMConfigurationManager(home, loaded, { probe: async () => undefined })
  70. await expect(manager.update(loaded.input!, () => undefined)).rejects.toMatchObject({
  71. status: 409,
  72. code: 'fcm_environment_managed',
  73. })
  74. const partial = loadFCMConfiguration(home, { HERMES_YAOYAO_FCM_PROJECT_ID: 'yaoyao-test-project' })
  75. expect(partial).toMatchObject({ source: 'environment', editable: false })
  76. expect(partial.configurationError).toMatch(/absolute local path/)
  77. })
  78. it('preserves an unavailable-provider status instead of misreporting it as rejected credentials', async () => {
  79. const { home, serviceAccountFile } = fixture()
  80. const manager = new FCMConfigurationManager(
  81. home,
  82. { source: 'none', editable: true, warnings: [] },
  83. { probe: async () => { throw new HttpError(503, 'FCM unavailable', 'fcm_probe_unavailable') } },
  84. )
  85. await expect(manager.update({
  86. serviceAccountFile,
  87. projectId: 'yaoyao-test-project',
  88. packageName: 'cn.samien.yaoyao.hermes',
  89. }, () => undefined)).rejects.toMatchObject({ status: 503, code: 'fcm_probe_unavailable' })
  90. })
  91. })