Skip to content

Commit f6c0ef2

Browse files
committed
test(releases): mark TOCTOU describe as .sequential to eliminate parallel-test flake
The TOCTOU race-protection tests in releases-github.test.mts shared the module-scoped httpDownload / httpRequest mocks across three test cases. vitest's local config sets concurrent: !CI in .config/vitest.config.mts, so local runs executed those three tests in parallel within the same vitest worker. The cross-test mock-call bleed surfaced intermittently as 'called N times' assertions seeing a sibling test's invocation count. Rewrite the three cases to: - Mark the describe '.sequential', matching the pattern from test/unit/logger-advanced.test.mts which already handles the same concern for the Logger suite. - Pre-populate the on-disk cache synchronously (binary file + .version file with chosen tag) before calling downloadGitHubRelease, instead of relying on a prior downloadGitHubRelease call + async httpDownload mock side effects to establish cache state. This makes the test's observable state fully deterministic at the assertion boundary. - Add a third case covering the version-tag-mismatch invalidation path (was implicitly untested). - Keep all per-test state — temp dir, dynamic imports — local to each it callback rather than hoisted to the describe. With isolate: false across the vitest worker pool (.config/vitest.config.mts) a describe-scoped 'let testDir' binding would be overwritten by the next test's beforeEach, making assertion messages print the wrong path. - Use per-test mkdtemp names ('-', '-missing-', '-stale-') so parallel vitest workers running different files see disjoint temp dirs even under load. Verified: full pnpm test (144 files, 6328 passing + 31 skipped) now passes deterministically across back-to-back runs.
1 parent cc10026 commit f6c0ef2

1 file changed

Lines changed: 132 additions & 140 deletions

File tree

test/unit/releases-github.test.mts

Lines changed: 132 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -819,49 +819,102 @@ describe('releases/github', () => {
819819
}, 40_000)
820820
})
821821

