local.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join, resolve } from 'node:path'
  6. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  7. import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'
  8. import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
  9. import { LocalCredentialProvider, resolveSpec } from '../src/index.ts'
  10. function writeCredentials(file: string, text: string): Promise<void> {
  11. return writeFile(file, text, { mode: 0o600 })
  12. }
  13. const KEY = credentialRef('DSH_CRED_TEST')
  14. const OTHER = credentialRef('DSH_CRED_OTHER')
  15. const cleanups: Array<() => Promise<void>> = []
  16. afterEach(async () => {
  17. vi.unstubAllEnvs()
  18. while (cleanups.length > 0) await cleanups.pop()!()
  19. })
  20. async function tempDir(): Promise<string> {
  21. const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-local-'))
  22. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  23. return dir
  24. }
  25. async function boot(config: ConstructorParameters<typeof LocalCredentialProvider>[1]): Promise<Context> {
  26. const ctx = new Context()
  27. const fiber = ctx.plugin(LocalCredentialProvider, config)
  28. cleanups.push(async () => {
  29. await fiber.dispose()
  30. })
  31. await fiber
  32. return ctx
  33. }
  34. function updates(ctx: Context): CredentialRef[] {
  35. const seen: CredentialRef[] = []
  36. ctx.on('credentials/reference-updated', (ref) => {
  37. seen.push(ref)
  38. })
  39. return seen
  40. }
  41. describe('resolveSpec', () => {
  42. it('defaults to .credentials.yaml under the harness home with watching on', () => {
  43. const spec = resolveSpec({ dshHome: '/custom/home' })
  44. expect(spec).toEqual({ filename: resolve('/custom/home/.credentials.yaml'), watch: true, debounceMs: 100 })
  45. })
  46. it('lets an explicit path win over the home', () => {
  47. const spec = resolveSpec({ path: '/etc/dsh/creds.yaml', dshHome: '/ignored', watch: false, debounceMs: 5 })
  48. expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.yaml'), watch: false, debounceMs: 5 })
  49. })
  50. })
  51. describe('layering and reads', () => {
  52. it('treats an absent file as an empty writable store', async () => {
  53. const dir = await tempDir()
  54. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  55. expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
  56. expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
  57. })
  58. it('serves file entries alongside comments and quoted values', async () => {
  59. const dir = await tempDir()
  60. const path = join(dir, '.credentials.yaml')
  61. await writeCredentials(path, 'version: 1\nrefs:\n # notes\n DSH_CRED_TEST: plain\n DSH_CRED_OTHER: "with space"\n')
  62. const ctx = await boot({ path, watch: false })
  63. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
  64. expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
  65. expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
  66. })
  67. it('lets a non-empty process environment win read-only over the file', async () => {
  68. const dir = await tempDir()
  69. const path = join(dir, '.credentials.yaml')
  70. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: from-file\n')
  71. const ctx = await boot({ path, watch: false })
  72. vi.stubEnv('DSH_CRED_TEST', 'from-env')
  73. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
  74. expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
  75. })
  76. it('treats an empty environment value as absent, falling through to the file', async () => {
  77. const dir = await tempDir()
  78. const path = join(dir, '.credentials.yaml')
  79. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: stored\n')
  80. const ctx = await boot({ path, watch: false })
  81. vi.stubEnv('DSH_CRED_TEST', '')
  82. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
  83. expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
  84. })
  85. it('fails boot loud when the document exists but cannot be read', async () => {
  86. const dir = await tempDir()
  87. const path = join(dir, 'occupied')
  88. await mkdir(path)
  89. const ctx = new Context()
  90. await expect(ctx.plugin(LocalCredentialProvider, { path, watch: false })).rejects.toThrow()
  91. })
  92. })
  93. describe('layer ladder', () => {
  94. // inherited process env > .credentials.yaml > $DSH_HOME/.env, and the
  95. // invoking directory's .env supplies no credential at all.
  96. async function bootLayered(
  97. path: string,
  98. layers: Parameters<typeof createLaunchEnvironmentSnapshot>[0],
  99. ): Promise<Context> {
  100. const ctx = new Context()
  101. ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot(layers))
  102. const fiber = ctx.plugin(LocalCredentialProvider, { path, watch: false })
  103. cleanups.push(async () => { await fiber.dispose() })
  104. await fiber
  105. return ctx
  106. }
  107. it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => {
  108. const dir = await tempDir()
  109. const path = join(dir, '.credentials.yaml')
  110. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: stored\n')
  111. const ctx = await bootLayered(path, [
  112. { source: 'process', values: {} },
  113. { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } },
  114. ])
  115. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
  116. // A key sitting in the user's .env does not make the stored one
  117. // unwritable.
  118. expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
  119. await expect(ctx.credentials.set(KEY, 'rotated')).resolves.toBeUndefined()
  120. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'rotated', source: 'file' })
  121. })
  122. it('serves the user .env only when nothing is stored', async () => {
  123. const dir = await tempDir()
  124. const ctx = await bootLayered(join(dir, '.credentials.yaml'), [
  125. { source: 'process', values: {} },
  126. { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
  127. ])
  128. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-user-env', source: 'user-env' })
  129. // Writable: storing a key replaces it as the effective one.
  130. expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'user-env', writable: true })
  131. })
  132. it('serves the invoking project .env over the user one, but never over the store', async () => {
  133. const dir = await tempDir()
  134. const path = join(dir, '.credentials.yaml')
  135. // The product trusts the project it is launched in, so a checkout may
  136. // carry its own key — ranked above the user's home file (more specific
  137. // wins) and below the managed store, which a stored key must never lose to.
  138. const layers = [
  139. { source: 'process' as const, values: {} },
  140. { source: 'project-env' as const, path: '/work/.env', values: { DSH_CRED_TEST: 'from-project' } },
  141. { source: 'user-env' as const, path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user' } },
  142. ]
  143. const bare = await bootLayered(path, layers)
  144. expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' })
  145. expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true })
  146. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: stored\n')
  147. const stored = await bootLayered(path, layers)
  148. expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
  149. })
  150. it.skipIf(process.platform === 'win32')('refuses a document other OS users can read', async () => {
  151. const dir = await tempDir()
  152. const path = join(dir, '.credentials.yaml')
  153. await writeFile(path, 'version: 1\nrefs:\n DSH_CRED_TEST: leaked\n', { mode: 0o644 })
  154. const ctx = new Context()
  155. // Before the contents are read at all: serving secrets out of a
  156. // world-readable file would make the 0600 the provider writes meaningless.
  157. await expect(ctx.plugin(LocalCredentialProvider, { path, watch: false }))
  158. .rejects.toThrow(/readable beyond its owner \(mode 644\)/)
  159. })
  160. it('propagates a permission check that fails for a reason other than absence', async () => {
  161. const dir = await tempDir()
  162. const notADirectory = join(dir, 'occupied')
  163. await writeFile(notADirectory, 'a regular file\n')
  164. // An absent document is an empty store, but a path that cannot be
  165. // reached at all is a misconfiguration: the parent is a file, so the
  166. // check fails with ENOTDIR rather than concluding "no credentials yet".
  167. const ctx = new Context()
  168. await expect(ctx.plugin(LocalCredentialProvider, { path: join(notADirectory, '.credentials.yaml'), watch: false }))
  169. .rejects.toThrow(/ENOTDIR/)
  170. })
  171. it('propagates a permission check rejected before the OS lookup', async () => {
  172. const dir = await tempDir()
  173. const ctx = new Context()
  174. await expect(ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials\0.yaml'), watch: false }))
  175. .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
  176. })
  177. it('propagates a read that fails for a reason other than absence', async () => {
  178. const dir = await tempDir()
  179. const path = join(dir, '.credentials.yaml')
  180. // Owner-only, so the permission check passes, and unreadable as a file:
  181. // the store is present but cannot be parsed, which must fail the launch
  182. // rather than silently serve nothing.
  183. await mkdir(path, { mode: 0o700 })
  184. const ctx = new Context()
  185. await expect(ctx.plugin(LocalCredentialProvider, { path, watch: false })).rejects.toThrow(/EISDIR/)
  186. })
  187. it('lets only the inherited environment shadow the store, read-only', async () => {
  188. const dir = await tempDir()
  189. const path = join(dir, '.credentials.yaml')
  190. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: stored\n')
  191. const ctx = await bootLayered(path, [
  192. { source: 'process', values: { DSH_CRED_TEST: 'from-shell' } },
  193. { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
  194. ])
  195. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-shell', source: 'env' })
  196. expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
  197. await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/launching environment/)
  198. })
  199. })
  200. describe('document validation', () => {
  201. // Every rejection below is a boot failure rather than a skipped entry: this
  202. // document holds nothing but credentials, so an ignored key would read as
  203. // "the secret I stored has no effect".
  204. it.each([
  205. ['a non-mapping root', 'just a string\n', /must be a mapping/],
  206. ['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/],
  207. // A flat document the boot migration cannot prove it understands is
  208. // refused by name rather than read as an empty store or rewritten: a
  209. // silently ignored document would surface as an authentication failure on
  210. // the first request instead of at load. (The recognized all-string flat
  211. // layout upgrades in place instead — migration.spec owns that path.)
  212. ['the flat layout with a non-string value', 'DSH_CRED_TEST: [nope]\n', /nest the existing 1 entry under/],
  213. ['the flat layout with several entries and a non-string value', 'DSH_CRED_TEST: a\nDSH_CRED_OTHER: [b]\n',
  214. /nest the existing 2 entries under/],
  215. ['the flat layout with an empty value', 'DSH_CRED_TEST: ""\n', /pre-release flat layout/],
  216. ['the flat layout with an unaddressable key', 'not-a-ref: value\n', /pre-release flat layout/],
  217. ['the flat layout with a non-string key', '1: a\n', /pre-release flat layout/],
  218. ['the flat layout under document directives', '%YAML 1.2\n---\nDSH_CRED_TEST: a\n',
  219. /pre-release flat layout/],
  220. ['a future version', 'version: 2\nrefs: {}\n', /this build reads version 1/],
  221. ['an unknown top-level key', 'version: 1\nsecrets: {}\n', /unknown top-level key "secrets"/],
  222. ['a non-mapping refs section', 'version: 1\nrefs: nope\n', /"refs" .* must be a mapping/],
  223. ['a key that is not a POSIX identifier', 'version: 1\nrefs:\n not-a-ref: value\n', /credential ref/],
  224. ['a non-string value', 'version: 1\nrefs:\n DSH_CRED_TEST: 123\n', /must be a string/],
  225. ['an empty value', 'version: 1\nrefs:\n DSH_CRED_TEST: ""\n', /is empty/],
  226. ['a record key that is not scoped', 'version: 1\nrecords:\n codex:\n kind: grant\n payload: 1\n',
  227. /must be "<scope>\/<id>"/],
  228. ['a record that is not a mapping', 'version: 1\nrecords:\n llm-pi-ai/codex: token\n',
  229. /record "llm-pi-ai\/codex" .* must be a mapping/],
  230. ['a record with no kind', 'version: 1\nrecords:\n llm-pi-ai/codex:\n payload: 1\n', /has no kind/],
  231. ['a record with an unknown kind', 'version: 1\nrecords:\n llm-pi-ai/codex:\n kind: token\n',
  232. /unknown kind "token"/],
  233. ['a record with an unknown field', 'version: 1\nrecords:\n llm-pi-ai/codex:\n kind: grant\n'
  234. + ' payload: 1\n extra: 2\n', /unknown field "extra"/],
  235. ['a grant with no payload', 'version: 1\nrecords:\n llm-pi-ai/codex:\n kind: grant\n', /has no payload/],
  236. // YAML spells values JSON has none for. The seam promises an owner its
  237. // payload comes back exactly as written, which a lossy round trip breaks.
  238. ['a non-finite payload number', 'version: 1\nrecords:\n llm-pi-ai/codex:\n kind: grant\n'
  239. + ' payload:\n ratio: .inf\n', /non-finite number/],
  240. ['a cyclic payload', 'version: 1\nrecords:\n llm-pi-ai/codex:\n kind: grant\n'
  241. + ' payload: &loop\n self: *loop\n', /is cyclic/],
  242. ['an api-key record with an empty key', 'version: 1\nrecords:\n llm-pi-ai/acme:\n kind: api-key\n'
  243. + ' key: ""\n', /non-string or empty key/],
  244. ['an api-key record env that is not a mapping', 'version: 1\nrecords:\n llm-pi-ai/acme:\n kind: api-key\n'
  245. + ' env: nope\n', /non-mapping env/],
  246. ['an api-key record env value that is empty', 'version: 1\nrecords:\n llm-pi-ai/acme:\n kind: api-key\n'
  247. + ' env:\n AWS_PROFILE: ""\n', /env "AWS_PROFILE" .* must be a non-empty string/],
  248. ['duplicate keys', 'version: 1\nrefs:\n DSH_CRED_TEST: one\n DSH_CRED_TEST: two\n', /invalid document/],
  249. ['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/],
  250. ])('fails boot on %s', async (_case, text, message) => {
  251. const dir = await tempDir()
  252. const path = join(dir, '.credentials.yaml')
  253. await writeCredentials(path, text)
  254. const ctx = new Context()
  255. await expect(ctx.plugin(LocalCredentialProvider, { path, watch: false })).rejects.toThrow(message)
  256. })
  257. it('never puts a credential value in a diagnostic', async () => {
  258. const dir = await tempDir()
  259. const path = join(dir, '.credentials.yaml')
  260. const secret = 'sk-live-DO-NOT-LOG-abcdef123456'
  261. // The yaml parser's own message quotes the offending source line, which in
  262. // this document is the secret itself. Boot stderr and the watcher's logger
  263. // both receive whatever this throws.
  264. await writeCredentials(path, `DSH_CRED_TEST: "${secret}\n`)
  265. let failure: unknown
  266. try {
  267. await new Context().plugin(LocalCredentialProvider, { path, watch: false })
  268. } catch (error) {
  269. failure = error
  270. }
  271. expect(String(failure)).toMatch(/invalid document/)
  272. // The position survives; the line's contents do not.
  273. expect(String(failure)).toMatch(/line 2, column 1/)
  274. expect(String(failure)).not.toContain(secret)
  275. expect((failure as Error).stack ?? '').not.toContain(secret)
  276. })
  277. it('reads an empty document as an empty store', async () => {
  278. const dir = await tempDir()
  279. const path = join(dir, '.credentials.yaml')
  280. await writeCredentials(path, '# nothing stored yet\n')
  281. const ctx = await boot({ path, watch: false })
  282. expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
  283. })
  284. })
  285. describe('document writes', () => {
  286. it('adds a missing key to a fresh 0600 document and emits the commit', async () => {
  287. const dir = await tempDir()
  288. const path = join(dir, '.credentials.yaml')
  289. const ctx = await boot({ path, watch: false })
  290. const seen = updates(ctx)
  291. await ctx.credentials.set(KEY, 'sk-fresh')
  292. expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n DSH_CRED_TEST: sk-fresh\n')
  293. if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
  294. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
  295. expect(seen).toEqual([KEY])
  296. })
  297. it('patches one entry, preserving comments and every untouched entry', async () => {
  298. const dir = await tempDir()
  299. const path = join(dir, '.credentials.yaml')
  300. await writeCredentials(path, 'version: 1\nrefs:\n # deployment notes\n DSH_CRED_OTHER: keep\n\n # the one under edit\n DSH_CRED_TEST: old\n')
  301. const ctx = await boot({ path, watch: false })
  302. await ctx.credentials.set(KEY, 'new value!')
  303. expect(await readFile(path, 'utf8')).toBe(
  304. 'version: 1\nrefs:\n # deployment notes\n DSH_CRED_OTHER: keep\n\n'
  305. + ' # the one under edit\n DSH_CRED_TEST: new value!\n',
  306. )
  307. })
  308. it('round-trips values no dotenv line could represent', async () => {
  309. const dir = await tempDir()
  310. const path = join(dir, '.credentials.yaml')
  311. const ctx = await boot({ path, watch: false })
  312. const multiLine = 'line one\nline two'
  313. const mixedQuotes = 'both \' and "'
  314. await ctx.credentials.set(KEY, multiLine)
  315. await ctx.credentials.set(OTHER, mixedQuotes)
  316. const reread = await boot({ path, watch: false })
  317. expect(await reread.credentials.resolve(KEY)).toEqual({ value: multiLine, source: 'file' })
  318. expect(await reread.credentials.resolve(OTHER)).toEqual({ value: mixedQuotes, source: 'file' })
  319. expect(await reread.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
  320. })
  321. it('unsets only the owning entry, with its own annotation, and keeps an absent unset silent', async () => {
  322. const dir = await tempDir()
  323. const path = join(dir, '.credentials.yaml')
  324. // Comments above an entry are that entry's annotation and go with it when
  325. // it is removed — including anything above the document's first entry.
  326. // Every other entry keeps its own comments.
  327. await writeCredentials(path, 'version: 1\nrefs:\n # about the doomed one\n DSH_CRED_TEST: gone\n # about the survivor\n DSH_CRED_OTHER: stays\n')
  328. const ctx = await boot({ path, watch: false })
  329. const seen = updates(ctx)
  330. await ctx.credentials.unset(KEY)
  331. expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n # about the survivor\n DSH_CRED_OTHER: stays\n')
  332. await ctx.credentials.unset(KEY)
  333. expect(seen).toEqual([KEY])
  334. })
  335. it('rejects empty values and writes the environment would shadow', async () => {
  336. const dir = await tempDir()
  337. const path = join(dir, '.credentials.yaml')
  338. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: stored\n')
  339. const ctx = await boot({ path, watch: false })
  340. await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
  341. vi.stubEnv('DSH_CRED_TEST', 'shadowing')
  342. await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/)
  343. await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/)
  344. })
  345. it('leaves an empty mapping after unsetting the only entry', async () => {
  346. const dir = await tempDir()
  347. const path = join(dir, '.credentials.yaml')
  348. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: only\n')
  349. const ctx = await boot({ path, watch: false })
  350. await ctx.credentials.unset(KEY)
  351. expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs: {}\n')
  352. // The emptied document still reloads as an empty store, not a parse error.
  353. const reread = await boot({ path, watch: false })
  354. expect(await reread.credentials.resolve(KEY)).toBeUndefined()
  355. })
  356. it('fails a write loud when the on-disk document became invalid', async () => {
  357. const dir = await tempDir()
  358. const path = join(dir, '.credentials.yaml')
  359. const ctx = await boot({ path, watch: false })
  360. // An external editor left the document unparsable: the read-modify-write
  361. // must refuse rather than overwrite content it cannot understand.
  362. await writeCredentials(path, 'DSH_CRED_TEST: "unterminated\n')
  363. await expect(ctx.credentials.set(OTHER, 'lands')).rejects.toThrow(/invalid document/)
  364. })
  365. it('chains past a rejected write so one bad value cannot poison the queue', async () => {
  366. const dir = await tempDir()
  367. const path = join(dir, '.credentials.yaml')
  368. const ctx = await boot({ path, watch: false })
  369. const bad = expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
  370. const good = ctx.credentials.set(OTHER, 'lands')
  371. await bad
  372. await good
  373. expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n DSH_CRED_OTHER: lands\n')
  374. })
  375. it('serializes concurrent writes so both land in the one document', async () => {
  376. const dir = await tempDir()
  377. const path = join(dir, '.credentials.yaml')
  378. const ctx = await boot({ path, watch: false })
  379. await Promise.all([
  380. ctx.credentials.set(KEY, 'one'),
  381. ctx.credentials.set(OTHER, 'two'),
  382. ])
  383. expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n DSH_CRED_TEST: one\n DSH_CRED_OTHER: two\n')
  384. })
  385. it('refuses writes after disposal', async () => {
  386. const dir = await tempDir()
  387. const ctx = new Context()
  388. const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
  389. await fiber
  390. // Capture the handle first: disposal also removes the ctx.credentials service.
  391. const service = ctx.credentials
  392. await fiber.dispose()
  393. await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/)
  394. })
  395. })
  396. describe('real hot reload', () => {
  397. it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => {
  398. const dir = await tempDir()
  399. const path = join(dir, '.credentials.yaml')
  400. // Watching starts on an existing document: creation racing watcher setup
  401. // is a chokidar readiness gap, not the reload contract under test.
  402. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: boot\n')
  403. const ctx = await boot({ path, debounceMs: 10 })
  404. const seen = updates(ctx)
  405. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: live\n DSH_CRED_OTHER: extra\n')
  406. await vi.waitFor(async () => {
  407. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
  408. })
  409. // Wholesale replacement: an entry deleted on disk never lingers in memory.
  410. await writeCredentials(path, 'version: 1\nrefs:\n DSH_CRED_TEST: live\n')
  411. await vi.waitFor(async () => {
  412. expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
  413. })
  414. const before = seen.length
  415. await ctx.credentials.set(KEY, 'self-written')
  416. await new Promise(resolvePause => setTimeout(resolvePause, 200))
  417. // Exactly the committed write's own event: the watcher echo of our own
  418. // content is recognized by the text cache and publishes nothing extra.
  419. expect(seen.length).toBe(before + 1)
  420. expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' })
  421. })
  422. })