Files
fission-ai__openspec/test/core/global-config.test.ts
Clay Good a874d1d671 chore(security): create test temp dirs with mkdtemp and override two pinned CVEs (#1432)
* test: create temp dirs with fs.mkdtemp

Every one of these suites built its temp directory by hand:

    testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
    await fs.mkdir(testDir, { recursive: true });

That check-then-create is what CodeQL's js/insecure-temporary-file flags,
and it accounted for 452 of the repo's 467 dismissed code-scanning alerts.
Dismissing them one by one is a treadmill: every new suite that copies the
idiom mints fresh alerts, and a real finding is easy to lose in that volume.

fs.mkdtemp creates the directory atomically at mode 0700 with a random
suffix, so there is no window to pre-empt and no name to guess. The suites
that already used it are the evidence this silences the rule: 33 of the 34
files calling mkdtemp carry zero alerts. The lone exception, archive.test.ts,
was scanned one commit before its own mkdtemp fix landed and is left to that
change rather than conflicting with it.

The dirs named on Date.now() alone were genuinely predictable; the randomUUID
ones were not, but they trained the same copy-paste. Both are gone now.

Behavior is unchanged: mkdtemp creates the root the old mkdir created, and
no assertion depended on the root being absent. Verified with the full suite
(2196 tests, 111 files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(deps): override postcss and sharp in the website

Two advisories on the docs site have no Dependabot PR and never will:
Next pins postcss at 8.4.31 as a direct dependency and sharp at ^0.34.5 as
an optional one, so Dependabot cannot raise either without a Next release
that does it first. 16.2.11 does not — it still pins both. Left alone these
sit open indefinitely.

  postcss  8.4.31 -> 8.5.22   GHSA-qx2v-qp2m-jg93  (XSS via unescaped </style>)
  sharp    0.34.5 -> 0.35.3   GHSA-f88m-g3jw-g9cj  (4 libvips CVEs)

A version-ranged selector (`postcss@<8.5.10`) was the first instinct, since it
lapses on its own once Next moves past it. It is the wrong tool: it pins the
override to one advisory's floor, and silently stops applying when the next
advisory raises that floor. GHSA-6g55-p6wh-862q landed while this branch was
open and moved postcss's patched floor to 8.5.12 — under the ranged selector a
dependency pinning 8.5.11 would have resolved to 8.5.11 and stayed vulnerable.
A plain floor cannot under-match, so that is what this uses.

The floor also covers the new advisory: 8.5.22 is past 8.5.12. postcss dedupes
to the single copy the site already had for Tailwind.

These are the last two open Dependabot alerts on the repo. `pnpm audit` on
website/ goes from 2 advisories to "No known vulnerabilities found". The site
builds clean, OG image generation included, and the package set grows by
exactly two platform-gated wasm32 binaries that never install on CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(security): audit the docs site too

Both `pnpm audit` steps run at the repo root. The docs site keeps its own
lockfile and is not a workspace member, so neither step could see it — the
root audit passed all the way through while postcss and sharp sat open in
website/. That blind spot is why they needed a manual override to find.

Blocking rule copied from the published-dependency audit: advisory on pull
requests, blocking on the weekly schedule and on pushes to main. An
always-advisory step would only relocate the blind spot — the sweep would stay
green with a live advisory and someone would have to read the log of a passing
run to notice.

`!cancelled()` because the two audits above can fail hard. Without it a root
advisory would skip this step entirely, in exactly the situation where the
site's own state matters most.

Verified against the pre-fix lockfile — the step reports the two advisories it
would have caught:

  2 vulnerabilities found
  Severity: 1 moderate | 1 high

and reports "No known vulnerabilities found" against the fixed one. Confirmed
it reads website/pnpm-lock.yaml and not the root: with a vulnerable website
lockfile and a clean root, it exits 1; a missing website lockfile is a hard
ERR_PNPM_AUDIT_NO_LOCKFILE rather than a false clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:17:26 +00:00

371 lines
12 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
getGlobalConfigDir,
getGlobalConfigPath,
getGlobalDataDir,
getGlobalConfig,
saveGlobalConfig,
GLOBAL_CONFIG_DIR_NAME,
GLOBAL_CONFIG_FILE_NAME
} from '../../src/core/global-config.js';
import type { Profile, Delivery } from '../../src/core/global-config.js';
describe('global-config', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Create temp directory for tests
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-global-config-test-'));
// Save original env
originalEnv = { ...process.env };
// Spy on console.error for warning tests
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
// Restore original env
process.env = originalEnv;
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
// Restore console.error
consoleErrorSpy.mockRestore();
});
describe('constants', () => {
it('should export correct directory name', () => {
expect(GLOBAL_CONFIG_DIR_NAME).toBe('openspec');
});
it('should export correct file name', () => {
expect(GLOBAL_CONFIG_FILE_NAME).toBe('config.json');
});
});
describe('getGlobalConfigDir', () => {
it('should use XDG_CONFIG_HOME when set', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const result = getGlobalConfigDir();
expect(result).toBe(path.join(tempDir, 'openspec'));
});
it('should fall back to ~/.config on Unix/macOS without XDG_CONFIG_HOME', () => {
delete process.env.XDG_CONFIG_HOME;
const result = getGlobalConfigDir();
// On non-Windows, should use ~/.config/openspec
if (os.platform() !== 'win32') {
expect(result).toBe(path.join(os.homedir(), '.config', 'openspec'));
}
});
it('should use APPDATA on Windows when XDG_CONFIG_HOME is not set', () => {
// This test only makes sense conceptually - we can't change os.platform()
// But we can verify the APPDATA logic by checking the code path
if (os.platform() === 'win32') {
delete process.env.XDG_CONFIG_HOME;
const appData = process.env.APPDATA;
if (appData) {
const result = getGlobalConfigDir();
expect(result).toBe(path.join(appData, 'openspec'));
}
}
});
});
describe('getGlobalConfigPath', () => {
it('should return path to config.json in config directory', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const result = getGlobalConfigPath();
expect(result).toBe(path.join(tempDir, 'openspec', 'config.json'));
});
});
describe('getGlobalDataDir', () => {
it('should use POSIX separators for Unix-like platform overrides', () => {
expect(
getGlobalDataDir({
env: {},
platform: 'linux',
homedir: '/home/tabish',
})
).toBe('/home/tabish/.local/share/openspec');
expect(
getGlobalDataDir({
env: { XDG_DATA_HOME: '/var/data' },
platform: 'darwin',
homedir: '/Users/tabish',
})
).toBe('/var/data/openspec');
});
it('should use Windows separators for native Windows platform overrides', () => {
expect(
getGlobalDataDir({
env: {},
platform: 'win32',
homedir: 'C:\\Users\\Tabish',
})
).toBe('C:\\Users\\Tabish\\AppData\\Local\\openspec');
expect(
getGlobalDataDir({
env: { LOCALAPPDATA: 'D:\\Users\\Tabish\\AppData\\Local' },
platform: 'win32',
homedir: 'C:\\Users\\Tabish',
})
).toBe('D:\\Users\\Tabish\\AppData\\Local\\openspec');
});
});
describe('getGlobalConfig', () => {
it('should return defaults when config file does not exist', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const config = getGlobalConfig();
expect(config).toEqual({ featureFlags: {}, profile: 'core', delivery: 'both' });
});
it('should not create directory when reading non-existent config', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
getGlobalConfig();
expect(fs.existsSync(configDir)).toBe(false);
});
it('should load valid config from file', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
featureFlags: { testFlag: true, anotherFlag: false }
}));
const config = getGlobalConfig();
expect(config.featureFlags).toEqual({ testFlag: true, anotherFlag: false });
});
it('should return defaults for invalid JSON', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, '{ invalid json }');
const config = getGlobalConfig();
expect(config).toEqual({ featureFlags: {}, profile: 'core', delivery: 'both' });
});
it('should log warning for invalid JSON', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, '{ invalid json }');
getGlobalConfig();
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid JSON')
);
});
it('should preserve unknown fields from config file', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
featureFlags: { x: true },
unknownField: 'preserved',
futureOption: 123
}));
const config = getGlobalConfig();
expect((config as any).unknownField).toBe('preserved');
expect((config as any).futureOption).toBe(123);
});
it('should merge loaded config with defaults', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
// Config with only some fields
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
featureFlags: { customFlag: true }
}));
const config = getGlobalConfig();
// Should have the custom flag
expect(config.featureFlags?.customFlag).toBe(true);
});
describe('schema evolution', () => {
it('should add default profile and delivery when loading old config without them', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
// Simulate a pre-existing config that only has featureFlags
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
featureFlags: { existingFlag: true }
}));
const config = getGlobalConfig();
expect(config.profile).toBe('core');
expect(config.delivery).toBe('both');
expect(config.workflows).toBeUndefined();
expect(config.featureFlags?.existingFlag).toBe(true);
});
it('should preserve explicit profile and delivery values from config', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
featureFlags: {},
profile: 'custom',
delivery: 'skills',
workflows: ['propose', 'review']
}));
const config = getGlobalConfig();
expect(config.profile).toBe('custom');
expect(config.delivery).toBe('skills');
expect(config.workflows).toEqual(['propose', 'review']);
});
it('should round-trip new fields correctly', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const originalConfig = {
featureFlags: { flag1: true },
profile: 'custom' as Profile,
delivery: 'commands' as Delivery,
workflows: ['propose']
};
saveGlobalConfig(originalConfig);
const loadedConfig = getGlobalConfig();
expect(loadedConfig.profile).toBe('custom');
expect(loadedConfig.delivery).toBe('commands');
expect(loadedConfig.workflows).toEqual(['propose']);
});
it('should default workflows to undefined when not in config', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
featureFlags: {},
profile: 'core',
delivery: 'both'
}));
const config = getGlobalConfig();
expect(config.workflows).toBeUndefined();
});
});
});
describe('saveGlobalConfig', () => {
it('should create directory if it does not exist', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
saveGlobalConfig({ featureFlags: { test: true } });
expect(fs.existsSync(configDir)).toBe(true);
});
it('should write config to file', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configPath = path.join(tempDir, 'openspec', 'config.json');
saveGlobalConfig({ featureFlags: { myFlag: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.featureFlags.myFlag).toBe(true);
});
it('should overwrite existing config file', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configDir = path.join(tempDir, 'openspec');
const configPath = path.join(configDir, 'config.json');
// Create initial config
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ featureFlags: { old: true } }));
// Overwrite
saveGlobalConfig({ featureFlags: { new: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.featureFlags.new).toBe(true);
expect(parsed.featureFlags.old).toBeUndefined();
});
it('should write formatted JSON with trailing newline', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const configPath = path.join(tempDir, 'openspec', 'config.json');
saveGlobalConfig({ featureFlags: {} });
const content = fs.readFileSync(configPath, 'utf-8');
expect(content).toContain('\n');
expect(content.endsWith('\n')).toBe(true);
});
it('should round-trip config correctly', () => {
process.env.XDG_CONFIG_HOME = tempDir;
const originalConfig = {
featureFlags: { flag1: true, flag2: false }
};
saveGlobalConfig(originalConfig);
const loadedConfig = getGlobalConfig();
expect(loadedConfig.featureFlags).toEqual(originalConfig.featureFlags);
});
});
});