From 15717d6d04a4c7c71cfa730443d6fa0c2c5c8dc5 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 13 Feb 2026 11:20:44 -0800 Subject: [PATCH] test: cover whitespace-only frontmatter field, empty SKILL.md, and getAllSessions TOCTOU symlink --- tests/ci/validators.test.js | 34 +++++++++++++++++++++++++ tests/lib/session-manager.test.js | 41 +++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/tests/ci/validators.test.js b/tests/ci/validators.test.js index eb2a2702..6077a389 100644 --- a/tests/ci/validators.test.js +++ b/tests/ci/validators.test.js @@ -2105,6 +2105,40 @@ function runTests() { cleanupTestDir(testDir); })) passed++; else failed++; + // ── Round 83: validate-agents whitespace-only field, validate-skills empty SKILL.md ── + + console.log('\nRound 83: validate-agents (whitespace-only frontmatter field value):'); + + if (test('rejects agent with whitespace-only model field (trim guard)', () => { + const testDir = createTestDir(); + // model has only whitespace — extractFrontmatter produces { model: ' ', tools: 'Read' } + // The condition: typeof frontmatter[field] === 'string' && !frontmatter[field].trim() + // evaluates to true for model → "Missing required field: model" + fs.writeFileSync(path.join(testDir, 'ws.md'), '---\nmodel: \ntools: Read\n---\n# Whitespace model'); + + const result = runValidatorWithDir('validate-agents', 'AGENTS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should reject whitespace-only model'); + assert.ok(result.stderr.includes('model'), 'Should report missing model field'); + assert.ok(!result.stderr.includes('tools'), 'tools field is valid and should NOT be flagged'); + cleanupTestDir(testDir); + })) passed++; else failed++; + + console.log('\nRound 83: validate-skills (empty SKILL.md file):'); + + if (test('rejects skill directory with empty SKILL.md file', () => { + const testDir = createTestDir(); + const skillDir = path.join(testDir, 'empty-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + // Create SKILL.md with only whitespace (trim to zero length) + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), ' \n \n'); + + const result = runValidatorWithDir('validate-skills', 'SKILLS_DIR', testDir); + assert.strictEqual(result.code, 1, 'Should reject empty SKILL.md'); + assert.ok(result.stderr.includes('Empty file'), + `Should report "Empty file", got: ${result.stderr}`); + cleanupTestDir(testDir); + })) passed++; else failed++; + // Summary console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/session-manager.test.js b/tests/lib/session-manager.test.js index f174a30e..56f03281 100644 --- a/tests/lib/session-manager.test.js +++ b/tests/lib/session-manager.test.js @@ -1308,6 +1308,47 @@ src/main.ts assert.strictEqual(stats.hasContext, false, 'null input should yield hasContext false'); })) passed++; else failed++; + // ── Round 83: getAllSessions TOCTOU statSync catch (broken symlink) ── + console.log('\nRound 83: getAllSessions (broken symlink — statSync catch):'); + + if (test('getAllSessions skips broken symlink .tmp files gracefully', () => { + // getAllSessions at line 241-246: statSync throws for broken symlinks, + // the catch causes `continue`, skipping that entry entirely. + const isoHome = path.join(os.tmpdir(), `ecc-r83-toctou-${Date.now()}`); + const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Create one real session file + const realFile = '2026-02-10-abcd1234-session.tmp'; + fs.writeFileSync(path.join(sessionsDir, realFile), '# Real session\n'); + + // Create a broken symlink that matches the session filename pattern + const brokenSymlink = '2026-02-10-deadbeef-session.tmp'; + fs.symlinkSync('/nonexistent/path/that/does/not/exist', path.join(sessionsDir, brokenSymlink)); + + const origHome = process.env.HOME; + const origUserProfile = process.env.USERPROFILE; + process.env.HOME = isoHome; + process.env.USERPROFILE = isoHome; + try { + delete require.cache[require.resolve('../../scripts/lib/session-manager')]; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + const freshManager = require('../../scripts/lib/session-manager'); + const result = freshManager.getAllSessions({ limit: 100 }); + + // Should have only the real session, not the broken symlink + assert.strictEqual(result.total, 1, 'Should find only the real session, not the broken symlink'); + assert.ok(result.sessions[0].filename === realFile, + `Should return the real file, got: ${result.sessions[0].filename}`); + } finally { + process.env.HOME = origHome; + process.env.USERPROFILE = origUserProfile; + delete require.cache[require.resolve('../../scripts/lib/session-manager')]; + delete require.cache[require.resolve('../../scripts/lib/utils')]; + fs.rmSync(isoHome, { recursive: true, force: true }); + } + })) passed++; else failed++; + // Summary console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0);