also looking at this
test: measure coverage, and call the three files nothing called
#1442
4 files
+210
-0
| @@ -59,6 +59,7 @@ | ||
| 59 | 59 | "fresh": "bunx rimraf node_modules/ bun.lock && bun i", |
| 60 | 60 | "prepublishOnly": "bun run build", |
| 61 | 61 | "test": "bun test", |
| 62 | "test:coverage": "bun test --coverage", | |
| 62 | 63 | "lint": "bunx --bun pickier .", |
| 63 | 64 | "lint:fix": "bunx --bun pickier . --fix", |
| 64 | 65 | "format": "bunx --bun pickier . --format", |
| @@ -374,3 +374,75 @@ describe('analysis configuration', () => { | ||
| 374 | 374 | expect(validateConfig({ analysis: { tools: { shellcheck: 'off' as never } } })).toHaveLength(1) |
| 375 | 375 | }) |
| 376 | 376 | }) |
| 377 | ||
| 378 | /** | |
| 379 | * The workflow-audit analyzer had 0% function coverage: the six security | |
| 380 | * rules it wraps are tested directly, but the wrapper — severity mapping, | |
| 381 | * changed-file filtering, the empty-input short-circuit — was not, and the | |
| 382 | * wrapper is what review actually calls. | |
| 383 | */ | |
| 384 | describe('workflow-audit analyzer', () => { | |
| 385 | const cleanups: Array<() => void> = [] | |
| 386 | ||
| 387 | afterEach(() => { | |
| 388 | while (cleanups.length > 0) | |
| 389 | cleanups.pop()?.() | |
| 390 | }) | |
| 391 | ||
| 392 | const RISKY = `name: CI | |
| 393 | on: push | |
| 394 | permissions: write-all | |
| 395 | jobs: | |
| 396 | test: | |
| 397 | runs-on: ubuntu-latest | |
| 398 | steps: | |
| 399 | - run: bun test | |
| 400 | ` | |
| 401 | ||
| 402 | async function repoRoot(): Promise<string> { | |
| 403 | const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs') | |
| 404 | const { tmpdir } = await import('node:os') | |
| 405 | const { join } = await import('node:path') | |
| 406 | ||
| 407 | const dir = mkdtempSync(join(tmpdir(), 'buddy-wf-audit-')) | |
| 408 | cleanups.push(() => rmSync(dir, { recursive: true, force: true })) | |
| 409 | mkdirSync(join(dir, '.github', 'workflows'), { recursive: true }) | |
| 410 | writeFileSync(join(dir, '.github', 'workflows', 'touched.yml'), RISKY) | |
| 411 | writeFileSync(join(dir, '.github', 'workflows', 'untouched.yml'), RISKY) | |
| 412 | return dir | |
| 413 | } | |
| 414 | ||
| 415 | it('success case - findings land as review findings with mapped severities', async () => { | |
| 416 | const { githubActionsAnalyzer } = await import('../src/analysis/analyzers/github-actions') | |
| 417 | const root = await repoRoot() | |
| 418 | ||
| 419 | const findings = await githubActionsAnalyzer.run(['.github/workflows/touched.yml'], root) | |
| 420 | ||
| 421 | expect(findings.length).toBeGreaterThan(0) | |
| 422 | for (const finding of findings) { | |
| 423 | expect(finding.tool).toBe('workflow-audit') | |
| 424 | expect(finding.path).toBe('.github/workflows/touched.yml') | |
| 425 | expect(['critical', 'major', 'minor', 'nit']).toContain(finding.severity) | |
| 426 | } | |
| 427 | // `permissions: write-all` is an error-level rule; errors map to major. | |
| 428 | expect(findings.some(finding => finding.severity === 'major')).toBe(true) | |
| 429 | }) | |
| 430 | ||
| 431 | it('success case - only the changed workflows are reported', async () => { | |
| 432 | // The auditor scans the whole repository; a review must not comment on | |
| 433 | // files the pull request never touched. | |
| 434 | const { githubActionsAnalyzer } = await import('../src/analysis/analyzers/github-actions') | |
| 435 | const root = await repoRoot() | |
| 436 | ||
| 437 | const findings = await githubActionsAnalyzer.run(['.github/workflows/touched.yml'], root) | |
| 438 | ||
| 439 | expect(findings.every(finding => finding.path !== '.github/workflows/untouched.yml')).toBe(true) | |
| 440 | }) | |
| 441 | ||
| 442 | it('edge case - no changed workflows means no audit at all', async () => { | |
| 443 | const { githubActionsAnalyzer } = await import('../src/analysis/analyzers/github-actions') | |
| 444 | ||
| 445 | expect(await githubActionsAnalyzer.run([], '/nonexistent-root')).toEqual([]) | |
| 446 | }) | |
| 447 | }) | |
| 448 | ||
| @@ -0,0 +1,67 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { isPantryLockFile, parsePantryLockFile } from '../src/utils/pantry-parser' | |
| 3 | ||
| 4 | /** | |
| 5 | * 0% function coverage before this file: the parser is wired into the | |
| 6 | * package scanner and was never once called by a test, so a regression here | |
| 7 | * would surface as pantry.lock silently vanishing from the dashboard. | |
| 8 | */ | |
| 9 | describe('pantry-parser', () => { | |
| 10 | describe('isPantryLockFile', () => { | |
| 11 | it('success case - matches the lock file wherever it lives', () => { | |
| 12 | expect(isPantryLockFile('pantry.lock')).toBe(true) | |
| 13 | expect(isPantryLockFile('packages/app/pantry.lock')).toBe(true) | |
| 14 | }) | |
| 15 | ||
| 16 | it('failure case - near-misses are not lock files', () => { | |
| 17 | expect(isPantryLockFile('pantry.lock.bak')).toBe(false) | |
| 18 | expect(isPantryLockFile('pantry.yaml')).toBe(false) | |
| 19 | expect(isPantryLockFile('bun.lock')).toBe(false) | |
| 20 | }) | |
| 21 | }) | |
| 22 | ||
| 23 | describe('parsePantryLockFile', () => { | |
| 24 | const lock = { | |
| 25 | version: '1', | |
| 26 | packages: { | |
| 27 | 'bun.com': { name: 'bun.com', version: '1.2.19', resolved: 'https://example.com/bun' }, | |
| 28 | 'node': { name: 'node', version: '22.3.0', resolved: 'https://example.com/node' }, | |
| 29 | }, | |
| 30 | } | |
| 31 | ||
| 32 | it('success case - extracts every resolved package', async () => { | |
| 33 | const parsed = await parsePantryLockFile('pantry.lock', JSON.stringify(lock)) | |
| 34 | ||
| 35 | expect(parsed?.type).toBe('pantry.lock') | |
| 36 | expect(parsed?.dependencies).toEqual([ | |
| 37 | { name: 'bun.com', currentVersion: '1.2.19', type: 'dependencies', file: 'pantry.lock' }, | |
| 38 | { name: 'node', currentVersion: '22.3.0', type: 'dependencies', file: 'pantry.lock' }, | |
| 39 | ]) | |
| 40 | }) | |
| 41 | ||
| 42 | it('edge case - entries missing a name or version are skipped', async () => { | |
| 43 | const partial = { | |
| 44 | version: '1', | |
| 45 | packages: { | |
| 46 | ok: { name: 'ok', version: '1.0.0', resolved: '' }, | |
| 47 | nameless: { version: '2.0.0', resolved: '' }, | |
| 48 | versionless: { name: 'versionless', resolved: '' }, | |
| 49 | }, | |
| 50 | } | |
| 51 | ||
| 52 | const parsed = await parsePantryLockFile('pantry.lock', JSON.stringify(partial)) | |
| 53 | ||
| 54 | expect(parsed?.dependencies.map(dep => dep.name)).toEqual(['ok']) | |
| 55 | }) | |
| 56 | ||
| 57 | it('edge case - a lock without packages parses to no dependencies', async () => { | |
| 58 | const parsed = await parsePantryLockFile('pantry.lock', JSON.stringify({ version: '1' })) | |
| 59 | ||
| 60 | expect(parsed?.dependencies).toEqual([]) | |
| 61 | }) | |
| 62 | ||
| 63 | it('failure case - malformed JSON returns null rather than throwing', async () => { | |
| 64 | expect(await parsePantryLockFile('pantry.lock', 'not json {')).toBeNull() | |
| 65 | }) | |
| 66 | }) | |
| 67 | }) | |
| @@ -0,0 +1,70 @@ | ||
| 1 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 2 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { loadWorkflows } from '../../src/security/loader' | |
| 6 | ||
| 7 | const VALID = `name: CI | |
| 8 | on: push | |
| 9 | jobs: | |
| 10 | test: | |
| 11 | runs-on: ubuntu-latest | |
| 12 | steps: | |
| 13 | - run: bun test | |
| 14 | ` | |
| 15 | ||
| 16 | /** | |
| 17 | * The loader is the front door of every `buddy security` audit — `audit()` | |
| 18 | * begins with it — and had 0% function coverage: the rules are tested | |
| 19 | * against hand-built fixtures, so a discovery bug (a skipped extension, a | |
| 20 | * traversed subdirectory) would fail every audit while every test stayed | |
| 21 | * green. | |
| 22 | */ | |
| 23 | describe('workflow loader', () => { | |
| 24 | const cleanups: Array<() => void> = [] | |
| 25 | ||
| 26 | afterEach(() => { | |
| 27 | while (cleanups.length > 0) | |
| 28 | cleanups.pop()?.() | |
| 29 | }) | |
| 30 | ||
| 31 | /** A temp repo root, removed after the test. */ | |
| 32 | function root(): string { | |
| 33 | const dir = mkdtempSync(join(tmpdir(), 'buddy-loader-')) | |
| 34 | cleanups.push(() => rmSync(dir, { recursive: true, force: true })) | |
| 35 | return dir | |
| 36 | } | |
| 37 | ||
| 38 | it('success case - finds yml and yaml, and nothing else', async () => { | |
| 39 | const dir = root() | |
| 40 | mkdirSync(join(dir, '.github', 'workflows'), { recursive: true }) | |
| 41 | writeFileSync(join(dir, '.github', 'workflows', 'ci.yml'), VALID) | |
| 42 | writeFileSync(join(dir, '.github', 'workflows', 'release.yaml'), VALID) | |
| 43 | writeFileSync(join(dir, '.github', 'workflows', 'README.md'), '# not a workflow') | |
| 44 | ||
| 45 | const workflows = await loadWorkflows(dir) | |
| 46 | ||
| 47 | expect(workflows.map(workflow => workflow.file).sort()).toEqual([ | |
| 48 | '.github/workflows/ci.yml', | |
| 49 | '.github/workflows/release.yaml', | |
| 50 | ]) | |
| 51 | expect(workflows[0].raw).toContain('runs-on') | |
| 52 | }) | |
| 53 | ||
| 54 | it('edge case - subdirectories are not traversed', async () => { | |
| 55 | // GitHub itself only loads top-level files; a nested yml is a composite | |
| 56 | // action or a template, not a triggered workflow. | |
| 57 | const dir = root() | |
| 58 | mkdirSync(join(dir, '.github', 'workflows', 'templates'), { recursive: true }) | |
| 59 | writeFileSync(join(dir, '.github', 'workflows', 'ci.yml'), VALID) | |
| 60 | writeFileSync(join(dir, '.github', 'workflows', 'templates', 'nested.yml'), VALID) | |
| 61 | ||
| 62 | const workflows = await loadWorkflows(dir) | |
| 63 | ||
| 64 | expect(workflows.map(workflow => workflow.file)).toEqual(['.github/workflows/ci.yml']) | |
| 65 | }) | |
| 66 | ||
| 67 | it('edge case - a repository without workflows loads as empty', async () => { | |
| 68 | expect(await loadWorkflows(root())).toEqual([]) | |
| 69 | }) | |
| 70 | }) | |