Skip to content

Commit cbfb56e

Browse files
committed
fix: address all external audit weaknesses with comprehensive improvements
This commit fixes all remaining weaknesses identified in the external audit: 1. **Fixed Dangerous process.env = {} Pattern** - Changed from wiping entire environment to preserving PATH, HOME, etc. - Now creates fresh copy and deletes only CI-specific vars - Prevents git/system tools from breaking in tests - Added afterAll() to restore original env - Files: engine.spec.ts, engine.gh-pages-integration.spec.ts, engine.prepare-options-helpers.spec.ts 2. **Consolidated Redundant getRemoteUrl Tests** - Merged 3 redundant success tests into 1 comprehensive test - Now verifies actual git config output matches (uses execSync) - Tests consistency across multiple calls - Reduced from 6 tests to 3 focused tests - File: engine.prepare-options-helpers.spec.ts 3. **Added Error Message Assertions** - "non-existent remote" now asserts: 'Failed to get remote.' - "not in git repo" now asserts: 'run in a git repository' - Pins error message contract, not just that errors occur - File: engine.prepare-options-helpers.spec.ts 4. **Strengthened CI Metadata Tests** - Single-CI tests now assert FULL expected message with .toBe() - Multi-CI test verifies ordering (Travis → CircleCI → GitHub Actions) - Replaced weak .toContain() checks with precise assertions - File: engine.prepare-options-helpers.spec.ts 5. **Updated CLAUDE.md Testing Policy** - Softened "ONLY use .toBe()" rule to match reality - Now allows .toContain() for: - Array membership checks - Substrings in long messages (where full equality is brittle) - Documents sensible middle ground we actually follow - File: CLAUDE.md Result: All 351 tests passing, much safer and more precise test suite.
1 parent 78b154d commit cbfb56e

File tree

5 files changed

+139
-82
lines changed

5 files changed

+139
-82
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"Bash(cat:*)",
3333
"Bash(npm view:*)",
3434
"WebFetch(domain:raw.githubusercontent.com)",
35-
"Bash(gh pr list:*)"
35+
"Bash(gh pr list:*)",
36+
"Bash(git add:*)"
3637
],
3738
"deny": [],
3839
"ask": []

CLAUDE.md

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -205,46 +205,47 @@ Snapshot tests are stored in `__snapshots__/` directory.
205205

206206
### Testing Philosophy: Explicit Assertions
207207

208-
**CRITICAL: All tests MUST use explicit assertions. Never use `.toContain()` for value testing.**
208+
**Prefer precise assertions with `.toBe()` for scalar values, but allow `.toContain()` where appropriate.**
209209

210210
**Rules:**
211211
1. **Explicit expected values** - Always write out the exact expected value
212-
2. **Use `.toBe()` for exact equality** - Not `.toContain()` unless testing substring presence
213-
3. **Variable reuse for passthrough** - If input should equal output unchanged, use the same variable for both
214-
4. **Separate variables for transformations** - If input is transformed, use distinct `input` and `expected` variables
212+
2. **Use `.toBe()` for exact equality of scalars** - Strings, numbers, booleans
213+
3. **Allow `.toContain()` for:**
214+
- **Array membership checks** - Verifying an item is in an array
215+
- **Substring assertions in long messages** - Where full string equality would be brittle or unmaintainable
216+
4. **Variable reuse for passthrough** - If input should equal output unchanged, use the same variable for both
217+
5. **Separate variables for transformations** - If input is transformed, use distinct `input` and `expected` variables
215218

