refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
90
packages/scaffold/telemetry/tests/anonymous-id.spec.ts
Normal file
90
packages/scaffold/telemetry/tests/anonymous-id.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { defaultDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ANONYMOUS_ID_FILE_NAME,
|
||||
getOrCreateAnonymousId,
|
||||
globalConfigDir,
|
||||
} from '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-anon-'))
|
||||
dirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
describe('globalConfigDir', () => {
|
||||
it('prefers an explicit DSH_HOME override', () => {
|
||||
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe(resolve('/custom/dsh'))
|
||||
})
|
||||
|
||||
it('falls back to ~/.dsh when DSH_HOME is unset', () => {
|
||||
expect(globalConfigDir({ env: {} })).toBe(resolve(defaultDshHome()))
|
||||
})
|
||||
|
||||
it('reads process.env by default', () => {
|
||||
// No override supplied: the call must not throw and must return an absolute path.
|
||||
// The ambient DSH_HOME is unknown here, so assert only the invariant the
|
||||
// resolver guarantees rather than a specific location.
|
||||
expect(isAbsolute(globalConfigDir())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrCreateAnonymousId', () => {
|
||||
it('creates, persists, and returns a UUID on first use', async () => {
|
||||
const dir = await tempDir()
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
expect(id).toMatch(UUID)
|
||||
const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8'))
|
||||
expect(stored).toEqual({ anonymousId: id })
|
||||
})
|
||||
|
||||
it('returns the same persisted id on subsequent calls', async () => {
|
||||
const dir = await tempDir()
|
||||
const first = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
const second = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('uses the injected UUID generator', async () => {
|
||||
const dir = await tempDir()
|
||||
const id = await getOrCreateAnonymousId({
|
||||
env: { DSH_HOME: dir },
|
||||
randomUUID: () => '00000000-0000-4000-8000-000000000000',
|
||||
})
|
||||
expect(id).toBe('00000000-0000-4000-8000-000000000000')
|
||||
})
|
||||
|
||||
it('regenerates when the stored file is corrupt JSON', async () => {
|
||||
const dir = await tempDir()
|
||||
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8')
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
|
||||
expect(id).toMatch(UUID)
|
||||
})
|
||||
|
||||
it('regenerates when the stored value is not a valid UUID or object', async () => {
|
||||
const dir = await tempDir()
|
||||
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8')
|
||||
expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID)
|
||||
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8')
|
||||
expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID)
|
||||
})
|
||||
|
||||
it('returns a usable id even when persistence fails', async () => {
|
||||
const dir = await tempDir()
|
||||
// A regular file where a directory is expected makes mkdir/writeFile fail.
|
||||
await writeFile(join(dir, 'blocker'), 'x', 'utf8')
|
||||
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: join(dir, 'blocker') } })
|
||||
expect(id).toMatch(UUID)
|
||||
})
|
||||
})
|
||||
132
packages/scaffold/telemetry/tests/consent-resolver.spec.ts
Normal file
132
packages/scaffold/telemetry/tests/consent-resolver.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ConsentResolver, DEFAULT_TELEMETRY_PLUGIN_NAME, type ConsentDecision } from '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
async function projectDir(cordisYml?: string): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-consent-'))
|
||||
dirs.push(dir)
|
||||
if (cordisYml !== undefined) await writeFile(join(dir, 'cordis.yml'), cordisYml, 'utf8')
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map(dir => import('node:fs/promises').then(fs => fs.rm(dir, { recursive: true, force: true }))))
|
||||
})
|
||||
|
||||
const enabledYml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n`
|
||||
|
||||
describe('ConsentResolver environment opt-out', () => {
|
||||
it('denies when DO_NOT_TRACK is set', async () => {
|
||||
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' } }).resolve(await projectDir(enabledYml))
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'do-not-track' })
|
||||
})
|
||||
|
||||
it('denies when CI is set', async () => {
|
||||
const decision = await new ConsentResolver({ env: { CI: 'true' } }).resolve(await projectDir(enabledYml))
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'ci' })
|
||||
})
|
||||
|
||||
it('ignores falsy env values and continues to the file', async () => {
|
||||
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '0', CI: 'false' } })
|
||||
.resolve(await projectDir(enabledYml))
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
|
||||
})
|
||||
|
||||
it('can be told to ignore env opt-out signals', async () => {
|
||||
const decision = await new ConsentResolver({ env: { DO_NOT_TRACK: '1' }, honorEnvOptOut: false })
|
||||
.resolve(await projectDir(enabledYml))
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
|
||||
})
|
||||
|
||||
it('reads process.env by default', async () => {
|
||||
const saved = { CI: process.env.CI, DO_NOT_TRACK: process.env.DO_NOT_TRACK }
|
||||
delete process.env.CI
|
||||
delete process.env.DO_NOT_TRACK
|
||||
try {
|
||||
const decision = await new ConsentResolver().resolve(await projectDir(enabledYml))
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
|
||||
} finally {
|
||||
if (saved.CI !== undefined) process.env.CI = saved.CI
|
||||
if (saved.DO_NOT_TRACK !== undefined) process.env.DO_NOT_TRACK = saved.DO_NOT_TRACK
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConsentResolver cordis.yml state', () => {
|
||||
const resolver = new ConsentResolver({ env: {} })
|
||||
|
||||
it('allows when the telemetry entry is enabled', async () => {
|
||||
expect(await resolver.resolve(await projectDir(enabledYml)))
|
||||
.toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
|
||||
})
|
||||
|
||||
it('denies when the telemetry entry is disabled', async () => {
|
||||
const yml = `- id: telemetry\n name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'\n disabled: true\n`
|
||||
expect(await resolver.resolve(await projectDir(yml)))
|
||||
.toEqual<ConsentDecision>({ allowed: false, reason: 'disabled' })
|
||||
})
|
||||
|
||||
it('tolerates !!js expression tags while reading plain scalars', async () => {
|
||||
const yml = [
|
||||
'- id: telemetry',
|
||||
` name: '${DEFAULT_TELEMETRY_PLUGIN_NAME}'`,
|
||||
'- id: llm',
|
||||
' name: \'@deepseek-ai/dsh-llm-deepseek\'',
|
||||
' config:',
|
||||
' apiKeyEnv: DEEPSEEK_API_KEY',
|
||||
' model: !!js process.env.DEEPSEEK_MODEL',
|
||||
'',
|
||||
].join('\n')
|
||||
expect(await resolver.resolve(await projectDir(yml)))
|
||||
.toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
|
||||
})
|
||||
|
||||
it('reports (allows) when cordis.yml has no telemetry entry', async () => {
|
||||
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
|
||||
expect(await resolver.resolve(await projectDir(yml)))
|
||||
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
|
||||
})
|
||||
|
||||
it('can be told to deny when the entry is absent', async () => {
|
||||
const yml = '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n'
|
||||
const decision = await new ConsentResolver({ env: {}, allowWhenEntryAbsent: false }).resolve(await projectDir(yml))
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'absent' })
|
||||
})
|
||||
|
||||
it('skips non-object sequence items and a non-sequence root, still reporting absent', async () => {
|
||||
expect(await resolver.resolve(await projectDir('- just-a-string\n- id: x\n name: y\n')))
|
||||
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
|
||||
expect(await resolver.resolve(await projectDir('root: not-a-sequence\n')))
|
||||
.toEqual<ConsentDecision>({ allowed: true, reason: 'absent' })
|
||||
})
|
||||
|
||||
it('honors a custom telemetry plugin name', async () => {
|
||||
const yml = '- id: t\n name: \'my-consent-marker\'\n'
|
||||
const decision = await new ConsentResolver({ env: {}, telemetryPluginName: 'my-consent-marker' })
|
||||
.resolve(await projectDir(yml))
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: true, reason: 'enabled' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConsentResolver missing or unreadable cordis.yml', () => {
|
||||
it('reports no-config and allows by default on first init', async () => {
|
||||
expect(await new ConsentResolver({ env: {} }).resolve(await projectDir()))
|
||||
.toEqual<ConsentDecision>({ allowed: true, reason: 'no-config' })
|
||||
})
|
||||
|
||||
it('can deny on first init', async () => {
|
||||
const decision = await new ConsentResolver({ env: {}, allowWhenNoConfig: false }).resolve(await projectDir())
|
||||
expect(decision).toEqual<ConsentDecision>({ allowed: false, reason: 'no-config' })
|
||||
})
|
||||
|
||||
it('denies with an unreadable reason when cordis.yml is not a regular file', async () => {
|
||||
const dir = await projectDir()
|
||||
await mkdir(join(dir, 'cordis.yml')) // a directory where the resolver expects a file
|
||||
expect(await new ConsentResolver({ env: {} }).resolve(dir))
|
||||
.toEqual<ConsentDecision>({ allowed: false, reason: 'unreadable' })
|
||||
})
|
||||
})
|
||||
69
packages/scaffold/telemetry/tests/payload.spec.ts
Normal file
69
packages/scaffold/telemetry/tests/payload.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { SecretRedactor, buildTelemetryPayload } from '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
const dirs: string[] = []
|
||||
|
||||
async function projectDir(files: Record<string, string>): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-payload-'))
|
||||
dirs.push(dir)
|
||||
await Promise.all(Object.entries(files).map(([name, content]) => writeFile(join(dir, name), content, 'utf8')))
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('buildTelemetryPayload', () => {
|
||||
it('carries lifecycle facts and redacted file content', async () => {
|
||||
const dir = await projectDir({
|
||||
'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n config:\n apiKey: sk-abcdefghij1234567890\n',
|
||||
'package.json': '{ "name": "my-app", "config": { "token": "sk-abcdefghij1234567890" } }',
|
||||
})
|
||||
const payload = await buildTelemetryPayload({ command: 'build', durationMs: 42, success: true, projectDir: dir })
|
||||
expect(payload.command).toBe('build')
|
||||
expect(payload.durationMs).toBe(42)
|
||||
expect(payload.success).toBe(true)
|
||||
expect(payload.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek') // package name preserved
|
||||
expect(payload.cordisYmlContent).not.toContain('sk-abcdefghij1234567890') // secret scrubbed
|
||||
expect(payload.packageJsonContent).toContain('my-app')
|
||||
expect(payload.packageJsonContent).not.toContain('sk-abcdefghij1234567890')
|
||||
})
|
||||
|
||||
it('omits fields whose files do not exist', async () => {
|
||||
const dir = await projectDir({ 'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n' })
|
||||
const payload = await buildTelemetryPayload({ command: 'create', durationMs: 1, success: false, projectDir: dir })
|
||||
expect(payload.cordisYmlContent).toBeDefined()
|
||||
expect('packageJsonContent' in payload).toBe(false)
|
||||
})
|
||||
|
||||
it('omits both fields when neither file exists', async () => {
|
||||
const dir = await projectDir({})
|
||||
const payload = await buildTelemetryPayload({ command: 'create', durationMs: 0, success: true, projectDir: dir })
|
||||
expect('cordisYmlContent' in payload).toBe(false)
|
||||
expect('packageJsonContent' in payload).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds package.json when cordis.yml is absent (not an SDK project)', async () => {
|
||||
const dir = await projectDir({ 'package.json': '{ "name": "unrelated-repo" }' })
|
||||
const payload = await buildTelemetryPayload({ command: 'build', durationMs: 3, success: false, projectDir: dir })
|
||||
expect('cordisYmlContent' in payload).toBe(false)
|
||||
expect('packageJsonContent' in payload).toBe(false)
|
||||
})
|
||||
|
||||
it('uses a supplied redactor', async () => {
|
||||
const dir = await projectDir({
|
||||
'cordis.yml': '- id: llm\n name: \'@deepseek-ai/dsh-llm-deepseek\'\n',
|
||||
'package.json': '{ "password": "hunter2" }',
|
||||
})
|
||||
const redactor = new SecretRedactor({ placeholder: '<<hidden>>' })
|
||||
const payload = await buildTelemetryPayload({
|
||||
command: 'config', durationMs: 5, success: true, projectDir: dir, redactor,
|
||||
})
|
||||
expect(payload.packageJsonContent).toContain('<<hidden>>')
|
||||
expect(payload.packageJsonContent).not.toContain('hunter2')
|
||||
})
|
||||
})
|
||||
134
packages/scaffold/telemetry/tests/reporter.spec.ts
Normal file
134
packages/scaffold/telemetry/tests/reporter.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DSH_TELEMETRY_ENDPOINT,
|
||||
SecretRedactor,
|
||||
TELEMETRY_SCHEMA_VERSION,
|
||||
TelemetryReporter,
|
||||
type AnonymousId,
|
||||
type ConsentDecision,
|
||||
type TelemetryPayload,
|
||||
} from '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
const ALLOW: ConsentDecision = { allowed: true, reason: 'enabled' }
|
||||
const DENY: ConsentDecision = { allowed: false, reason: 'disabled' }
|
||||
const anon = (value = 'anon-123'): (() => Promise<AnonymousId>) => async () => value as AnonymousId
|
||||
|
||||
function okResponse(): Response {
|
||||
return { ok: true } as Response
|
||||
}
|
||||
|
||||
describe('TelemetryReporter.report', () => {
|
||||
it('skips delivery when consent is denied', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon() })
|
||||
reporter.report({ command: 'build', durationMs: 1, success: true }, DENY)
|
||||
await reporter.flush(50)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts a redacted envelope when consent is granted', async () => {
|
||||
const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
|
||||
const reporter = new TelemetryReporter({
|
||||
endpoint: 'https://collector.test/telemetry',
|
||||
fetch: fetchMock,
|
||||
anonymousId: anon('anon-xyz'),
|
||||
redactor: new SecretRedactor(),
|
||||
now: () => 0,
|
||||
timeoutMs: 100,
|
||||
})
|
||||
const payload: TelemetryPayload = {
|
||||
command: 'config',
|
||||
durationMs: 7,
|
||||
success: true,
|
||||
cordisYmlContent: 'apiKey: sk-abcdefghij1234567890\nname: \'@deepseek-ai/dsh-llm-deepseek\'\n',
|
||||
packageJsonContent: '{ "name": "app" }',
|
||||
}
|
||||
reporter.report(payload, ALLOW)
|
||||
await reporter.flush(50)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const call = fetchMock.mock.calls[0]!
|
||||
expect(call[0]).toBe('https://collector.test/telemetry')
|
||||
const init = call[1]!
|
||||
expect(init.method).toBe('POST')
|
||||
const body = JSON.parse(init.body as string) as Record<string, unknown>
|
||||
expect(body.schemaVersion).toBe(TELEMETRY_SCHEMA_VERSION)
|
||||
expect(body.anonymousId).toBe('anon-xyz')
|
||||
expect(body.sentAt).toBe('1970-01-01T00:00:00.000Z')
|
||||
expect(body.command).toBe('config')
|
||||
expect(body.cordisYmlContent).not.toContain('sk-abcdefghij1234567890')
|
||||
expect(body.cordisYmlContent).toContain('@deepseek-ai/dsh-llm-deepseek')
|
||||
expect(body.packageJsonContent).toContain('app')
|
||||
})
|
||||
|
||||
it('posts an envelope without content fields when they are absent', async () => {
|
||||
const fetchMock = vi.fn<typeof globalThis.fetch>(() => Promise.resolve(okResponse()))
|
||||
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), now: () => 0, timeoutMs: 100 })
|
||||
reporter.report({ command: 'start', durationMs: 2, success: true }, ALLOW)
|
||||
await reporter.flush(50)
|
||||
const body = JSON.parse(fetchMock.mock.calls[0]![1]!.body as string) as Record<string, unknown>
|
||||
expect('cordisYmlContent' in body).toBe(false)
|
||||
expect('packageJsonContent' in body).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a non-OK HTTP status', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, status: 503 } as Response))
|
||||
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
|
||||
reporter.report({ command: 'dev', durationMs: 3, success: true }, ALLOW)
|
||||
await expect(reporter.flush(50)).resolves.toBeUndefined()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('swallows a transport failure', async () => {
|
||||
const fetchMock = vi.fn(async () => { throw new Error('network down') })
|
||||
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
|
||||
reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
|
||||
await expect(reporter.flush(50)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('swallows a non-Error transport rejection', async () => {
|
||||
const fetchMock = vi.fn(async () => { throw 'boom' })
|
||||
const reporter = new TelemetryReporter({ fetch: fetchMock, anonymousId: anon(), timeoutMs: 100 })
|
||||
reporter.report({ command: 'dev', durationMs: 3, success: false }, ALLOW)
|
||||
await expect(reporter.flush(50)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('swallows a failure while resolving the anonymous id, never sending', async () => {
|
||||
const fetchMock = vi.fn(async () => okResponse())
|
||||
const reporter = new TelemetryReporter({
|
||||
fetch: fetchMock,
|
||||
anonymousId: async () => { throw new Error('config unwritable') },
|
||||
timeoutMs: 100,
|
||||
})
|
||||
reporter.report({ command: 'build', durationMs: 1, success: true }, ALLOW)
|
||||
await reporter.flush(50)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TelemetryReporter.flush', () => {
|
||||
it('returns immediately when nothing is in flight', async () => {
|
||||
const reporter = new TelemetryReporter({ fetch: vi.fn(async () => okResponse()), anonymousId: anon() })
|
||||
await expect(reporter.flush()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves on the timeout cap when a send never settles', async () => {
|
||||
const reporter = new TelemetryReporter({
|
||||
fetch: () => new Promise<Response>(() => {}),
|
||||
anonymousId: anon(),
|
||||
timeoutMs: 10,
|
||||
})
|
||||
reporter.report({ command: 'start', durationMs: 1, success: true }, ALLOW)
|
||||
const started = Date.now()
|
||||
await reporter.flush(15)
|
||||
expect(Date.now() - started).toBeLessThan(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TelemetryReporter defaults', () => {
|
||||
it('defaults the endpoint and transport seams without options', () => {
|
||||
const reporter = new TelemetryReporter()
|
||||
expect(reporter).toBeInstanceOf(TelemetryReporter)
|
||||
expect(DSH_TELEMETRY_ENDPOINT).toContain('.invalid')
|
||||
})
|
||||
})
|
||||
176
packages/scaffold/telemetry/tests/secret-redactor.spec.ts
Normal file
176
packages/scaffold/telemetry/tests/secret-redactor.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_ENTROPY_THRESHOLD,
|
||||
DEFAULT_MIN_TOKEN_LENGTH,
|
||||
DEFAULT_REDACTION_PLACEHOLDER,
|
||||
SecretRedactor,
|
||||
keyLooksSecret,
|
||||
} from '@deepseek-ai/dsh-telemetry'
|
||||
|
||||
const REDACTED = DEFAULT_REDACTION_PLACEHOLDER
|
||||
|
||||
describe('exported defaults', () => {
|
||||
it('expose the documented tunable defaults', () => {
|
||||
expect(DEFAULT_REDACTION_PLACEHOLDER).toBe('[REDACTED]')
|
||||
expect(DEFAULT_MIN_TOKEN_LENGTH).toBe(24)
|
||||
expect(DEFAULT_ENTROPY_THRESHOLD).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keyLooksSecret', () => {
|
||||
it('matches secret substrings across casings and separators', () => {
|
||||
for (const key of ['password', 'API_KEY', 'apiKey', 'clientSecret', 'x-api-key', 'privateKey', 'CREDENTIALS']) {
|
||||
expect(keyLooksSecret(key)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches *token as a suffix but not tokenizer', () => {
|
||||
expect(keyLooksSecret('accessToken')).toBe(true)
|
||||
expect(keyLooksSecret('token')).toBe(true)
|
||||
expect(keyLooksSecret('tokenizer')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches short ambiguous words only as whole keys', () => {
|
||||
expect(keyLooksSecret('auth')).toBe(true)
|
||||
expect(keyLooksSecret('authorization')).toBe(true)
|
||||
expect(keyLooksSecret('cookie')).toBe(true)
|
||||
expect(keyLooksSecret('author')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not match ordinary config keys', () => {
|
||||
for (const key of ['name', 'version', 'model', 'baseURL', 'timeout', 'path', 'pass']) {
|
||||
expect(keyLooksSecret(key)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns false for a key with no alphanumerics', () => {
|
||||
expect(keyLooksSecret('---')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretRedactor.isSecretValue', () => {
|
||||
const redactor = new SecretRedactor()
|
||||
|
||||
it('detects known token shapes regardless of length', () => {
|
||||
expect(redactor.isSecretValue('sk-abcdefghij1234567890')).toBe(true)
|
||||
expect(redactor.isSecretValue('sk-ant-abcdefghij1234567890')).toBe(true)
|
||||
expect(redactor.isSecretValue('ghp_abcdefghijklmnop1234')).toBe(true)
|
||||
expect(redactor.isSecretValue('github_pat_abcdefghijklmnopqrst')).toBe(true)
|
||||
expect(redactor.isSecretValue('xoxb-abcdefghij-klmno')).toBe(true)
|
||||
expect(redactor.isSecretValue('AKIA1234567890ABCDEF')).toBe(true)
|
||||
expect(redactor.isSecretValue(`AIza${'a'.repeat(35)}`)).toBe(true)
|
||||
expect(redactor.isSecretValue('eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefghijklmnop')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects high-entropy opaque tokens with three character classes', () => {
|
||||
// Non-hex letters keep it off the hex-digest exemption; three classes trip the rule.
|
||||
expect(redactor.isSecretValue('zX9zX9zX9zX9zX9zX9zX9zX9')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects high-entropy opaque tokens by entropy even within two classes', () => {
|
||||
// 30 distinct lowercase+digit chars: entropy ~4.9, only two classes.
|
||||
const token = 'abcdefghijklmnopqrstuvwxyz0123'
|
||||
expect(token.length).toBeGreaterThanOrEqual(DEFAULT_MIN_TOKEN_LENGTH)
|
||||
expect(redactor.isSecretValue(token)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves short values, non-opaque text, hex digests, and versions untouched', () => {
|
||||
expect(redactor.isSecretValue('deepseek-chat')).toBe(false) // short
|
||||
expect(redactor.isSecretValue('a token with spaces here!!')).toBe(false) // not opaque
|
||||
expect(redactor.isSecretValue('a'.repeat(40))).toBe(false) // low entropy, one class
|
||||
expect(redactor.isSecretValue('abcdef0123456789abcdef0123456789abcdef01')).toBe(false) // 40-hex git SHA
|
||||
expect(redactor.isSecretValue('1.2.3.4.5.6.7.8.9.10.11.12')).toBe(false) // version-like
|
||||
expect(redactor.isSecretValue('ZXQPZXQPZXQPZXQPZXQPZXQP')).toBe(false) // uppercase only, low entropy
|
||||
})
|
||||
|
||||
it('honors a custom entropy threshold', () => {
|
||||
const strict = new SecretRedactor({ entropyThreshold: 100 })
|
||||
// Two-class token can no longer trip the entropy branch under an impossible threshold.
|
||||
expect(strict.isSecretValue('abcdefghijklmnopqrstuvwxyz0123')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretRedactor.redactValue', () => {
|
||||
const redactor = new SecretRedactor()
|
||||
|
||||
it('redacts secret-keyed strings and secret-shaped strings, keeping structure', () => {
|
||||
const result = redactor.redactValue({
|
||||
apiKey: 'short-not-shaped',
|
||||
name: 'my-package',
|
||||
token: 'sk-abcdefghij1234567890',
|
||||
count: 3,
|
||||
enabled: true,
|
||||
missing: null,
|
||||
nested: { password: 'p', note: 'plain text value' },
|
||||
list: ['harmless', 'sk-abcdefghij1234567890'],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
apiKey: REDACTED, // redacted by key even though the value is not secret-shaped
|
||||
name: 'my-package',
|
||||
token: REDACTED,
|
||||
count: 3,
|
||||
enabled: true,
|
||||
missing: null,
|
||||
nested: { password: REDACTED, note: 'plain text value' },
|
||||
list: ['harmless', REDACTED],
|
||||
})
|
||||
})
|
||||
|
||||
it('redacts a top-level secret string and passes through primitives', () => {
|
||||
expect(redactor.redactValue('sk-abcdefghij1234567890')).toBe(REDACTED)
|
||||
expect(redactor.redactValue('plain')).toBe('plain')
|
||||
expect(redactor.redactValue(42)).toBe(42)
|
||||
expect(redactor.redactValue(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SecretRedactor.redactText', () => {
|
||||
const redactor = new SecretRedactor()
|
||||
|
||||
it('redacts PEM private key blocks', () => {
|
||||
const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIabc\ndef==\n-----END RSA PRIVATE KEY-----'
|
||||
expect(redactor.redactText(text)).toBe(REDACTED)
|
||||
})
|
||||
|
||||
it('redacts secret-keyed assignments across YAML, JSON, and .env', () => {
|
||||
expect(redactor.redactText('password: hunter2')).toBe(`password: ${REDACTED}`)
|
||||
expect(redactor.redactText('apiKey: "sk-abcdefghij1234567890"')).toBe(`apiKey: "${REDACTED}"`)
|
||||
expect(redactor.redactText('"token": "abcdefgh"')).toBe(`"token": "${REDACTED}"`)
|
||||
expect(redactor.redactText('API_KEY=sk-abcdefghij1234567890')).toBe(`API_KEY=${REDACTED}`)
|
||||
})
|
||||
|
||||
it('keeps non-secret assignments and whitespace-only secret values intact', () => {
|
||||
expect(redactor.redactText('model: deepseek-chat')).toBe('model: deepseek-chat')
|
||||
expect(redactor.redactText('password: \n')).toBe('password: \n')
|
||||
})
|
||||
|
||||
it('redacts only the password in URL credentials, keeping the host', () => {
|
||||
expect(redactor.redactText('url: https://user:s3cretPass@api.deepseek.com/v1'))
|
||||
.toBe(`url: https://user:${REDACTED}@api.deepseek.com/v1`)
|
||||
})
|
||||
|
||||
it('redacts bearer tokens embedded in free text', () => {
|
||||
expect(redactor.redactText('sending Bearer abcdefgh12345678 now'))
|
||||
.toBe(`sending Bearer ${REDACTED} now`)
|
||||
})
|
||||
|
||||
it('keeps letters-only prose after the word bearer intact', () => {
|
||||
expect(redactor.redactText('uses bearer authentication for requests'))
|
||||
.toBe('uses bearer authentication for requests')
|
||||
expect(redactor.redactText('"description": "bearer token-helper middleware"'))
|
||||
.toBe('"description": "bearer token-helper middleware"')
|
||||
})
|
||||
|
||||
it('redacts standalone secret-shaped tokens while keeping package names and paths', () => {
|
||||
expect(redactor.redactText('key sk-abcdefghij1234567890 end'))
|
||||
.toBe(`key ${REDACTED} end`)
|
||||
expect(redactor.redactText('name: @deepseek-ai/dsh-telemetry')).toBe('name: @deepseek-ai/dsh-telemetry')
|
||||
expect(redactor.redactText('path: ./plugins/local-plugin/src/index.ts'))
|
||||
.toBe('path: ./plugins/local-plugin/src/index.ts')
|
||||
})
|
||||
|
||||
it('is idempotent on already-redacted text', () => {
|
||||
const once = redactor.redactText('password: hunter2')
|
||||
expect(redactor.redactText(once)).toBe(once)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user