* fix(#2997): mask SECRET_CONFIG_KEYS in SDK config-set/get and init responses The CJS→TS port at sdk/src/query/config-mutation.ts:240,243 and config-query.ts:122,128,132 dropped the masking layer that secrets.cjs spec defines for brave_search/firecrawl/exa_search. Result: the SDK echoed plaintext API keys into machine-readable JSON output (stdout, transcripts, CI logs). Adjacent leak in init.ts:673-675 / init.cjs:728-730: the init bundle passed config.brave_search through raw, leaking the API key whenever the user had stored one. Fix: - New sdk/src/query/secrets.ts ports SECRET_CONFIG_KEYS, isSecretKey, maskSecret, maskIfSecret. Exact CJS parity (verified by 17 tests in secrets.test.ts that import secrets.cjs and compare). - config-set masks value + previousValue in response; on-disk plaintext intact (key stays usable). - config-get masks read response. --default flows through unmasked (user's own input, not stored secret). - init.ts/init.cjs mask string values only; booleans (availability flags) pass through unchanged so the typed contract is preserved. Tests: 17 in secrets.test.ts (including CJS parity), 5 in config-mutation.test.ts (#2997 block — covers on-disk-preserved, previousValue masking, short-value, unset, non-secret pass-through), 4 in config-query.test.ts. Closes #2997 * chore(#2997): add changeset fragment for PR #2999 * chore(#2997): add changeset fragment for PR #2999 * chore(#2999): drop direct CHANGELOG.md edit; release entry now lives in .changeset/ The changeset-fragment workflow (#2975) renders fragments into CHANGELOG.md at release time. Direct edits to [Unreleased] on each PR caused merge conflicts on every concurrent PR. This commit restores CHANGELOG.md to match origin/main; the release entry for this fix is preserved in the .changeset/*.md fragment(s) on this branch, which the release workflow consolidates.
546 lines
23 KiB
TypeScript
546 lines
23 KiB
TypeScript
/**
|
|
* Unit tests for config mutation handlers.
|
|
*
|
|
* Tests: isValidConfigKey, parseConfigValue, configSet,
|
|
* configSetModelProfile, configNewProject, configEnsureSection.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtemp, writeFile, readFile, mkdir, rm } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { GSDError } from '../errors.js';
|
|
|
|
// ─── Test setup ─────────────────────────────────────────────────────────────
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-cfgmut-'));
|
|
await mkdir(join(tmpDir, '.planning'), { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ─── isValidConfigKey ──────────────────────────────────────────────────────
|
|
|
|
describe('isValidConfigKey', () => {
|
|
it('accepts known exact keys', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
expect(isValidConfigKey('model_profile').valid).toBe(true);
|
|
expect(isValidConfigKey('commit_docs').valid).toBe(true);
|
|
expect(isValidConfigKey('workflow.auto_advance').valid).toBe(true);
|
|
});
|
|
|
|
it('accepts workflow.context_coverage_gate (#2492)', async () => {
|
|
const { isValidConfigKey, parseConfigValue } = await import('./config-mutation.js');
|
|
expect(isValidConfigKey('workflow.context_coverage_gate').valid).toBe(true);
|
|
expect(parseConfigValue('true')).toBe(true);
|
|
expect(parseConfigValue('false')).toBe(false);
|
|
});
|
|
|
|
it('accepts wildcard agent_skills.* patterns', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
expect(isValidConfigKey('agent_skills.gsd-planner').valid).toBe(true);
|
|
expect(isValidConfigKey('agent_skills.custom_agent').valid).toBe(true);
|
|
});
|
|
|
|
it('accepts wildcard features.* patterns', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
expect(isValidConfigKey('features.global_learnings').valid).toBe(true);
|
|
expect(isValidConfigKey('features.thinking_partner').valid).toBe(true);
|
|
});
|
|
|
|
it('rejects unknown keys with suggestion', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
const result = isValidConfigKey('model_profle');
|
|
expect(result.valid).toBe(false);
|
|
expect(result.suggestion).toBeDefined();
|
|
});
|
|
|
|
it('rejects completely invalid keys', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
const result = isValidConfigKey('totally_unknown_key');
|
|
expect(result.valid).toBe(false);
|
|
});
|
|
|
|
it('accepts learnings.max_inject as valid key (D7)', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
expect(isValidConfigKey('learnings.max_inject').valid).toBe(true);
|
|
});
|
|
|
|
it('accepts features.global_learnings as valid key (D7)', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
expect(isValidConfigKey('features.global_learnings').valid).toBe(true);
|
|
});
|
|
|
|
it('returns curated suggestion for known typos before LCP fallback (D9)', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
const r1 = isValidConfigKey('workflow.codereview');
|
|
expect(r1.valid).toBe(false);
|
|
expect(r1.suggestion).toBe('workflow.code_review');
|
|
|
|
const r2 = isValidConfigKey('agents.nyquist_validation_enabled');
|
|
expect(r2.valid).toBe(false);
|
|
expect(r2.suggestion).toBe('workflow.nyquist_validation');
|
|
});
|
|
|
|
// #2653 — SDK/CJS config-schema drift regression.
|
|
// Every key accepted by the CJS config-set must also be accepted by
|
|
// the SDK config-set. We exercise every entry in the shared schema
|
|
// so drift fails this test the moment it is introduced.
|
|
it('#2653 — accepts every key in shared VALID_CONFIG_KEYS', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
const { VALID_CONFIG_KEYS } = await import('./config-schema.js');
|
|
const rejected: string[] = [];
|
|
for (const key of VALID_CONFIG_KEYS) {
|
|
const { valid } = isValidConfigKey(key);
|
|
if (!valid) rejected.push(key);
|
|
}
|
|
expect(rejected).toEqual([]);
|
|
});
|
|
|
|
it('#2653 — accepts sample dynamic keys from every DYNAMIC_KEY_PATTERN', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
const samples = [
|
|
'agent_skills.gsd-planner',
|
|
'review.models.claude',
|
|
'features.some_feature',
|
|
'claude_md_assembly.blocks.intro',
|
|
'model_profile_overrides.codex.opus',
|
|
'model_profile_overrides.codex.sonnet',
|
|
'model_profile_overrides.my-runtime.haiku',
|
|
];
|
|
for (const key of samples) {
|
|
expect(isValidConfigKey(key).valid, `expected ${key} to be accepted`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('#2653 — accepts planning.sub_repos (CJS/docs key, previously rejected by SDK)', async () => {
|
|
const { isValidConfigKey } = await import('./config-mutation.js');
|
|
expect(isValidConfigKey('planning.sub_repos').valid).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── parseConfigValue ──────────────────────────────────────────────────────
|
|
|
|
describe('parseConfigValue', () => {
|
|
it('converts "true" to boolean true', async () => {
|
|
const { parseConfigValue } = await import('./config-mutation.js');
|
|
expect(parseConfigValue('true')).toBe(true);
|
|
});
|
|
|
|
it('converts "false" to boolean false', async () => {
|
|
const { parseConfigValue } = await import('./config-mutation.js');
|
|
expect(parseConfigValue('false')).toBe(false);
|
|
});
|
|
|
|
it('converts numeric strings to numbers', async () => {
|
|
const { parseConfigValue } = await import('./config-mutation.js');
|
|
expect(parseConfigValue('42')).toBe(42);
|
|
expect(parseConfigValue('3.14')).toBe(3.14);
|
|
});
|
|
|
|
it('parses JSON arrays', async () => {
|
|
const { parseConfigValue } = await import('./config-mutation.js');
|
|
expect(parseConfigValue('["a","b"]')).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('parses JSON objects', async () => {
|
|
const { parseConfigValue } = await import('./config-mutation.js');
|
|
expect(parseConfigValue('{"key":"val"}')).toEqual({ key: 'val' });
|
|
});
|
|
|
|
it('preserves plain strings', async () => {
|
|
const { parseConfigValue } = await import('./config-mutation.js');
|
|
expect(parseConfigValue('hello')).toBe('hello');
|
|
});
|
|
|
|
it('preserves empty string as empty string', async () => {
|
|
const { parseConfigValue } = await import('./config-mutation.js');
|
|
expect(parseConfigValue('')).toBe('');
|
|
});
|
|
});
|
|
|
|
// ─── atomicWriteConfig behavior ───────────────────────────────────────────
|
|
|
|
describe('atomicWriteConfig internals (via configSet)', () => {
|
|
it('uses PID-qualified temp file name (D4)', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}');
|
|
|
|
await configSet(['model_profile', 'quality'], tmpDir);
|
|
|
|
// Verify the config was written (temp file should be cleaned up)
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.model_profile).toBe('quality');
|
|
});
|
|
|
|
it('falls back to direct write when rename fails (D5)', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}');
|
|
|
|
// Even if rename would fail, config-set should still succeed via fallback
|
|
await configSet(['model_profile', 'balanced'], tmpDir);
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.model_profile).toBe('balanced');
|
|
});
|
|
});
|
|
|
|
// ─── configSet lock protection ────────────────────────────────────────────
|
|
|
|
describe('configSet lock protection (D6)', () => {
|
|
it('acquires and releases lock around read-modify-write', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}');
|
|
|
|
// Run two concurrent config-set operations — both should succeed without corruption
|
|
const [r1, r2] = await Promise.all([
|
|
configSet(['commit_docs', 'true'], tmpDir),
|
|
configSet(['model_profile', 'quality'], tmpDir),
|
|
]);
|
|
expect((r1.data as { updated: boolean }).updated).toBe(true);
|
|
expect((r2.data as { updated: boolean }).updated).toBe(true);
|
|
|
|
// Both values should be present (no lost updates)
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.commit_docs).toBe(true);
|
|
expect(raw.model_profile).toBe('quality');
|
|
});
|
|
});
|
|
|
|
// ─── configSet context validation ─────────────────────────────────────────
|
|
|
|
describe('configSet context validation (D8)', () => {
|
|
it('rejects invalid context values', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}');
|
|
|
|
await expect(configSet(['context', 'invalid'], tmpDir)).rejects.toThrow(/Invalid context value/);
|
|
});
|
|
|
|
it('accepts valid context values (dev, research, review)', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
|
|
for (const ctx of ['dev', 'research', 'review']) {
|
|
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}');
|
|
const result = await configSet(['context', ctx], tmpDir);
|
|
expect((result.data as { updated: boolean }).updated).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── configNewProject global defaults ─────────────────────────────────────
|
|
|
|
describe('configNewProject global defaults (D11)', () => {
|
|
it('creates config with standard defaults when no global defaults exist', async () => {
|
|
const { configNewProject } = await import('./config-mutation.js');
|
|
const result = await configNewProject([], tmpDir);
|
|
expect((result.data as { created: boolean }).created).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.model_profile).toBe('balanced');
|
|
});
|
|
});
|
|
|
|
// ─── configNewProject nested globalDefaults merging ───────────────────────
|
|
|
|
describe('configNewProject nested globalDefaults merging (fix #2673)', () => {
|
|
let fakeHome: string;
|
|
let originalHome: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
fakeHome = await mkdtemp(join(tmpdir(), 'gsd-fakehome-'));
|
|
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
|
|
originalHome = process.env.HOME;
|
|
process.env.HOME = fakeHome;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalHome !== undefined) {
|
|
process.env.HOME = originalHome;
|
|
} else {
|
|
delete process.env.HOME;
|
|
}
|
|
await rm(fakeHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('preserves nested workflow keys from globalDefaults', async () => {
|
|
await writeFile(
|
|
join(fakeHome, '.gsd', 'defaults.json'),
|
|
JSON.stringify({
|
|
workflow: { auto_advance: true, discuss_mode: 'skip' },
|
|
git: { branching_strategy: 'milestone' },
|
|
}),
|
|
);
|
|
|
|
const { configNewProject } = await import('./config-mutation.js');
|
|
const result = await configNewProject([], tmpDir);
|
|
expect((result.data as { created: boolean }).created).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
// Nested workflow keys from globalDefaults must survive
|
|
expect(raw.workflow.auto_advance).toBe(true);
|
|
expect(raw.workflow.discuss_mode).toBe('skip');
|
|
// Hardcoded defaults not overridden by globalDefaults must still be present
|
|
expect(raw.workflow.research).toBe(true);
|
|
// Nested git key from globalDefaults must survive
|
|
expect(raw.git.branching_strategy).toBe('milestone');
|
|
// Hardcoded git defaults not overridden must still be present
|
|
expect(raw.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}');
|
|
});
|
|
|
|
it('lets userChoices override globalDefaults nested keys', async () => {
|
|
await writeFile(
|
|
join(fakeHome, '.gsd', 'defaults.json'),
|
|
JSON.stringify({
|
|
workflow: { auto_advance: true },
|
|
}),
|
|
);
|
|
|
|
const { configNewProject } = await import('./config-mutation.js');
|
|
const choices = JSON.stringify({ workflow: { auto_advance: false } });
|
|
const result = await configNewProject([choices], tmpDir);
|
|
expect((result.data as { created: boolean }).created).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
// userChoices must win over globalDefaults
|
|
expect(raw.workflow.auto_advance).toBe(false);
|
|
});
|
|
|
|
it('preserves nested hooks, agent_skills, and features keys from globalDefaults', async () => {
|
|
await writeFile(
|
|
join(fakeHome, '.gsd', 'defaults.json'),
|
|
JSON.stringify({
|
|
hooks: { context_warnings: false },
|
|
agent_skills: { my_skill: true },
|
|
features: { beta_feature: true },
|
|
}),
|
|
);
|
|
|
|
const { configNewProject } = await import('./config-mutation.js');
|
|
const result = await configNewProject([], tmpDir);
|
|
expect((result.data as { created: boolean }).created).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.hooks.context_warnings).toBe(false);
|
|
expect(raw.agent_skills.my_skill).toBe(true);
|
|
expect(raw.features.beta_feature).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── configSet ─────────────────────────────────────────────────────────────
|
|
|
|
describe('configSet', () => {
|
|
it('writes value and round-trips through reading config.json', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
const result = await configSet(['model_profile', 'quality'], tmpDir);
|
|
expect(result.data).toEqual({
|
|
updated: true,
|
|
key: 'model_profile',
|
|
value: 'quality',
|
|
previousValue: 'balanced',
|
|
});
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.model_profile).toBe('quality');
|
|
});
|
|
|
|
it('sets nested dot-notation keys', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { research: true } }),
|
|
);
|
|
const result = await configSet(['workflow.auto_advance', 'true'], tmpDir);
|
|
expect(result.data).toEqual({
|
|
updated: true,
|
|
key: 'workflow.auto_advance',
|
|
value: true,
|
|
});
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.workflow.auto_advance).toBe(true);
|
|
expect(raw.workflow.research).toBe(true);
|
|
});
|
|
|
|
it('rejects invalid key with GSDError', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({}),
|
|
);
|
|
await expect(configSet(['totally_bogus_key', 'value'], tmpDir)).rejects.toThrow(GSDError);
|
|
});
|
|
|
|
it('coerces values through parseConfigValue', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({}),
|
|
);
|
|
await configSet(['commit_docs', 'true'], tmpDir);
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.commit_docs).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ─── configSetModelProfile ─────────────────────────────────────────────────
|
|
|
|
describe('configSetModelProfile', () => {
|
|
it('writes valid profile', async () => {
|
|
const { configSetModelProfile } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
const result = await configSetModelProfile(['quality'], tmpDir);
|
|
expect((result.data as { updated: boolean }).updated).toBe(true);
|
|
expect((result.data as { profile: string }).profile).toBe('quality');
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.model_profile).toBe('quality');
|
|
});
|
|
|
|
it('rejects invalid profile with GSDError', async () => {
|
|
const { configSetModelProfile } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({}),
|
|
);
|
|
await expect(configSetModelProfile(['invalid_profile'], tmpDir)).rejects.toThrow(GSDError);
|
|
});
|
|
|
|
it('normalizes profile name to lowercase', async () => {
|
|
const { configSetModelProfile } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({}),
|
|
);
|
|
const result = await configSetModelProfile(['Quality'], tmpDir);
|
|
expect((result.data as { profile: string }).profile).toBe('quality');
|
|
});
|
|
});
|
|
|
|
// ─── configNewProject ──────────────────────────────────────────────────────
|
|
|
|
describe('configNewProject', () => {
|
|
it('creates config.json with defaults', async () => {
|
|
const { configNewProject } = await import('./config-mutation.js');
|
|
const result = await configNewProject([], tmpDir);
|
|
expect((result.data as { created: boolean }).created).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.model_profile).toBe('balanced');
|
|
expect(raw.commit_docs).toBe(true);
|
|
});
|
|
|
|
it('merges user choices', async () => {
|
|
const { configNewProject } = await import('./config-mutation.js');
|
|
const choices = JSON.stringify({ model_profile: 'quality', commit_docs: true });
|
|
const result = await configNewProject([choices], tmpDir);
|
|
expect((result.data as { created: boolean }).created).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.model_profile).toBe('quality');
|
|
expect(raw.commit_docs).toBe(true);
|
|
});
|
|
|
|
it('does not overwrite existing config', async () => {
|
|
const { configNewProject } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'quality' }),
|
|
);
|
|
const result = await configNewProject([], tmpDir);
|
|
expect((result.data as { created: boolean }).created).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── configEnsureSection ───────────────────────────────────────────────────
|
|
|
|
describe('configEnsureSection', () => {
|
|
it('creates section if not present', async () => {
|
|
const { configEnsureSection } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ model_profile: 'balanced' }),
|
|
);
|
|
const result = await configEnsureSection(['workflow'], tmpDir);
|
|
expect((result.data as { ensured: boolean }).ensured).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.workflow).toEqual({});
|
|
});
|
|
|
|
it('is idempotent on existing section', async () => {
|
|
const { configEnsureSection } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ workflow: { research: true } }),
|
|
);
|
|
const result = await configEnsureSection(['workflow'], tmpDir);
|
|
expect((result.data as { ensured: boolean }).ensured).toBe(true);
|
|
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.workflow).toEqual({ research: true });
|
|
});
|
|
});
|
|
|
|
// ─── #2997: Secret masking in configSet response ────────────────────────────
|
|
|
|
describe('configSet secret masking (#2997)', () => {
|
|
it('masks the response value for SECRET_CONFIG_KEYS, leaving on-disk plaintext intact', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
const apiKey = 'BSA-1234567890abcdef';
|
|
const result = await configSet(['brave_search', apiKey], tmpDir);
|
|
const data = result.data as { value: string };
|
|
// Response is masked
|
|
expect(data.value).toBe('****cdef');
|
|
expect(data.value).not.toContain(apiKey);
|
|
// On-disk plaintext is intact (the key is usable)
|
|
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
|
|
expect(raw.brave_search).toBe(apiKey);
|
|
});
|
|
|
|
it('masks previousValue when overwriting an existing secret', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
await writeFile(
|
|
join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ brave_search: 'OLD-KEY-1234567890' }),
|
|
);
|
|
const result = await configSet(['brave_search', 'NEW-KEY-abcdef9999'], tmpDir);
|
|
const data = result.data as { value: string; previousValue: string };
|
|
expect(data.value).toBe('****9999');
|
|
expect(data.previousValue).toBe('****7890');
|
|
expect(data.previousValue).not.toContain('OLD-KEY');
|
|
});
|
|
|
|
it('does NOT mask non-secret keys', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
const result = await configSet(['model_profile', 'quality'], tmpDir);
|
|
const data = result.data as { value: string };
|
|
expect(data.value).toBe('quality');
|
|
});
|
|
|
|
it('renders short secret values as **** (no tail leak)', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
const result = await configSet(['firecrawl', 'short'], tmpDir);
|
|
const data = result.data as { value: string };
|
|
expect(data.value).toBe('****');
|
|
});
|
|
|
|
it('renders unset/empty as (unset) in the response', async () => {
|
|
const { configSet } = await import('./config-mutation.js');
|
|
const result = await configSet(['exa_search', ''], tmpDir);
|
|
const data = result.data as { value: string };
|
|
expect(data.value).toBe('(unset)');
|
|
});
|
|
});
|