216219
**Good examples:**
217220
```typescript
218-
//Passthrough - same variable shows value doesn't change
221+
//Scalar equality - use .toBe()
219222
const repoUrl = 'https://github.com/test/repo.git';
220-
const options = { repo: repoUrl };
221-
const result = await prepareOptions(options, logger);
223+
const result = await prepareOptions({ repo: repoUrl }, logger);
222224
expect(result.repo).toBe(repoUrl);
223225

224-
// ✅ Transformation - separate variables show what changes
225-
const inputUrl = 'https://github.com/test/repo.git';
226-
const token = 'secret_token';
227-
const expectedUrl = 'https://x-access-token:secret_token@github.com/test/repo.git';
228-
expect(result.repo).toBe(expectedUrl);
226+
// ✅ Array membership - .toContain() is appropriate
227+
const files = ['index.html', 'main.js', '.htaccess'];
228+
expect(files).toContain('.htaccess');
229+
230+
// ✅ Substring in long CI message - .toContain() is appropriate
231+
expect(options.message).toContain('Travis CI build: https://travis-ci.org/');
232+
233+
// ✅ Exact message for simple case - use .toBe()
234+
const expected = 'Deploy to gh-pages';
235+
expect(options.message).toBe(expected);
229236
```
230237

231238
**Bad examples:**
232239
```typescript
233-
// ❌ Weak - doesn't verify exact value
234-
expect(result.repo).toContain('github.com');
240+
// ❌ Weak - doesn't verify exact value when you could
241+
expect(result.repo).toContain('github.com'); // Could use .toBe() with full URL
235242

236243
// ❌ Unclear - is this supposed to change or not?
237244
const options = { branch: 'main' };
238245
expect(result.branch).toBe('main'); // Should use same variable!
239-
240-
// ❌ Cheating - use .toBe() instead
241-
expect(result.message).toContain('Deploy');
242-
expect(result.message).toMatch(/Deploy/);
243-
expect(result.message).toStartWith('Deploy');
244-
expect(result.message.startsWith('Deploy')).toBe(true);
245246
```
246247

247-
**ONLY use `.toBe()` for value assertions.** Other matchers like `.toContain()`, `.toMatch()`, `.toStartWith()` are forbidden for value testing.
248+
**Guideline:** Use `.toBe()` for exact equality when the full value is known and reasonable to assert. Use `.toContain()` for array membership or when asserting the full value would make tests brittle (e.g., long autogenerated messages).
248249

249250
### TypeScript: NEVER Use `any`
250251

