apnsConfiguration.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { generateKeyPairSync } from 'node:crypto'
  2. import { execFileSync } from 'node:child_process'
  3. import {
  4. chmodSync,
  5. mkdirSync,
  6. mkdtempSync,
  7. readFileSync,
  8. realpathSync,
  9. rmSync,
  10. statSync,
  11. writeFileSync,
  12. } from 'node:fs'
  13. import { tmpdir } from 'node:os'
  14. import { join } from 'node:path'
  15. import { afterEach, describe, expect, it, vi } from 'vitest'
  16. import {
  17. APNsConfigurationManager,
  18. apnsConfigurationPath,
  19. loadAPNsConfiguration,
  20. validateAPNsConfiguration,
  21. } from '../../src/server/apnsConfiguration.js'
  22. const roots: string[] = []
  23. afterEach(() => {
  24. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  25. })
  26. function temporaryRoot(name: string): string {
  27. const root = mkdtempSync(join(tmpdir(), `${name}-`))
  28. roots.push(root)
  29. return root
  30. }
  31. function ecKey(path: string, mode = 0o600): void {
  32. const { privateKey } = generateKeyPairSync('ec', {
  33. namedCurve: 'prime256v1',
  34. privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  35. publicKeyEncoding: { type: 'spki', format: 'pem' },
  36. })
  37. writeFileSync(path, privateKey, { mode })
  38. chmodSync(path, mode)
  39. }
  40. function input(keyFile: string) {
  41. return {
  42. keyFile,
  43. keyId: 'KEY1234567',
  44. teamId: 'TEAM123456',
  45. topic: 'cn.samien.yaoyao.hermes',
  46. environments: ['development', 'production'] as const,
  47. }
  48. }
  49. describe('APNs Web-managed configuration', () => {
  50. it('persists a validated local path atomically and treats broad key permissions as a warning', async () => {
  51. const home = temporaryRoot('yaoyao-apns-config')
  52. const keyFile = join(home, 'AuthKey_TEST.p8')
  53. ecKey(keyFile, 0o644)
  54. const probe = vi.fn(async () => undefined)
  55. const manager = new APNsConfigurationManager(
  56. home,
  57. { source: 'none', editable: true, warnings: [] },
  58. { probe },
  59. )
  60. const apply = vi.fn()
  61. const result = await manager.update(input(keyFile), apply)
  62. const canonicalKeyFile = realpathSync(keyFile)
  63. expect(result).toMatchObject({
  64. source: 'file',
  65. editable: true,
  66. config: { keyFile: canonicalKeyFile, keyId: 'KEY1234567' },
  67. warnings: [{ code: 'apns_key_permissions', actualMode: '0644', recommendedMode: '0600' }],
  68. })
  69. expect(probe).toHaveBeenCalledWith(expect.objectContaining({ keyFile: canonicalKeyFile }), ['development', 'production'])
  70. expect(apply).toHaveBeenCalledTimes(1)
  71. expect(statSync(apnsConfigurationPath(home)).mode & 0o777).toBe(0o600)
  72. expect(loadAPNsConfiguration(home, {})).toMatchObject({ source: 'file', editable: true, config: { keyFile: canonicalKeyFile } })
  73. })
  74. it('accepts stricter 0400 permissions without warning and rejects invalid key material', () => {
  75. const home = temporaryRoot('yaoyao-apns-permissions')
  76. const keyFile = join(home, 'AuthKey_TEST.p8')
  77. ecKey(keyFile, 0o400)
  78. expect(validateAPNsConfiguration(input(keyFile)).warnings).toEqual([])
  79. expect(() => validateAPNsConfiguration({
  80. ...input(keyFile), topic: 'cn.samien.another-app',
  81. })).toThrow(/topic must be cn\.samien\.yaoyao\.hermes/)
  82. const { privateKey } = generateKeyPairSync('rsa', {
  83. modulusLength: 2048,
  84. privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
  85. publicKeyEncoding: { type: 'spki', format: 'pem' },
  86. })
  87. const invalid = join(home, 'not-ec.p8')
  88. writeFileSync(invalid, privateKey, { mode: 0o600 })
  89. expect(() => validateAPNsConfiguration(input(invalid))).toThrow(/ES256/)
  90. expect(() => validateAPNsConfiguration(input(join(home, 'missing.p8')))).toThrow(/cannot read/)
  91. const fifo = join(home, 'named-pipe.p8')
  92. execFileSync('mkfifo', [fifo])
  93. expect(() => validateAPNsConfiguration(input(fifo))).toThrow(/private key file/)
  94. const configurationHome = temporaryRoot('yaoyao-apns-config-fifo')
  95. mkdirSync(join(configurationHome, 'push'), { recursive: true })
  96. execFileSync('mkfifo', [apnsConfigurationPath(configurationHome)])
  97. expect(loadAPNsConfiguration(configurationHome, {})).toMatchObject({
  98. source: 'file', editable: true, configurationError: 'APNs configuration path is not a regular file',
  99. })
  100. })
  101. it('gives any APNs environment variable priority over a valid Web file', async () => {
  102. const home = temporaryRoot('yaoyao-apns-env-priority')
  103. const keyFile = join(home, 'AuthKey_TEST.p8')
  104. ecKey(keyFile)
  105. const manager = new APNsConfigurationManager(home, { source: 'none', editable: true, warnings: [] }, {
  106. probe: async () => undefined,
  107. })
  108. await manager.update(input(keyFile), () => undefined)
  109. const loaded = loadAPNsConfiguration(home, { HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes' })
  110. expect(loaded).toMatchObject({ source: 'environment', editable: false })
  111. expect(loaded.config).toBeUndefined()
  112. expect(loaded.configurationError).toMatch(/absolute local path/)
  113. })
  114. it('keeps the previous file and runtime when a replacement probe fails', async () => {
  115. const home = temporaryRoot('yaoyao-apns-rollback')
  116. const keyFile = join(home, 'AuthKey_TEST.p8')
  117. ecKey(keyFile)
  118. let reject = false
  119. const manager = new APNsConfigurationManager(home, { source: 'none', editable: true, warnings: [] }, {
  120. probe: async () => {
  121. if (reject) throw new Error('probe rejected')
  122. },
  123. })
  124. const apply = vi.fn()
  125. await manager.update(input(keyFile), apply)
  126. const before = readFileSync(apnsConfigurationPath(home))
  127. reject = true
  128. await expect(manager.update({ ...input(keyFile), keyId: 'REPLACED01' }, apply)).rejects.toThrow('probe rejected')
  129. expect(readFileSync(apnsConfigurationPath(home))).toEqual(before)
  130. expect(manager.snapshot().input?.keyId).toBe('KEY1234567')
  131. expect(apply).toHaveBeenCalledTimes(1)
  132. })
  133. it('refuses Web changes when environment variables own the configuration', async () => {
  134. const home = temporaryRoot('yaoyao-apns-managed')
  135. const keyFile = join(home, 'AuthKey_TEST.p8')
  136. ecKey(keyFile)
  137. const initial = loadAPNsConfiguration(home, {
  138. HERMES_YAOYAO_APNS_KEY_FILE: keyFile,
  139. HERMES_YAOYAO_APNS_KEY_ID: 'KEY1234567',
  140. HERMES_YAOYAO_APNS_TEAM_ID: 'TEAM123456',
  141. HERMES_YAOYAO_APNS_TOPIC: 'cn.samien.yaoyao.hermes',
  142. })
  143. const manager = new APNsConfigurationManager(home, initial, { probe: async () => undefined })
  144. await expect(manager.update(input(keyFile), () => undefined)).rejects.toMatchObject({
  145. status: 409,
  146. code: 'apns_environment_managed',
  147. })
  148. })
  149. })