Files
deepseek-harness/packages/credentials/credentials-local/tests/drain.spec.ts
T
Yichen Jiang 86a9f8c862 feat(credentials): store durable credential records beside references
The seam answered one question — what is behind this environment-variable
name — and that shape cannot hold what an authorization grant is: a
multi-field, rotating value keyed by a provider id rather than by a POSIX
identifier. The Models page already works around the gap by inventing a
synthetic environment name (`MINIMAX_CN_API_KEY`) for a route the user added
by hand, because the store's key must look like one.

`CredentialKey` is `<scope>/<id>`, where the scope is the owning plugin's
registered name. The owner is in the key because a `grant` payload is written
in its owner's format: two plugins serving the same provider name would
otherwise read each other's payload, and a record left by an uninstalled
plugin could not be told from a live one. The `/` also keeps the grammar
disjoint from `CredentialRef`, so the key spaces cannot collide.

`CredentialRecord` is `api-key` (key and/or provider environment values) or
`grant` (an opaque, owner-owned payload). The asymmetry is deliberate: an api
key is the harness's own data, a grant is a package it carries for someone
else. `modifyRecord` is the only write path because a correct write depends
on the current value — a token refresh is read-decide-replace under one
cross-process lock, without which two processes rotating one refresh token
lose whichever wrote first.

`.credentials.yaml` becomes a versioned two-section document. The pre-release
flat layout is refused by name, with the entry count and the one edit needed,
rather than read as an empty store — which would surface as an authentication
failure on the first request instead of at load. A grant payload is admitted
in both directions, so a value the document could not read back exactly as
written is refused rather than stored lossily.
2026-08-20 17:58:38 +08:00

102 lines
4.3 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
import { LocalCredentialProvider } from '../src/index.ts'
// The atomic write is the gated asynchronous hold point inside a queued
// write; gating it makes the dispose-versus-queued-write race fully
// deterministic. The lock helper passes through so the gated operation still
// runs inside its real acquire/release cycle.
vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-atomic-write')>()
let gate: Promise<void> = Promise.resolve()
return {
...actual,
writeFileAtomic: vi.fn(() => gate),
__setGate: (next: Promise<void>) => {
gate = next
},
}
})
async function setGate(next: Promise<void>): Promise<void> {
const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise<void>) => void }
mocked.__setGate(next)
}
const KEY = credentialRef('DSH_CRED_DRAIN_A')
const OTHER = credentialRef('DSH_CRED_DRAIN_B')
const RECORD = credentialKey('llm-drain', 'alpha')
const OTHER_RECORD = credentialKey('llm-drain', 'beta')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
await setGate(Promise.resolve())
while (cleanups.length > 0) await cleanups.pop()!()
})
describe('write-drain teardown', () => {
it('lets the in-flight write land and fails the queued one after disposal', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const ctx = new Context()
const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
await fiber
const service = ctx.credentials
let release!: () => void
await setGate(new Promise<void>((resolveGate) => {
release = resolveGate
}))
const first = service.set(KEY, 'one')
// Let the first task pass its liveness checks and park on the gate, so it
// is genuinely in-flight when disposal begins.
await new Promise(resolvePause => setTimeout(resolvePause, 5))
// Attach the rejection handler up front: the queued write fails while the
// drain is still awaited, before any later `await expect` could run.
const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/)
const disposal = fiber.dispose()
// Give the drain disposer its first turn (set closed) before opening the gate.
await new Promise(resolvePause => setTimeout(resolvePause, 10))
release()
await disposal
await expect(first).resolves.toBeUndefined()
await secondRejects
expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' })
expect(await service.resolve(OTHER)).toBeUndefined()
})
it('fails a queued record write after disposal on the same terms', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-record-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const ctx = new Context()
const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
await fiber
const service = ctx.credentials
let release!: () => void
await setGate(new Promise<void>((resolveGate) => {
release = resolveGate
}))
const first = service.modifyRecord(RECORD, () => Promise.resolve({ kind: 'grant', payload: { v: 1 } }))
await new Promise(resolvePause => setTimeout(resolvePause, 5))
const queuedModify = expect(service.modifyRecord(OTHER_RECORD, () => Promise.resolve({ kind: 'api-key' })))
.rejects.toThrow(/disposed before the queued/)
const queuedDelete = expect(service.deleteRecord(OTHER_RECORD)).rejects.toThrow(/disposed before the queued/)
const disposal = fiber.dispose()
await new Promise(resolvePause => setTimeout(resolvePause, 10))
release()
await disposal
await expect(first).resolves.toEqual({ kind: 'grant', payload: { v: 1 } })
await queuedModify
await queuedDelete
expect(await service.readRecord(OTHER_RECORD)).toBeUndefined()
})
})