Skip to content

Commit 3983329

Browse files
committed
test(cli): add tests for env-helpers and ghsa-tracker
Add comprehensive tests for: - env-helpers: getFixEnv function covering CI detection, GITHUB_REPOSITORY parsing, repo info fallback, PR fetching - ghsa-tracker: file locking mechanism tests including EEXIST handling, stale lock detection, lock cleanup, and error recovery
1 parent 8ad3ee4 commit 3983329

File tree

2 files changed

+328
-2
lines changed

2 files changed

+328
-2
lines changed

packages/cli/test/unit/commands/fix/env-helpers.test.mts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,53 @@ vi.mock('../../../../src/env/socket-cli-git-user-email.mts', () => mockGitEmail)
4848
const mockGitUser = vi.hoisted(() => ({ SOCKET_CLI_GIT_USER_NAME: '' }))
4949
vi.mock('../../../../src/env/socket-cli-git-user-name.mts', () => mockGitUser)
5050

51+
// Mock GITHUB_REPOSITORY.
52+
const mockGithubRepo = vi.hoisted(() => ({ GITHUB_REPOSITORY: '' }))
53+
vi.mock('../../../../src/env/github-repository.mts', () => mockGithubRepo)
54+
55+
// Mock git operations.
56+
const mockGetBaseBranch = vi.hoisted(() =>
57+
vi.fn().mockResolvedValue('main'),
58+
)
59+
const mockGetRepoInfo = vi.hoisted(() =>
60+
vi.fn().mockResolvedValue({ owner: 'test-owner', repo: 'test-repo' }),
61+
)
62+
vi.mock('../../../../src/utils/git/operations.mts', () => ({
63+
getBaseBranch: mockGetBaseBranch,
64+
getRepoInfo: mockGetRepoInfo,
65+
}))
66+
67+
// Mock pull-request functions.
68+
const mockGetSocketFixPrs = vi.hoisted(() => vi.fn().mockResolvedValue([]))
69+
vi.mock('../../../../src/commands/fix/pull-request.mts', () => ({
70+
getSocketFixPrs: mockGetSocketFixPrs,
71+
}))
72+
73+
// Mock logger.
74+
const mockLogger = vi.hoisted(() => ({
75+
error: vi.fn(),
76+
fail: vi.fn(),
77+
info: vi.fn(),
78+
log: vi.fn(),
79+
success: vi.fn(),
80+
warn: vi.fn(),
81+
}))
82+
vi.mock('@socketsecurity/lib/logger', () => ({
83+
getDefaultLogger: () => mockLogger,
84+
}))
85+
86+
// Mock debug.
87+
const mockDebug = vi.hoisted(() => vi.fn())
88+
const mockIsDebug = vi.hoisted(() => vi.fn(() => false))
89+
vi.mock('@socketsecurity/lib/debug', () => ({
90+
debug: mockDebug,
91+
isDebug: mockIsDebug,
92+
}))
93+
5194
import {
5295
checkCiEnvVars,
5396
getCiEnvInstructions,
97+
getFixEnv,
5498
} from '../../../../src/commands/fix/env-helpers.mts'
5599

