records.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. // The record half of the seam: the store keeps an owner's payload verbatim,
  2. // presence rather than content answers "configured", and every write goes
  3. // through one serialized read-modify-write so a rotating credential cannot be
  4. // lost between processes.
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  8. import { tmpdir } from 'node:os'
  9. import { join } from 'node:path'
  10. import { credentialKey, credentialKeyScope, credentialRef, parseCredentialKey } from '@deepseek-ai/dsh-credentials'
  11. import type { CredentialKey, CredentialRecord } from '@deepseek-ai/dsh-credentials'
  12. import { LocalCredentialProvider } 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 writeFile(file, text, { mode: 0o600 })
  16. }
  17. const CODEX = credentialKey('llm-pi-ai', 'openai-codex')
  18. const BEDROCK = credentialKey('llm-pi-ai', 'amazon-bedrock')
  19. const OTHER_OWNER = credentialKey('llm-kimi', 'openai-codex')
  20. const cleanups: Array<() => Promise<void>> = []
  21. afterEach(async () => {
  22. while (cleanups.length > 0) await cleanups.pop()!()
  23. })
  24. async function tempDir(): Promise<string> {
  25. const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-records-'))
  26. cleanups.push(() => rm(dir, { recursive: true, force: true }))
  27. return dir
  28. }
  29. async function boot(config: ConstructorParameters<typeof LocalCredentialProvider>[1]): Promise<Context> {
  30. const ctx = new Context()
  31. const fiber = ctx.plugin(LocalCredentialProvider, config)
  32. cleanups.push(async () => { await fiber.dispose() })
  33. await fiber
  34. return ctx
  35. }
  36. /** Store one record outright; the seam offers only the read-modify-write path. */
  37. function put(ctx: Context, key: CredentialKey, record: CredentialRecord): Promise<CredentialRecord | undefined> {
  38. return ctx.credentials.modifyRecord(key, () => Promise.resolve(record))
  39. }
  40. function recordUpdates(ctx: Context): CredentialKey[] {
  41. const seen: CredentialKey[] = []
  42. ctx.on('credentials/record-updated', (key) => { seen.push(key) })
  43. return seen
  44. }
  45. describe('credential keys', () => {
  46. it('rejects a segment that is not a lowercase hyphenated identifier', () => {
  47. expect(() => credentialKey('llm-pi-ai', 'OpenAI')).toThrow(/credential key segment/)
  48. expect(() => credentialKey('', 'codex')).toThrow(/credential key segment/)
  49. })
  50. it('stays disjoint from the reference grammar', () => {
  51. // The `/` is what makes the two key spaces incapable of colliding, so a
  52. // record address can never be mistaken for an environment-variable name.
  53. expect(() => credentialRef(CODEX)).toThrow(/credential ref/)
  54. })
  55. it('reads back the owning plugin, which is what makes an orphan recognizable', () => {
  56. expect(credentialKeyScope(CODEX)).toBe('llm-pi-ai')
  57. expect(credentialKeyScope(OTHER_OWNER)).toBe('llm-kimi')
  58. })
  59. it('admits a stored key and refuses one that is not two segments', () => {
  60. expect(parseCredentialKey('llm-pi-ai/openai-codex')).toBe(CODEX)
  61. expect(() => parseCredentialKey('openai-codex')).toThrow(/must be "<scope>\/<id>"/)
  62. expect(() => parseCredentialKey('a/b/c')).toThrow(/must be "<scope>\/<id>"/)
  63. })
  64. })
  65. describe('record storage', () => {
  66. it('returns a grant payload exactly as its owner wrote it', async () => {
  67. const dir = await tempDir()
  68. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  69. // Fields the seam has never heard of ride along: an owner's SDK gains them
  70. // between releases, and a whitelist here would silently eat the new ones.
  71. const payload = { type: 'oauth', access: 'at', refresh: 'rt', expires: 1786000000000, accountId: 'acct_1' }
  72. await put(ctx, CODEX, { kind: 'grant', payload })
  73. expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload })
  74. const reread = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  75. expect(await reread.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload })
  76. })
  77. it('treats a record carrying no key and no environment as configured', async () => {
  78. const dir = await tempDir()
  79. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  80. // The owner confirmed this route authenticates from its own ambient
  81. // discovery. That is a stored decision, not a blank — the opposite reading
  82. // of the empty-value rule the reference half follows.
  83. await put(ctx, BEDROCK, { kind: 'api-key' })
  84. expect(await ctx.credentials.describeRecord(BEDROCK)).toEqual({ configured: true, kind: 'api-key', writable: true })
  85. })
  86. it('describes an absent record as unconfigured but writable', async () => {
  87. const dir = await tempDir()
  88. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  89. expect(await ctx.credentials.describeRecord(CODEX)).toEqual({ configured: false, writable: true })
  90. expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
  91. })
  92. it('stores provider environment values beside or instead of a key', async () => {
  93. const dir = await tempDir()
  94. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  95. await put(ctx, BEDROCK, { kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
  96. expect(await ctx.credentials.readRecord(BEDROCK)).toEqual({ kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
  97. })
  98. it('keeps references and records in one document without either disturbing the other', async () => {
  99. const dir = await tempDir()
  100. const path = join(dir, '.credentials.yaml')
  101. const ctx = await boot({ path, watch: false })
  102. await ctx.credentials.set(credentialRef('DSH_RECORDS_KEY'), 'sk-live')
  103. await put(ctx, CODEX, { kind: 'grant', payload: { token: 't' } })
  104. const text = await readFile(path, 'utf8')
  105. expect(text).toBe(
  106. 'version: 1\nrefs:\n DSH_RECORDS_KEY: sk-live\nrecords:\n'
  107. + ' llm-pi-ai/openai-codex:\n kind: grant\n payload:\n token: t\n',
  108. )
  109. expect(await ctx.credentials.resolve(credentialRef('DSH_RECORDS_KEY'))).toEqual({ value: 'sk-live', source: 'file' })
  110. })
  111. it('reads every record shape back off disk', async () => {
  112. const dir = await tempDir()
  113. const path = join(dir, '.credentials.yaml')
  114. // Written by hand rather than through the API: this is the parse path, and
  115. // an api-key record is legal with a key, with environment values, with
  116. // both, or with neither.
  117. await writeCredentials(path, 'version: 1\nrecords:\n'
  118. + ' llm-pi-ai/openai-codex:\n kind: grant\n payload:\n access: at\n'
  119. + ' llm-pi-ai/amazon-bedrock:\n kind: api-key\n env:\n AWS_PROFILE: prod\n'
  120. + ' llm-pi-ai/azure:\n kind: api-key\n key: sk-azure\n'
  121. + ' llm-pi-ai/both:\n kind: api-key\n key: sk-both\n env:\n REGION: eu\n'
  122. + ' llm-pi-ai/ambient:\n kind: api-key\n')
  123. const ctx = await boot({ path, watch: false })
  124. expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { access: 'at' } })
  125. expect(await ctx.credentials.readRecord(BEDROCK)).toEqual({ kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
  126. expect(await ctx.credentials.readRecord(credentialKey('llm-pi-ai', 'azure')))
  127. .toEqual({ kind: 'api-key', key: 'sk-azure' })
  128. expect(await ctx.credentials.readRecord(credentialKey('llm-pi-ai', 'both')))
  129. .toEqual({ kind: 'api-key', key: 'sk-both', env: { REGION: 'eu' } })
  130. expect(await ctx.credentials.readRecord(credentialKey('llm-pi-ai', 'ambient'))).toEqual({ kind: 'api-key' })
  131. })
  132. it('publishes a record an external edit reshaped, whatever shape it took', async () => {
  133. const dir = await tempDir()
  134. const path = join(dir, '.credentials.yaml')
  135. const ctx = await boot({ path, watch: false })
  136. await put(ctx, CODEX, { kind: 'grant', payload: { scopes: ['a'] } })
  137. const seen = recordUpdates(ctx)
  138. // A sequence where a mapping stood, then a mapping that gained a field:
  139. // neither is caught by an identity check, and reporting them as unchanged
  140. // would leave a stale credential on every configuration surface.
  141. for (const payload of ['[1]', '{a: 1}', '{a: 1, b: 2}']) {
  142. await writeCredentials(path, 'version: 1\nrecords:\n llm-pi-ai/openai-codex:\n'
  143. + ` kind: grant\n payload: ${payload}\n`)
  144. // Any write folds the unobserved document in before committing its own.
  145. await put(ctx, BEDROCK, { kind: 'api-key' })
  146. }
  147. expect(seen.filter(key => key === CODEX)).toHaveLength(3)
  148. expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { a: 1, b: 2 } })
  149. })
  150. it('keeps two owners of the same provider id apart', async () => {
  151. const dir = await tempDir()
  152. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  153. await put(ctx, CODEX, { kind: 'grant', payload: { owner: 'pi-ai' } })
  154. await put(ctx, OTHER_OWNER, { kind: 'grant', payload: { owner: 'kimi' } })
  155. expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { owner: 'pi-ai' } })
  156. expect(await ctx.credentials.readRecord(OTHER_OWNER)).toEqual({ kind: 'grant', payload: { owner: 'kimi' } })
  157. })
  158. })
  159. describe('record mutation', () => {
  160. it('shows the mutation the record as it stands and commits its replacement', async () => {
  161. const dir = await tempDir()
  162. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  163. await put(ctx, CODEX, { kind: 'grant', payload: { expires: 1 } })
  164. const seen: Array<CredentialRecord | undefined> = []
  165. const next = await ctx.credentials.modifyRecord(CODEX, (current) => {
  166. seen.push(current)
  167. return Promise.resolve({ kind: 'grant', payload: { expires: 2 } })
  168. })
  169. expect(seen).toEqual([{ kind: 'grant', payload: { expires: 1 } }])
  170. expect(next).toEqual({ kind: 'grant', payload: { expires: 2 } })
  171. })
  172. it('leaves the entry untouched when the mutation declines', async () => {
  173. const dir = await tempDir()
  174. const path = join(dir, '.credentials.yaml')
  175. const ctx = await boot({ path, watch: false })
  176. await put(ctx, CODEX, { kind: 'grant', payload: { expires: 1 } })
  177. const before = await readFile(path, 'utf8')
  178. const seen = recordUpdates(ctx)
  179. // The refresh path declines whenever a second reader finds the credential
  180. // already rotated; declining must not rewrite the document or announce a
  181. // change that did not happen.
  182. const result = await ctx.credentials.modifyRecord(CODEX, () => Promise.resolve(undefined))
  183. expect(result).toEqual({ kind: 'grant', payload: { expires: 1 } })
  184. expect(await readFile(path, 'utf8')).toBe(before)
  185. expect(seen).toEqual([])
  186. })
  187. it('announces a committed write and a committed delete, and stays silent on an absent delete', async () => {
  188. const dir = await tempDir()
  189. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  190. const seen = recordUpdates(ctx)
  191. await put(ctx, CODEX, { kind: 'grant', payload: 1 })
  192. await ctx.credentials.deleteRecord(CODEX)
  193. await ctx.credentials.deleteRecord(CODEX)
  194. expect(seen).toEqual([CODEX, CODEX])
  195. expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
  196. })
  197. it('removes a later record without disturbing the annotation above the first', async () => {
  198. const dir = await tempDir()
  199. const path = join(dir, '.credentials.yaml')
  200. // The comment sits above the section's first entry, where the parser
  201. // attaches it to the section rather than to the pair. Removing a *later*
  202. // entry must leave it exactly where it is.
  203. await writeCredentials(path, 'version: 1\nrecords:\n # the one to keep\n'
  204. + ' llm-pi-ai/openai-codex:\n kind: grant\n payload: 1\n'
  205. + ' llm-pi-ai/amazon-bedrock:\n kind: api-key\n')
  206. const ctx = await boot({ path, watch: false })
  207. await ctx.credentials.deleteRecord(BEDROCK)
  208. expect(await readFile(path, 'utf8')).toBe('version: 1\nrecords:\n # the one to keep\n'
  209. + ' llm-pi-ai/openai-codex:\n kind: grant\n payload: 1\n')
  210. })
  211. it('folds an unobserved external record edit into a write instead of overwriting it', async () => {
  212. const dir = await tempDir()
  213. const path = join(dir, '.credentials.yaml')
  214. const ctx = await boot({ path, watch: false })
  215. await put(ctx, CODEX, { kind: 'grant', payload: { v: 1 } })
  216. // Landed on disk with no watcher to report it — the same blind spot as a
  217. // debounce window, a missed event, or another process's write.
  218. await writeCredentials(path, 'version: 1\nrecords:\n'
  219. + ' llm-pi-ai/openai-codex:\n kind: grant\n payload:\n v: 1\n'
  220. + ' llm-pi-ai/amazon-bedrock:\n kind: api-key\n env:\n AWS_PROFILE: prod\n')
  221. await put(ctx, CODEX, { kind: 'grant', payload: { v: 2 } })
  222. expect(await ctx.credentials.readRecord(BEDROCK)).toEqual({ kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
  223. expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { v: 2 } })
  224. })
  225. it('keeps both records when two providers write the same document concurrently', async () => {
  226. const dir = await tempDir()
  227. const path = join(dir, '.credentials.yaml')
  228. const first = await boot({ path, watch: false })
  229. const second = await boot({ path, watch: false })
  230. await Promise.all([
  231. (async () => {
  232. for (const v of [1, 2, 3]) await put(first, CODEX, { kind: 'grant', payload: { v } })
  233. })(),
  234. (async () => {
  235. for (const v of [1, 2, 3]) await put(second, BEDROCK, { kind: 'grant', payload: { v } })
  236. })(),
  237. ])
  238. const reread = await boot({ path, watch: false })
  239. expect(await reread.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { v: 3 } })
  240. expect(await reread.credentials.readRecord(BEDROCK)).toEqual({ kind: 'grant', payload: { v: 3 } })
  241. })
  242. it('enumerates stored records by address and tag, never by value', async () => {
  243. const dir = await tempDir()
  244. const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
  245. await put(ctx, CODEX, { kind: 'grant', payload: { secret: 'do-not-list' } })
  246. await put(ctx, BEDROCK, { kind: 'api-key', key: 'sk-listed' })
  247. const listed = await ctx.credentials.listRecords()
  248. expect(listed).toEqual([{ key: CODEX, kind: 'grant' }, { key: BEDROCK, kind: 'api-key' }])
  249. expect(JSON.stringify(listed)).not.toContain('do-not-list')
  250. expect(JSON.stringify(listed)).not.toContain('sk-listed')
  251. })
  252. it('refuses a payload this document could not read back', async () => {
  253. const dir = await tempDir()
  254. const path = join(dir, '.credentials.yaml')
  255. const ctx = await boot({ path, watch: false })
  256. // An owner's SDK value that YAML would either lose or re-read as another
  257. // type. Rejecting on the way in is what keeps the round-trip promise
  258. // keepable; a rejected value must also leave nothing behind.
  259. for (const payload of [{ at: new Date(0) }, { size: 1n }, { run: () => undefined }, { ratio: Number.NaN }]) {
  260. await expect(put(ctx, CODEX, { kind: 'grant', payload }))
  261. .rejects.toThrow(/record "llm-pi-ai\/openai-codex" payload/)
  262. }
  263. expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
  264. await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  265. })
  266. it('refuses an api-key record this document could not read back', async () => {
  267. const dir = await tempDir()
  268. const path = join(dir, '.credentials.yaml')
  269. const ctx = await boot({ path, watch: false })
  270. // The same admission rule as the read path: an api-key record parseRecord
  271. // would reject at the next boot is refused before it is rendered, so the
  272. // current process can never report a success the next one refuses to load.
  273. await expect(put(ctx, CODEX, { kind: 'api-key', key: '' }))
  274. .rejects.toThrow(/empty key/)
  275. await expect(put(ctx, CODEX, { kind: 'api-key', env: { 'not a name': 'value' } }))
  276. .rejects.toThrow(/must match/)
  277. await expect(put(ctx, CODEX, { kind: 'api-key', env: { AWS_REGION: '' } }))
  278. .rejects.toThrow(/non-empty string/)
  279. expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
  280. await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
  281. })
  282. it('refuses record writes once disposed', async () => {
  283. const dir = await tempDir()
  284. const ctx = new Context()
  285. const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
  286. await fiber
  287. const credentials = ctx.credentials
  288. await fiber.dispose()
  289. await expect(credentials.modifyRecord(CODEX, () => Promise.resolve({ kind: 'grant', payload: 1 })))
  290. .rejects.toThrow(/disposed/)
  291. await expect(credentials.deleteRecord(CODEX)).rejects.toThrow(/disposed/)
  292. })
  293. })