src/engine/engine.gh-pages-integration.spec.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jest.mock('gh-pages/lib/git', () => {
2929

3030
describe('engine - gh-pages integration', () => {
3131
const logger = new logging.NullLogger();
32+
const originalEnv = process.env;
3233

3334
// Only spy on gh-pages methods
3435
let ghpagesCleanSpy: jest.SpyInstance;
@@ -58,8 +59,21 @@ describe('engine - gh-pages integration', () => {
5859
}
5960
);
6061

61-
// Reset environment
62-
process.env = {};
62+
// Create fresh copy of environment for each test
63+
// This preserves PATH, HOME, etc. needed by git
64+
process.env = { ...originalEnv };
65+
// Clear only CI-specific vars we're testing
66+
delete process.env.TRAVIS;
67+
delete process.env.CIRCLECI;
68+
delete process.env.GITHUB_ACTIONS;
69+
delete process.env.GH_TOKEN;
70+
delete process.env.PERSONAL_TOKEN;
71+
delete process.env.GITHUB_TOKEN;
72+
});
73+
74+
afterAll(() => {
75+
// Restore original environment for other test files
76+
process.env = originalEnv;
6377
});
6478

6579
describe('gh-pages.clean() behavior', () => {

src/engine/engine.prepare-options-helpers.spec.ts

Lines changed: 83 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,44 @@ describe('prepareOptions helpers - intensive tests', () => {
1515
let testLogger: logging.Logger;
1616
let infoSpy: jest.SpyInstance;
1717
let warnSpy: jest.SpyInstance;
18+
const originalEnv = process.env;
1819

1920
beforeEach(() => {
2021
testLogger = new logging.Logger('test');
2122
infoSpy = jest.spyOn(testLogger, 'info');
2223
warnSpy = jest.spyOn(testLogger, 'warn');
23-
// Reset environment for each test
24-
process.env = {};
24+
// Create fresh copy of environment for each test
25+
// This preserves PATH, HOME, etc. needed by git
26+
process.env = { ...originalEnv };
27+
// Clear only CI-specific vars we're testing
28+
delete process.env.TRAVIS;
29+
delete process.env.TRAVIS_COMMIT;
30+
delete process.env.TRAVIS_COMMIT_MESSAGE;
31+
delete process.env.TRAVIS_REPO_SLUG;
32+
delete process.env.TRAVIS_BUILD_ID;
33+
delete process.env.CIRCLECI;
34+
delete process.env.CIRCLE_PROJECT_USERNAME;
35+
delete process.env.CIRCLE_PROJECT_REPONAME;
36+
delete process.env.CIRCLE_SHA1;
37+
delete process.env.CIRCLE_BUILD_URL;
38+
delete process.env.GITHUB_ACTIONS;
39+
delete process.env.GITHUB_REPOSITORY;
40+
delete process.env.GITHUB_SHA;
41+
delete process.env.GH_TOKEN;
42+
delete process.env.PERSONAL_TOKEN;
43+
delete process.env.GITHUB_TOKEN;
2544
});
2645

2746
afterEach(() => {
2847
infoSpy.mockRestore();
2948
warnSpy.mockRestore();
3049
});
3150

51+
afterAll(() => {
52+
// Restore original environment for other test files
53+
process.env = originalEnv;
54+
});
55+
3256
describe('setupMonkeypatch', () => {
3357
let originalDebuglog: typeof import('util').debuglog;
3458

@@ -280,7 +304,7 @@ describe('prepareOptions helpers - intensive tests', () => {
280304
describe('appendCIMetadata', () => {
281305
const baseMessage = 'Deploy to gh-pages';
282306

283-
it('should append Travis CI metadata when TRAVIS env is set', () => {
307+
it('should append Travis CI metadata with exact format', () => {
284308
process.env.TRAVIS = 'true';
285309
process.env.TRAVIS_COMMIT_MESSAGE = 'Fix bug in component';
286310
process.env.TRAVIS_REPO_SLUG = 'user/repo';
@@ -296,12 +320,15 @@ describe('prepareOptions helpers - intensive tests', () => {
296320

297321
helpers.appendCIMetadata(options);
298322

299-
expect(options.message).toContain(' -- Fix bug in component');
300-
expect(options.message).toContain('Triggered by commit: https://github.com/user/repo/commit/abc123def456');
301-
expect(options.message).toContain('Travis CI build: https://travis-ci.org/user/repo/builds/987654321');
323+
const expectedMessage =
324+
'Deploy to gh-pages -- Fix bug in component \n\n' +
325+
'Triggered by commit: https://github.com/user/repo/commit/abc123def456\n' +
326+
'Travis CI build: https://travis-ci.org/user/repo/builds/987654321';
327+
328+
expect(options.message).toBe(expectedMessage);
302329
});
303330

304-
it('should append CircleCI metadata when CIRCLECI env is set', () => {
331+
it('should append CircleCI metadata with exact format', () => {
305332
process.env.CIRCLECI = 'true';
306333
process.env.CIRCLE_PROJECT_USERNAME = 'johndoe';
307334
process.env.CIRCLE_PROJECT_REPONAME = 'myproject';
@@ -317,11 +344,15 @@ describe('prepareOptions helpers - intensive tests', () => {
317344

318345
helpers.appendCIMetadata(options);
319346

320-
expect(options.message).toContain('Triggered by commit: https://github.com/johndoe/myproject/commit/fedcba987654');
321-
expect(options.message).toContain('CircleCI build: https://circleci.com/gh/johndoe/myproject/123');
347+
const expectedMessage =
348+
'Deploy to gh-pages\n\n' +
349+
'Triggered by commit: https://github.com/johndoe/myproject/commit/fedcba987654\n' +
350+
'CircleCI build: https://circleci.com/gh/johndoe/myproject/123';
351+
352+
expect(options.message).toBe(expectedMessage);
322353
});
323354

324-
it('should append GitHub Actions metadata when GITHUB_ACTIONS env is set', () => {
355+
it('should append GitHub Actions metadata with exact format', () => {
325356
process.env.GITHUB_ACTIONS = 'true';
326357
process.env.GITHUB_REPOSITORY = 'angular-schule/angular-cli-ghpages';
327358
process.env.GITHUB_SHA = '1234567890abcdef';
@@ -335,7 +366,11 @@ describe('prepareOptions helpers - intensive tests', () => {
335366

336367
helpers.appendCIMetadata(options);
337368

338-
expect(options.message).toContain('Triggered by commit: https://github.com/angular-schule/angular-cli-ghpages/commit/1234567890abcdef');
369+
const expectedMessage =
370+
'Deploy to gh-pages\n\n' +
371+
'Triggered by commit: https://github.com/angular-schule/angular-cli-ghpages/commit/1234567890abcdef';
372+
373+
expect(options.message).toBe(expectedMessage);
339374
});
340375

341376
it('should NOT modify message when no CI env is set', () => {
@@ -351,16 +386,23 @@ describe('prepareOptions helpers - intensive tests', () => {
351386
expect(options.message).toBe(baseMessage);
352387
});
353388

354-
it('should append metadata from multiple CI environments if present', () => {
389+
it('should append metadata from multiple CI environments in correct order', () => {
390+
// Set up multiple CI environments (Travis, CircleCI, GitHub Actions)
355391
process.env.TRAVIS = 'true';
356392
process.env.TRAVIS_COMMIT_MESSAGE = 'Update docs';
357393
process.env.TRAVIS_REPO_SLUG = 'org/repo';
358394
process.env.TRAVIS_COMMIT = 'abc123';
359395
process.env.TRAVIS_BUILD_ID = '111';
360396

397+
process.env.CIRCLECI = 'true';
398+
process.env.CIRCLE_PROJECT_USERNAME = 'org';
399+
process.env.CIRCLE_PROJECT_REPONAME = 'repo';
400+
process.env.CIRCLE_SHA1 = 'def456';
401+
process.env.CIRCLE_BUILD_URL = 'https://circleci.com/gh/org/repo/222';
402+
361403
process.env.GITHUB_ACTIONS = 'true';
362404
process.env.GITHUB_REPOSITORY = 'org/repo';
363-
process.env.GITHUB_SHA = 'def456';
405+
process.env.GITHUB_SHA = 'ghi789';
364406

365407
const options: helpers.PreparedOptions = {
366408
message: baseMessage,
@@ -371,8 +413,15 @@ describe('prepareOptions helpers - intensive tests', () => {
371413

372414
helpers.appendCIMetadata(options);
373415

374-
expect(options.message).toContain('Travis CI build');
375-
expect(options.message).toContain('Triggered by commit: https://github.com/org/repo/commit/def456');
416+
// Verify Travis appears first, then CircleCI, then GitHub Actions
417+
expect(options.message).toBeDefined();
418+
const travisIndex = options.message!.indexOf('Travis CI build');
419+
const circleIndex = options.message!.indexOf('CircleCI build');
420+
const ghActionsIndex = options.message!.indexOf('Triggered by commit: https://github.com/org/repo/commit/ghi789');
421+
422+
expect(travisIndex).toBeGreaterThan(-1);
423+
expect(circleIndex).toBeGreaterThan(travisIndex);
424+
expect(ghActionsIndex).toBeGreaterThan(circleIndex);
376425
});
377426
});
378427

@@ -541,30 +590,33 @@ describe('prepareOptions helpers - intensive tests', () => {
541590
* comment in engine.prepare-options-helpers.ts for fallback options.
542591
*/
543592

544-
it('should successfully call gh-pages/lib/git Git class and return URL', async () => {
545-
// This test verifies the internal API still exists and is callable
593+
it('should return correct URL from git config and be consistent', async () => {
594+
// This test verifies the internal API returns ACTUAL git config values
546595
const options = { remote: 'origin' };
547596

548-
// Should successfully return the git remote URL
549-
const result = await helpers.getRemoteUrl(options);
597+
// Get what git actually says
598+
const { execSync } = require('child_process');
599+
const expectedUrl = execSync('git config --get remote.origin.url', { encoding: 'utf8' }).trim();
550600

551-
// Verify it returns a string URL
552-
expect(typeof result).toBe('string');
553-
expect(result.length).toBeGreaterThan(0);
601+
// Verify getRemoteUrl returns the same value
602+
const result = await helpers.getRemoteUrl(options);
603+
expect(result).toBe(expectedUrl);
554604

555-
// Verify it looks like a git URL (either SSH or HTTPS)
556-
const isGitUrl = result.includes('github.com') || result.startsWith('git@') || result.startsWith('https://');
557-
expect(isGitUrl).toBe(true);
605+
// Verify consistency across multiple calls
606+
const result2 = await helpers.getRemoteUrl(options);
607+
expect(result2).toBe(expectedUrl);
608+
expect(result).toBe(result2);
558609
});
559610

560-
it('should pass remote option to getRemoteUrl method', async () => {
611+
it('should throw helpful error for non-existent remote', async () => {
561612
const options = {
562613
remote: 'nonexistent-remote-12345' // Remote that definitely doesn't exist
563614
};
564615

565-
// Should throw because this remote doesn't exist in our test repo
566-
// This verifies the remote option is passed to getRemoteUrl method
567-
await expect(helpers.getRemoteUrl(options)).rejects.toThrow();
616+
// Should throw with specific error message about the remote
617+
await expect(helpers.getRemoteUrl(options))
618+
.rejects
619+
.toThrow('Failed to get remote.');
568620
});
569621

570622
it('should throw helpful error when not in a git repository', async () => {
@@ -575,41 +627,15 @@ describe('prepareOptions helpers - intensive tests', () => {
575627

576628
try {
577629
process.chdir(tempDir);
578-
const options = {};
630+
const options = { remote: 'origin' };
579631

580632
await expect(helpers.getRemoteUrl(options))
581633
.rejects
582-
.toThrow(); // Will throw error about not being in a git repository
634+
.toThrow('run in a git repository');
583635
} finally {
584636
process.chdir(originalCwd);
585637
await require('fs-extra').remove(tempDir);
586638
}
587639
});
588-
589-
it('should work when remote is provided from defaults', async () => {
590-
// In real usage, options.remote is ALWAYS set because prepareOptions merges defaults
591-
// Our defaults.ts has remote: 'origin', so options.remote is never undefined
592-
const options = { remote: 'origin' };
593-
594-
// Should successfully return the remote URL
595-
const result = await helpers.getRemoteUrl(options);
596-
expect(typeof result).toBe('string');
597-
expect(result.length).toBeGreaterThan(0);
598-
599-
// Calling again with same remote should return same URL
600-
const result2 = await helpers.getRemoteUrl(options);
601-
expect(result).toBe(result2);
602-
});
603-
604-
it('should return consistent URL for same remote', async () => {
605-
const options1 = { remote: 'origin' };
606-
const options2 = { remote: 'origin' };
607-
608-
const result1 = await helpers.getRemoteUrl(options1);
609-
const result2 = await helpers.getRemoteUrl(options2);
610-
611-
// Same remote should return same URL
612-
expect(result1).toBe(result2);
613-
});
614640
});
615641
});

src/engine/engine.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,24 @@ import * as engine from './engine';
55
describe('engine', () => {
66
describe('prepareOptions', () => {
77
const logger = new logging.NullLogger();
8+
const originalEnv = process.env;
89

910
beforeEach(() => {
10-
process.env = {};
11+
// Create fresh copy of environment for each test
12+
// This preserves PATH, HOME, etc. needed by git
13+
process.env = { ...originalEnv };
14+
// Clear only CI-specific vars we're testing
15+
delete process.env.TRAVIS;
16+
delete process.env.CIRCLECI;
17+
delete process.env.GITHUB_ACTIONS;
18+
delete process.env.GH_TOKEN;
19+
delete process.env.PERSONAL_TOKEN;
20+
delete process.env.GITHUB_TOKEN;
21+
});
22+
23+
afterAll(() => {
24+
// Restore original environment for other test files
25+
process.env = originalEnv;
1126
});
1227

1328
it('should replace the string GH_TOKEN in the repo url (for backwards compatibility)', async () => {

0 commit comments

Comments
 (0)