822-
describe('downloadGitHubRelease - TOCTOU race protection', () => {
822+
// .sequential is required because vitest's config sets
823+
// `concurrent: !process.env.CI` (.config/vitest.config.mts). These
824+
// tests share the module-level httpDownload / httpRequest mocks so
825+
// running them in parallel lets one test's call pollute another's
826+
// `toHaveBeenCalledTimes` assertion. Pattern borrowed from
827+
// test/unit/logger-advanced.test.mts which disables concurrency for
828+
// the same reason.
829+
describe.sequential('downloadGitHubRelease - TOCTOU race protection', () => {
830+
// Pre-populate the on-disk cache state synchronously before calling
831+
// downloadGitHubRelease, so the function under test can only
832+
// observe the state the test prepared. httpDownload is either
833+
// asserted-never-called (cache-hit path) or mocked as a trivial
834+
// no-op that writes the missing file for the re-download path.
835+
// No mock side effects interleave with the call under test.
836+
837+
// All state (temp dir, imports) lives inside each `it` block so
838+
// nothing leaks across tests under `isolate: false`. The previous
839+
// rewrite used `let testDir` at the describe scope and flaked
840+
// because that single binding got overwritten by the next
841+
// test's beforeEach while vitest was still reporting the first
842+
// test's assertion. Using fully local state makes that impossible.
823843
beforeEach(() => {
824844
vi.clearAllMocks()
825845
})
826846

827-
it('should re-check binary existence after reading version file (TOCTOU protection)', async () => {
828-
// This tests the fix for the TOCTOU (Time-of-check-time-of-use) race condition
829-
// in releases/github.ts:214-226
830-
//
831-
// Scenario:
832-
// 1. Check version file exists and matches tag
833-
// 2. Read version file
834-
// 3. Binary gets deleted (by cleanup or concurrent process)
835-
// 4. Try to use binary -> FAILURE
836-
//
837-
// Fix: Re-check binary existence after reading version file
838-
847+
it('uses cache and does not call httpDownload when binary + version file exist and tag matches', async () => {
839848
const { downloadGitHubRelease } =
840849
await import('../../src/releases/github')
841-
const { tmpdir } = await import('node:os')
842850
const { promises: fs } = await import('node:fs')
843-
const path = await import('node:path')
844-
845-
// mkdtemp gives an OS-guaranteed unique dir. Previous `Date.now()`
846-
// naming collided across test runs on the same millisecond and
847-
// across parallel vitest workers, occasionally letting a prior
848-
// run's cached .version + binary satisfy the cache-hit branch
849-
// inside downloadGitHubRelease — so `httpDownload` was called 0
850-
// times and the assertion below saw the wrong count.
851-
const testDownloadDir = await fs.mkdtemp(
852-
path.join(tmpdir(), 'test-github-dl-'),
851+
const { tmpdir } = await import('node:os')
852+
const nodePath = await import('node:path')
853+
854+
const testDir = await fs.mkdtemp(
855+
nodePath.join(tmpdir(), 'test-github-dl-'),
853856
)
857+
try {
858+
// Pre-populate the cache exactly as a prior successful download
859+
// would have left it: binary + .version file with matching tag.
860+
// downloadGitHubRelease must short-circuit to the cached binary
861+
// path without touching httpDownload.
862+
const binaryFile = nodePath.join(testDir, 'test-bin')
863+
const versionFile = nodePath.join(testDir, '.version')
864+
await fs.writeFile(binaryFile, '#!/bin/bash\necho "test"', 'utf8')
865+
await fs.writeFile(versionFile, 'v1.0.0', 'utf8')
866+
867+
const result = await downloadGitHubRelease({
868+
assetName: 'test-binary',
869+
binaryName: 'test-bin',
870+
downloadDir: testDir,
871+
owner: 'test-owner',
872+
platformArch: 'test-arch',
873+
quiet: true,
874+
repo: 'test-repo',
875+
tag: 'v1.0.0',
876+
toolName: 'test-tool',
877+
})
878+
879+
expect(result).toBe(binaryFile)
880+
expect(httpDownload).not.toHaveBeenCalled()
881+
expect(httpRequest).not.toHaveBeenCalled()
882+
expect(existsSync(binaryFile)).toBe(true)
883+
expect(existsSync(versionFile)).toBe(true)
884+
} finally {
885+
await fs.rm(testDir, { force: true, recursive: true }).catch(() => {})
886+
}
887+
})
854888

889+
it('re-downloads when version file exists but binary is missing (TOCTOU recovery)', async () => {
890+
const { downloadGitHubRelease } =
891+
await import('../../src/releases/github')
892+
const { promises: fs } = await import('node:fs')
893+
const { tmpdir } = await import('node:os')
894+
const nodePath = await import('node:path')
895+
896+
const testDir = await fs.mkdtemp(
897+
nodePath.join(tmpdir(), 'test-github-dl-missing-'),
898+
)
855899
try {
856-
// Mock successful HTTP download
857-
vi.mocked(httpRequest).mockResolvedValue(
900+
// The TOCTOU recovery path: version file claims the right tag
901+
// is cached, but the binary was removed (by OS cleanup, another
902+
// process, manual rm, etc.). The second existsSync check inside
903+
// downloadGitHubRelease must detect the missing binary after
904+
// reading the version file and fall through to re-download.
905+
const binaryFile = nodePath.join(testDir, 'test-bin')
906+
const versionFile = nodePath.join(testDir, '.version')
907+
await fs.writeFile(versionFile, 'v1.0.0', 'utf8')
908+
// Intentionally do NOT create binaryFile.
909+
910+
vi.mocked(httpRequest).mockResolvedValueOnce(
858911
createMockHttpResponse(
859912
Buffer.from(
860913
JSON.stringify({
861914
assets: [
862915
{
863-
name: 'test-binary',
864916
browser_download_url: 'https://example.com/binary',
917+
name: 'test-binary',
865918
},
866919
],
867920
tag_name: 'v1.0.0',
@@ -871,54 +924,24 @@ describe('releases/github', () => {
871924
200,
872925
),
873926
)
874-
// Mock httpDownload to actually create the file
875-
vi.mocked(httpDownload).mockImplementation(async (_url, outputPath) => {
876-
// Create directory if it doesn't exist
877-
const dir = path.dirname(outputPath)
878-
await fs.mkdir(dir, { recursive: true })
879-
// Create a dummy binary file
880-
const content = '#!/bin/bash\necho "test"'
881-
await fs.writeFile(outputPath, content, 'utf8')
882-
return {
883-
headers: {},
884-
ok: true as const,
885-
path: outputPath,
886-
size: content.length,
887-
status: 200,
888-
statusText: 'OK',
889-
}
890-
})
891-
892-
// First download - creates cache
893-
const result1 = await downloadGitHubRelease({
894-
assetName: 'test-binary',
895-
binaryName: 'test-bin',
896-
downloadDir: testDownloadDir,
897-
owner: 'test-owner',
898-
platformArch: 'test-arch',
899-
quiet: true,
900-
repo: 'test-repo',
901-
tag: 'v1.0.0',
902-
toolName: 'test-tool',
903-
})
904-
905-
expect(result1).toBeDefined()
906-
expect(result1).toContain('test-bin')
907-
908-
// Verify version file and binary exist
909-
const binaryDir = testDownloadDir
910-
const versionFile = path.join(binaryDir, '.version')
911-
const binaryFile = path.join(binaryDir, 'test-bin')
912-
913-
// Both should exist after first download
914-
expect(existsSync(versionFile)).toBe(true)
915-
expect(existsSync(binaryFile)).toBe(true)
927+
vi.mocked(httpDownload).mockImplementationOnce(
928+
async (_url, outputPath) => {
929+
await fs.writeFile(outputPath, '#!/bin/bash\necho "test"', 'utf8')
930+
return {
931+
headers: {},
932+
ok: true as const,
933+
path: outputPath,
934+
size: 22,
935+
status: 200,
936+
statusText: 'OK',
937+
}
938+
},
939+
)
916940

917-
// Second call - should use cache
918-
const result2 = await downloadGitHubRelease({
941+
const result = await downloadGitHubRelease({
919942
assetName: 'test-binary',
920943
binaryName: 'test-bin',
921-
downloadDir: testDownloadDir,
944+
downloadDir: testDir,
922945
owner: 'test-owner',
923946
platformArch: 'test-arch',
924947
quiet: true,
@@ -927,42 +950,41 @@ describe('releases/github', () => {
927950
toolName: 'test-tool',
928951
})
929952

930-
expect(result2).toBe(result1)
931-
// httpDownload should only be called once (first download)
953+
expect(result).toBe(binaryFile)
932954
expect(httpDownload).toHaveBeenCalledTimes(1)
955+
expect(existsSync(binaryFile)).toBe(true)
933956
} finally {
934-
// Cleanup
935-
try {
936-
await fs.rm(testDownloadDir, { force: true, recursive: true })
937-
} catch {
938-
// Ignore cleanup errors
939-
}
957+
await fs.rm(testDir, { force: true, recursive: true }).catch(() => {})
940958
}
941-
}, 40_000)
959+
})
942960

943-
it('should re-download if binary is missing despite version file existing', async () => {
961+
it('re-downloads when version file tag does not match requested tag', async () => {
944962
const { downloadGitHubRelease } =
945963
await import('../../src/releases/github')
946-
const { tmpdir } = await import('node:os')
947964
const { promises: fs } = await import('node:fs')
948-
const path = await import('node:path')
965+
const { tmpdir } = await import('node:os')
966+
const nodePath = await import('node:path')
949967

950-
// mkdtemp avoids collisions with prior test runs' leftover cache
951-
// dirs. See note on the sibling test above.
952-
const testDownloadDir = await fs.mkdtemp(
953-
path.join(tmpdir(), 'test-github-dl-missing-'),
968+
const testDir = await fs.mkdtemp(
969+
nodePath.join(tmpdir(), 'test-github-dl-stale-'),
954970
)
955-
956971
try {
957-
// Mock successful HTTP download
958-
vi.mocked(httpRequest).mockResolvedValue(
972+
// Cache-invalidation path: both files present but .version
973+
// says a different tag than the caller asked for. Must fall
974+
// through to re-download.
975+
const binaryFile = nodePath.join(testDir, 'test-bin')
976+
const versionFile = nodePath.join(testDir, '.version')
977+
await fs.writeFile(binaryFile, 'stale-binary', 'utf8')
978+
await fs.writeFile(versionFile, 'v0.9.0', 'utf8')
979+
980+
vi.mocked(httpRequest).mockResolvedValueOnce(
959981
createMockHttpResponse(
960982
Buffer.from(
961983
JSON.stringify({
962984
assets: [
963985
{
964-
name: 'test-binary',
965986
browser_download_url: 'https://example.com/binary',
987+
name: 'test-binary',
966988
},
967989
],
968990
tag_name: 'v1.0.0',
@@ -972,50 +994,24 @@ describe('releases/github', () => {
972994
200,
973995
),
974996
)
975-
// Mock httpDownload to actually create the file
976-
vi.mocked(httpDownload).mockImplementation(async (_url, outputPath) => {
977-
// Create directory if it doesn't exist
978-
const dir = path.dirname(outputPath)
979-
await fs.mkdir(dir, { recursive: true })
980-
// Create a dummy binary file
981-
const content = '#!/bin/bash\necho "test"'
982-
await fs.writeFile(outputPath, content, 'utf8')
983-
return {
984-
headers: {},
985-
ok: true as const,
986-
path: outputPath,
987-
size: content.length,
988-
status: 200,
989-
statusText: 'OK',
990-
}
991-
})
992-
993-
// First download
994-
await downloadGitHubRelease({
995-
assetName: 'test-binary',
996-
binaryName: 'test-bin',
997-
downloadDir: testDownloadDir,
998-
owner: 'test-owner',
999-
platformArch: 'test-arch',
1000-
quiet: true,
1001-
repo: 'test-repo',
1002-
tag: 'v1.0.0',
1003-
toolName: 'test-tool',
1004-
})
1005-
1006-
// Simulate TOCTOU scenario: delete binary but leave version file
1007-
const binaryDir = testDownloadDir
1008-
const binaryFile = path.join(binaryDir, 'test-bin')
1009-
await fs.unlink(binaryFile)
1010-
1011-
// Reset mock call count
1012-
vi.mocked(httpDownload).mockClear()
997+
vi.mocked(httpDownload).mockImplementationOnce(
998+
async (_url, outputPath) => {
999+
await fs.writeFile(outputPath, 'fresh-binary', 'utf8')
1000+
return {
1001+
headers: {},
1002+
ok: true as const,
1003+
path: outputPath,
1004+
size: 12,
1005+
status: 200,
1006+
statusText: 'OK',
1007+
}
1008+
},
1009+
)
10131010

1014-
// Second call - should detect missing binary and re-download
10151011
await downloadGitHubRelease({
10161012
assetName: 'test-binary',
10171013
binaryName: 'test-bin',
1018-
downloadDir: testDownloadDir,
1014+
downloadDir: testDir,
10191015
owner: 'test-owner',
10201016
platformArch: 'test-arch',
10211017
quiet: true,
@@ -1024,16 +1020,12 @@ describe('releases/github', () => {
10241020
toolName: 'test-tool',
10251021
})
10261022

1027-
// Should download again due to missing binary
10281023
expect(httpDownload).toHaveBeenCalledTimes(1)
1024+
// Cache updated to the new tag.
1025+
expect(await fs.readFile(versionFile, 'utf8')).toBe('v1.0.0')
10291026
} finally {
1030-
// Cleanup
1031-
try {
1032-
await fs.rm(testDownloadDir, { force: true, recursive: true })
1033-
} catch {
1034-
// Ignore cleanup errors
1035-
}
1027+
await fs.rm(testDir, { force: true, recursive: true }).catch(() => {})
10361028
}
1037-
}, 40_000)
1029+
})
10381030
})
10391031
})

0 commit comments

Comments
 (0)