56100
describe('env-helpers', () => {
@@ -60,6 +104,11 @@ describe('env-helpers', () => {
60104
mockGetSocketCliGithubToken.mockReturnValue(undefined)
61105
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = ''
62106
mockGitUser.SOCKET_CLI_GIT_USER_NAME = ''
107+
mockGithubRepo.GITHUB_REPOSITORY = ''
108+
mockGetBaseBranch.mockResolvedValue('main')
109+
mockGetRepoInfo.mockResolvedValue({ owner: 'test-owner', repo: 'test-repo' })
110+
mockGetSocketFixPrs.mockResolvedValue([])
111+
mockIsDebug.mockReturnValue(false)
63112
})
64113

65114
describe('getCiEnvInstructions', () => {
@@ -159,4 +208,156 @@ describe('env-helpers', () => {
159208
expect(result.present).toHaveLength(4)
160209
})
161210
})
211+
212+
describe('getFixEnv', () => {
213+
it('should return basic fix env when not in CI', async () => {
214+
mockGetCI.mockReturnValue(false)
215+
216+
const result = await getFixEnv()
217+
218+
expect(result.isCi).toBe(false)
219+
expect(result.baseBranch).toBe('main')
220+
expect(result.prs).toEqual([])
221+
expect(result.repoInfo).toEqual({ owner: 'test-owner', repo: 'test-repo' })
222+
})
223+
224+
it('should return isCi true when all CI vars are set', async () => {
225+
mockGetCI.mockReturnValue(true)
226+
mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token')
227+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user'
228+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com'
229+
mockGithubRepo.GITHUB_REPOSITORY = 'owner/repo'
230+
231+
const result = await getFixEnv()
232+
233+
expect(result.isCi).toBe(true)
234+
expect(result.gitUser).toBe('test-user')
235+
expect(result.gitEmail).toBe('test@example.com')
236+
expect(result.githubToken).toBe('ghp_test_token')
237+
})
238+
239+
it('should use GITHUB_REPOSITORY env var for repoInfo in CI', async () => {
240+
mockGetCI.mockReturnValue(true)
241+
mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token')
242+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user'
243+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com'
244+
mockGithubRepo.GITHUB_REPOSITORY = 'my-owner/my-repo'
245+
246+
const result = await getFixEnv()
247+
248+
expect(result.repoInfo).toEqual({ owner: 'my-owner', repo: 'my-repo' })
249+
// Should not call getRepoInfo when GITHUB_REPOSITORY is valid.
250+
expect(mockGetRepoInfo).not.toHaveBeenCalled()
251+
})
252+
253+
it('should fall back to getRepoInfo when GITHUB_REPOSITORY is invalid', async () => {
254+
mockGetCI.mockReturnValue(true)
255+
mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token')
256+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user'
257+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com'
258+
// Invalid GITHUB_REPOSITORY (no slash).
259+
mockGithubRepo.GITHUB_REPOSITORY = 'invalid-repo'
260+
261+
const result = await getFixEnv()
262+
263+
expect(result.repoInfo).toEqual({ owner: 'test-owner', repo: 'test-repo' })
264+
expect(mockGetRepoInfo).toHaveBeenCalled()
265+
})
266+
267+
it('should fall back to getRepoInfo when GITHUB_REPOSITORY is empty', async () => {
268+
mockGetCI.mockReturnValue(true)
269+
mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token')
270+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user'
271+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com'
272+
mockGithubRepo.GITHUB_REPOSITORY = ''
273+
274+
const result = await getFixEnv()
275+
276+
expect(mockGetRepoInfo).toHaveBeenCalled()
277+
})
278+
279+
it('should warn when CI is set but other vars are missing', async () => {
280+
mockGetCI.mockReturnValue(true)
281+
// Missing: githubToken, gitUser, gitEmail.
282+
mockGetSocketCliGithubToken.mockReturnValue(undefined)
283+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = ''
284+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = ''
285+
286+
await getFixEnv()
287+
288+
expect(mockLogger.warn).toHaveBeenCalledWith(
289+
expect.stringContaining('CI mode detected'),
290+
)
291+
expect(mockLogger.warn).toHaveBeenCalledWith(
292+
expect.stringContaining('Missing:'),
293+
)
294+
})
295+
296+
it('should not warn when not in CI', async () => {
297+
mockGetCI.mockReturnValue(false)
298+
299+
await getFixEnv()
300+
301+
expect(mockLogger.warn).not.toHaveBeenCalled()
302+
})
303+
304+
it('should log debug message when not in CI but some vars are set', async () => {
305+
mockGetCI.mockReturnValue(false)
306+
mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token')
307+
mockIsDebug.mockReturnValue(true)
308+
309+
await getFixEnv()
310+
311+
expect(mockDebug).toHaveBeenCalledWith(
312+
expect.stringContaining('isCi is false'),
313+
)
314+
})
315+
316+
it('should fetch PRs when in CI mode', async () => {
317+
mockGetCI.mockReturnValue(true)
318+
mockGetSocketCliGithubToken.mockReturnValue('ghp_test_token')
319+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'test-user'
320+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'test@example.com'
321+
mockGithubRepo.GITHUB_REPOSITORY = 'owner/repo'
322+
mockGetSocketFixPrs.mockResolvedValue([
323+
{ number: 1, title: 'Fix PR' },
324+
])
325+
326+
const result = await getFixEnv()
327+
328+
expect(mockGetSocketFixPrs).toHaveBeenCalledWith('owner', 'repo', {
329+
author: 'test-user',
330+
states: 'all',
331+
})
332+
expect(result.prs).toEqual([{ number: 1, title: 'Fix PR' }])
333+
})
334+
335+
it('should not fetch PRs when not in CI mode', async () => {
336+
mockGetCI.mockReturnValue(false)
337+
338+
await getFixEnv()
339+
340+
expect(mockGetSocketFixPrs).not.toHaveBeenCalled()
341+
})
342+
343+
it('should return gitEmail and gitUser from env vars', async () => {
344+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = 'custom-user'
345+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = 'custom@example.com'
346+
347+
const result = await getFixEnv()
348+
349+
expect(result.gitUser).toBe('custom-user')
350+
expect(result.gitEmail).toBe('custom@example.com')
351+
})
352+
353+
it('should return undefined for gitEmail and gitUser when not set', async () => {
354+
mockGitUser.SOCKET_CLI_GIT_USER_NAME = ''
355+
mockGitEmail.SOCKET_CLI_GIT_USER_EMAIL = ''
356+
357+
const result = await getFixEnv()
358+
359+
expect(result.gitUser).toBe('')
360+
expect(result.gitEmail).toBe('')
361+
})
362+
})
162363
})

