describe() now carries each namespace's detached composition base and raw
user section beside the resolved value — presence in the user layer is how
a form marks a field user-overridden — and describe({redactSecrets:true})
strips role('secret') fields from every layer while enumerating their
{path,set} slots, so a wire surface has no slot that can carry a secret.
The pure redactSecrets(schema,value) walker (object/dict/array containers,
secret-role subtree as opaque leaf, inputs never mutated) is exported for
any other wire; the README's no-redaction Known Limitation is discharged.
169 lines
6.9 KiB
TypeScript
169 lines
6.9 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import z from 'schemastery'
|
|
import { redactSecrets, settingsNamespace } from '../src/index.ts'
|
|
import { MemorySettings } from './memory.ts'
|
|
|
|
const Profile = z.object({
|
|
apiKey: z.string().role('secret'),
|
|
apiKeyEnv: z.string().role('credential-ref'),
|
|
baseURL: z.string(),
|
|
})
|
|
|
|
const Adapter: z<object> = z.object({
|
|
apiKey: z.string().role('secret'),
|
|
providers: z.dict(Profile),
|
|
fallbacks: z.array(Profile),
|
|
nested: z.object({
|
|
token: z.string().role('secret'),
|
|
}),
|
|
})
|
|
|
|
describe('redactSecrets', () => {
|
|
it('strips secrets from object, dict, and array containers and records each position', () => {
|
|
const { value, secrets } = redactSecrets(Adapter as z<never>, {
|
|
apiKey: 'top-secret',
|
|
providers: {
|
|
openai: { apiKey: 'sk-live', apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://x' },
|
|
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
|
},
|
|
fallbacks: [{ apiKey: 'fb', baseURL: 'https://y' }],
|
|
nested: {},
|
|
})
|
|
expect(value).toEqual({
|
|
providers: {
|
|
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://x' },
|
|
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
|
},
|
|
fallbacks: [{ baseURL: 'https://y' }],
|
|
nested: {},
|
|
})
|
|
expect(secrets).toEqual([
|
|
{ path: ['apiKey'], set: true },
|
|
{ path: ['providers', 'openai', 'apiKey'], set: true },
|
|
{ path: ['providers', 'anthropic', 'apiKey'], set: false },
|
|
{ path: ['fallbacks', '0', 'apiKey'], set: true },
|
|
{ path: ['nested', 'token'], set: false },
|
|
])
|
|
})
|
|
|
|
it('enumerates unset object-property slots without inventing containers', () => {
|
|
const { value, secrets } = redactSecrets(Adapter as z<never>, undefined)
|
|
expect(value).toBeUndefined()
|
|
expect(secrets).toEqual([
|
|
{ path: ['apiKey'], set: false },
|
|
{ path: ['nested', 'token'], set: false },
|
|
])
|
|
})
|
|
|
|
it('never mutates the input and preserves keys outside the schema', () => {
|
|
const input = Object.freeze({
|
|
apiKey: 'frozen',
|
|
extra: Object.freeze({ keep: true }),
|
|
})
|
|
const { value } = redactSecrets(Adapter as z<never>, input)
|
|
expect(input.apiKey).toBe('frozen')
|
|
expect(value).toEqual({ extra: { keep: true }, nested: undefined } as never)
|
|
expect((value as { extra: unknown }).extra).toEqual({ keep: true })
|
|
})
|
|
|
|
it('passes malformed container values through untouched', () => {
|
|
const { value, secrets } = redactSecrets(Adapter as z<never>, {
|
|
providers: 'not-a-dict',
|
|
fallbacks: 'not-an-array',
|
|
})
|
|
expect(value).toEqual({ providers: 'not-a-dict', fallbacks: 'not-an-array' })
|
|
expect(secrets).toEqual([
|
|
{ path: ['apiKey'], set: false },
|
|
{ path: ['nested', 'token'], set: false },
|
|
])
|
|
})
|
|
|
|
it('treats a secret-role container as one opaque secret leaf', () => {
|
|
const Weird = z.object({ blob: z.object({ inner: z.string() }).role('secret') })
|
|
const { value, secrets } = redactSecrets(Weird as z<never>, { blob: { inner: 'x' } })
|
|
expect(value).toEqual({})
|
|
expect(secrets).toEqual([{ path: ['blob'], set: true }])
|
|
})
|
|
|
|
it('drops a dict entry whose entire value is the secret', () => {
|
|
const Tokens = z.object({ tokens: z.dict(z.string().role('secret')) })
|
|
const { value, secrets } = redactSecrets(Tokens as z<never>, { tokens: { a: 'x', b: 'y' } })
|
|
expect(value).toEqual({ tokens: {} })
|
|
expect(secrets).toEqual([
|
|
{ path: ['tokens', 'a'], set: true },
|
|
{ path: ['tokens', 'b'], set: true },
|
|
])
|
|
})
|
|
|
|
it('tolerates structural nodes missing their relation maps', () => {
|
|
expect(redactSecrets({ type: 'dict' } as never, { k: 'v' })).toEqual({ value: { k: 'v' }, secrets: [] })
|
|
expect(redactSecrets({ type: 'object' } as never, { k: 'v' })).toEqual({ value: { k: 'v' }, secrets: [] })
|
|
expect(redactSecrets({ type: 'array' } as never, ['v'])).toEqual({ value: ['v'], secrets: [] })
|
|
})
|
|
})
|
|
|
|
describe('describe() layers and redaction', () => {
|
|
const NS = settingsNamespace('adapter')
|
|
|
|
async function boot(doc?: Record<string, unknown>) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(MemorySettings, doc === undefined ? undefined : { doc })
|
|
return ctx
|
|
}
|
|
|
|
it('exposes detached base and user layers beside the resolved value', async () => {
|
|
const ctx = await boot({ adapter: { baseURL: 'https://user' } })
|
|
const base = { apiKey: 'entry-key', baseURL: 'https://base' }
|
|
ctx.settings.register(NS, Profile, { base })
|
|
const [descriptor] = ctx.settings.describe()
|
|
expect(descriptor?.base).toEqual(base)
|
|
expect(descriptor?.base).not.toBe(base)
|
|
expect(descriptor?.user).toEqual({ baseURL: 'https://user' })
|
|
expect(descriptor?.value).toEqual({ apiKey: 'entry-key', baseURL: 'https://user' })
|
|
;(descriptor?.user as Record<string, unknown>).baseURL = 'mutated'
|
|
expect(ctx.settings.describe()[0]?.user).toEqual({ baseURL: 'https://user' })
|
|
expect(descriptor?.secrets).toBeUndefined()
|
|
})
|
|
|
|
it('omits the layers when neither a base nor a user section exists', async () => {
|
|
const ctx = await boot()
|
|
ctx.settings.register(NS, Profile)
|
|
const [descriptor] = ctx.settings.describe()
|
|
expect(descriptor).not.toHaveProperty('base')
|
|
expect(descriptor).not.toHaveProperty('user')
|
|
})
|
|
|
|
it('describes a section that became malformed after registration as having no user layer', async () => {
|
|
const ctx = await boot({ adapter: { baseURL: 'https://user' } })
|
|
const provider = ctx.get('settings') as MemorySettings
|
|
ctx.settings.register(NS, Profile, { base: { baseURL: 'https://base' } })
|
|
provider.pushExternal({ adapter: 5 })
|
|
const [descriptor] = ctx.settings.describe()
|
|
expect(descriptor).not.toHaveProperty('user')
|
|
// The malformed publish kept the last good resolved value.
|
|
expect(descriptor?.value).toEqual({ baseURL: 'https://user' })
|
|
})
|
|
|
|
it('redacts a descriptor that has neither base nor user layer', async () => {
|
|
const ctx = await boot()
|
|
ctx.settings.register(NS, Profile)
|
|
const [descriptor] = ctx.settings.describe({ redactSecrets: true })
|
|
expect(descriptor).not.toHaveProperty('base')
|
|
expect(descriptor).not.toHaveProperty('user')
|
|
expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: false }])
|
|
})
|
|
|
|
it('redacts every layer and enumerates secret slots under redactSecrets', async () => {
|
|
const ctx = await boot({ adapter: { apiKey: 'user-key', baseURL: 'https://user' } })
|
|
ctx.settings.register(NS, Profile, { base: { apiKey: 'entry-key' } })
|
|
const [descriptor] = ctx.settings.describe({ redactSecrets: true })
|
|
expect(descriptor?.value).toEqual({ baseURL: 'https://user' })
|
|
expect(descriptor?.base).toEqual({})
|
|
expect(descriptor?.user).toEqual({ baseURL: 'https://user' })
|
|
expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: true }])
|
|
const [verbatim] = ctx.settings.describe()
|
|
expect(verbatim?.value).toEqual({ apiKey: 'user-key', baseURL: 'https://user' })
|
|
})
|
|
})
|