migration.spec.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. // One-shot boot upgrade of the pre-release flat layout: a key stored by an
  2. // earlier build must survive the versioned-document change without a hand
  3. // edit, byte for byte, while everything the recognizer cannot prove flat
  4. // keeps the loud rejection local.spec exercises.
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  11. import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
  12. import { LocalCredentialProvider, renderFlatLayoutMigration } from '../src/index.ts'
  13. /** Credential documents are seeded owner-only, exactly as the provider creates them. */
  14. function writeCredentials(file: string, text: string): Promise<void> {
  15. return writeFileAtomic(file, text, { mode: 0o600, dirMode: 0o700 })
  16. }
  17. const cleanups: Array<() => Promise<void>> = []
  18. afterEach(async () => {
  19. while (cleanups.length > 0) await cleanups.pop()!()
  20. })
  21. async function tempDir(): Promise<string> {
  22. const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-migration-'))
  23. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  24. return dir
  25. }
  26. async function boot(config: ConstructorParameters<typeof LocalCredentialProvider>[1]): Promise<Context> {
  27. const ctx = new Context()
  28. const fiber = ctx.plugin(LocalCredentialProvider, config)
  29. cleanups.push(async () => { await fiber.dispose() })
  30. await fiber
  31. return ctx
  32. }
  33. // Every spelling an earlier build accepted: plain, quoted, block-scalar, a
  34. // comment header, an interior blank line, and a key that happens to spell a
  35. // section name of the versioned layout.
  36. const FLAT = [
  37. '# keys stored before the versioned layout',
  38. 'DSH_CRED_TEST: stored',
  39. '',
  40. '# annotates the quoted entry',
  41. "DSH_CRED_OTHER: 'quoted value'",
  42. 'DSH_CRED_BLOCK: |',
  43. ' first line',
  44. ' second line',
  45. 'records: tricky',
  46. ].join('\n') + '\n'
  47. const MIGRATED = [
  48. 'version: 1',
  49. 'refs:',
  50. ' # keys stored before the versioned layout',
  51. ' DSH_CRED_TEST: stored',
  52. '',
  53. ' # annotates the quoted entry',
  54. " DSH_CRED_OTHER: 'quoted value'",
  55. ' DSH_CRED_BLOCK: |',
  56. ' first line',
  57. ' second line',
  58. ' records: tricky',
  59. ].join('\n') + '\n'
  60. describe('flat-layout boot migration', () => {
  61. it('upgrades the flat document in place, byte for byte, and serves its keys', async () => {
  62. const dir = await tempDir()
  63. const path = join(dir, '.credentials.yaml')
  64. await writeCredentials(path, FLAT)
  65. const ctx = await boot({ path, watch: false })
  66. expect(await readFile(path, 'utf8')).toBe(MIGRATED)
  67. if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
  68. expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toEqual({ value: 'stored', source: 'file' })
  69. expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_OTHER')))
  70. .toEqual({ value: 'quoted value', source: 'file' })
  71. expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_BLOCK')))
  72. .toEqual({ value: 'first line\nsecond line\n', source: 'file' })
  73. expect(await ctx.credentials.resolve(credentialRef('records'))).toEqual({ value: 'tricky', source: 'file' })
  74. })
  75. it('a second boot reads the migrated document without touching it', async () => {
  76. const dir = await tempDir()
  77. const path = join(dir, '.credentials.yaml')
  78. await writeCredentials(path, FLAT)
  79. await boot({ path, watch: false })
  80. const ctx = await boot({ path, watch: false })
  81. expect(await readFile(path, 'utf8')).toBe(MIGRATED)
  82. expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toEqual({ value: 'stored', source: 'file' })
  83. })
  84. it('yields to a concurrent migrator under the writer lock', async () => {
  85. const dir = await tempDir()
  86. const path = join(dir, '.credentials.yaml')
  87. await writeCredentials(path, FLAT)
  88. const winner = 'version: 1\nrefs:\n DSH_CRED_TEST: winner\n'
  89. let release!: () => void
  90. const held = new Promise<void>((resolve) => { release = resolve })
  91. let acquired!: () => void
  92. const holding = new Promise<void>((resolve) => { acquired = resolve })
  93. const holder = withFileLock(path, async () => {
  94. acquired()
  95. await held
  96. })
  97. await holding
  98. // The boot sees the flat text, then waits for the lock; the "other
  99. // process" completes the migration in the meantime.
  100. const booting = boot({ path, watch: false })
  101. await writeCredentials(path, winner)
  102. release()
  103. await holder
  104. const ctx = await booting
  105. expect(await readFile(path, 'utf8')).toBe(winner)
  106. expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toEqual({ value: 'winner', source: 'file' })
  107. })
  108. it('leaves an empty flow mapping alone', async () => {
  109. const dir = await tempDir()
  110. const path = join(dir, '.credentials.yaml')
  111. await writeCredentials(path, '{}\n')
  112. const ctx = await boot({ path, watch: false })
  113. expect(await readFile(path, 'utf8')).toBe('{}\n')
  114. expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toBeUndefined()
  115. })
  116. it('leaves a comment-only document alone', async () => {
  117. const dir = await tempDir()
  118. const path = join(dir, '.credentials.yaml')
  119. await writeCredentials(path, '# nothing stored yet\n')
  120. const ctx = await boot({ path, watch: false })
  121. expect(await readFile(path, 'utf8')).toBe('# nothing stored yet\n')
  122. expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toBeUndefined()
  123. })
  124. it('renders a final newline for a document that lacks one', () => {
  125. expect(renderFlatLayoutMigration('DSH_CRED_TEST: bare')).toBe('version: 1\nrefs:\n DSH_CRED_TEST: bare\n')
  126. })
  127. })