packages/cli/test/unit/commands/fix/ghsa-tracker.test.mts

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,21 @@ const mockReadJson = vi.hoisted(() => vi.fn())
4646
const mockSafeMkdir = vi.hoisted(() => vi.fn())
4747
const mockWriteJson = vi.hoisted(() => vi.fn())
4848

49+
// Mock fs promises.
50+
const mockFsWriteFile = vi.hoisted(() => vi.fn())
51+
const mockFsReadFile = vi.hoisted(() => vi.fn())
52+
const mockFsUnlink = vi.hoisted(() => vi.fn())
53+
4954
vi.mock('node:fs', async () => {
5055
const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
5156
return {
5257
...actual,
5358
promises: {
5459
...actual.promises,
5560
mkdir: vi.fn(),
56-
readFile: vi.fn(),
57-
writeFile: vi.fn(),
61+
readFile: mockFsReadFile,
62+
writeFile: mockFsWriteFile,
63+
unlink: mockFsUnlink,
5864
},
5965
}
6066
})
@@ -71,6 +77,10 @@ describe('ghsa-tracker', () => {
7177

7278
beforeEach(() => {
7379
vi.clearAllMocks()
80+
// Default: lock file creation succeeds.
81+
mockFsWriteFile.mockResolvedValue(undefined)
82+
mockFsReadFile.mockResolvedValue('12345')
83+
mockFsUnlink.mockResolvedValue(undefined)
7484
})
7585

7686
describe('loadGhsaTracker', () => {
@@ -364,5 +374,120 @@ describe('ghsa-tracker', () => {
364374
expect(record).toBeDefined()
365375
expect(record!.prNumber).toBeUndefined()
366376
})
377+
378+
it('handles lock file already exists (EEXIST)', async () => {
379+
const existingTracker: GhsaTracker = {
380+
version: 1,
381+
fixed: [],
382+
}
383+
384+
// First call to writeFile fails with EEXIST, subsequent succeeds.
385+
const eexistError = new Error('Lock exists') as NodeJS.ErrnoException
386+
eexistError.code = 'EEXIST'
387+
mockFsWriteFile.mockRejectedValueOnce(eexistError)
388+
mockFsWriteFile.mockResolvedValueOnce(undefined)
389+
390+
// Mock reading lock file to show stale lock (dead process).
391+
mockFsReadFile.mockResolvedValueOnce('99999999')
392+
393+
mockReadJson.mockResolvedValue(existingTracker)
394+
395+
await markGhsaFixed(mockCwd, 'GHSA-lock-test', 123)
396+
397+
// Should still save the tracker.
398+
expect(mockWriteJson).toHaveBeenCalled()
399+
})
400+
401+
it('handles lock file read error', async () => {
402+
const existingTracker: GhsaTracker = {
403+
version: 1,
404+
fixed: [],
405+
}
406+
407+
// First call to writeFile fails with EEXIST.
408+
const eexistError = new Error('Lock exists') as NodeJS.ErrnoException
409+
eexistError.code = 'EEXIST'
410+
mockFsWriteFile.mockRejectedValueOnce(eexistError)
411+
mockFsWriteFile.mockResolvedValueOnce(undefined)
412+
413+
// Mock reading lock file fails.
414+
mockFsReadFile.mockRejectedValueOnce(new Error('Read error'))
415+
416+
mockReadJson.mockResolvedValue(existingTracker)
417+
418+
await markGhsaFixed(mockCwd, 'GHSA-lock-read-error', 123)
419+
420+
// Should still save the tracker (proceeds without lock).
421+
expect(mockWriteJson).toHaveBeenCalled()
422+
})
423+
424+
it('handles lock file with invalid PID', async () => {
425+
const existingTracker: GhsaTracker = {
426+
version: 1,
427+
fixed: [],
428+
}
429+
430+
// First call to writeFile fails with EEXIST.
431+
const eexistError = new Error('Lock exists') as NodeJS.ErrnoException
432+
eexistError.code = 'EEXIST'
433+
mockFsWriteFile.mockRejectedValueOnce(eexistError)
434+
mockFsWriteFile.mockResolvedValueOnce(undefined)
435+
436+
// Mock reading lock file with invalid PID.
437+
mockFsReadFile.mockResolvedValueOnce('not-a-number')
438+
439+
mockReadJson.mockResolvedValue(existingTracker)
440+
441+
await markGhsaFixed(mockCwd, 'GHSA-invalid-pid', 123)
442+
443+
// Should proceed anyway.
444+
expect(mockWriteJson).toHaveBeenCalled()
445+
})
446+
447+
it('releases lock after successful operation', async () => {
448+
const existingTracker: GhsaTracker = {
449+
version: 1,
450+
fixed: [],
451+
}
452+
453+
mockReadJson.mockResolvedValue(existingTracker)
454+
455+
await markGhsaFixed(mockCwd, 'GHSA-release-lock', 123)
456+
457+
// Should attempt to unlink the lock file.
458+
expect(mockFsUnlink).toHaveBeenCalled()
459+
})
460+
461+
it('handles lock cleanup error gracefully', async () => {
462+
const existingTracker: GhsaTracker = {
463+
version: 1,
464+
fixed: [],
465+
}
466+
467+
mockReadJson.mockResolvedValue(existingTracker)
468+
mockFsUnlink.mockRejectedValueOnce(new Error('Cleanup error'))
469+
470+
// Should not throw.
471+
await expect(
472+
markGhsaFixed(mockCwd, 'GHSA-cleanup-error', 123),
473+
).resolves.toBeUndefined()
474+
})
475+
476+
it('proceeds without lock when all attempts fail', async () => {
477+
const existingTracker: GhsaTracker = {
478+
version: 1,
479+
fixed: [],
480+
}
481+
482+
// All lock attempts fail with non-EEXIST error.
483+
mockFsWriteFile.mockRejectedValue(new Error('Permission denied'))
484+
485+
mockReadJson.mockResolvedValue(existingTracker)
486+
487+
await markGhsaFixed(mockCwd, 'GHSA-no-lock', 123)
488+
489+
// Should still save the tracker.
490+
expect(mockWriteJson).toHaveBeenCalled()
491+
})
367492
})
368493
})

0 commit comments

Comments
 (0)