mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 01:45:30 -04:00
168 lines
6.4 KiB
TypeScript
168 lines
6.4 KiB
TypeScript
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { afterEach, beforeEach, expect, jest, test } from '@jest/globals'
|
|
|
|
import type { PatchRemoveCommandOptions } from '../src/patchRemove.js'
|
|
|
|
jest.unstable_mockModule('@pnpm/installing.commands', () => ({
|
|
install: {
|
|
handler: jest.fn(),
|
|
},
|
|
}))
|
|
|
|
jest.unstable_mockModule('../src/updatePatchedDependencies.js', () => ({
|
|
updatePatchedDependencies: jest.fn(),
|
|
}))
|
|
|
|
const { install } = await import('@pnpm/installing.commands')
|
|
const patchRemove = await import('../src/patchRemove.js')
|
|
const { updatePatchedDependencies } = await import('../src/updatePatchedDependencies.js')
|
|
|
|
const installHandler = jest.mocked(install.handler)
|
|
const updatePatchedDependenciesMock = jest.mocked(updatePatchedDependencies)
|
|
const testOnNonWindows = process.platform === 'win32' ? test.skip : test
|
|
|
|
let tempRoot: string
|
|
|
|
beforeEach(() => {
|
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-patch-remove-'))
|
|
installHandler.mockResolvedValue(undefined)
|
|
updatePatchedDependenciesMock.mockResolvedValue(undefined)
|
|
})
|
|
|
|
afterEach(() => {
|
|
installHandler.mockReset()
|
|
updatePatchedDependenciesMock.mockReset()
|
|
fs.rmSync(tempRoot, { force: true, recursive: true })
|
|
})
|
|
|
|
test('patch-remove rejects traversal outside the patches directory before deleting any patch', async () => {
|
|
const projectDir = path.join(tempRoot, 'project')
|
|
const outsideFile = path.join(tempRoot, 'outside.patch')
|
|
const goodPatch = path.join(projectDir, 'patches/good.patch')
|
|
fs.mkdirSync(path.dirname(goodPatch), { recursive: true })
|
|
fs.writeFileSync(goodPatch, 'good patch', 'utf8')
|
|
fs.writeFileSync(outsideFile, 'outside patch', 'utf8')
|
|
|
|
await expect(patchRemove.handler(createOptions(projectDir, {
|
|
good: 'patches/good.patch',
|
|
bad: '../outside.patch',
|
|
}), ['good', 'bad'])).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_PATCH_FILE_OUTSIDE_PATCHES_DIR',
|
|
})
|
|
|
|
expect(fs.existsSync(goodPatch)).toBe(true)
|
|
expect(fs.existsSync(outsideFile)).toBe(true)
|
|
expect(updatePatchedDependenciesMock).not.toHaveBeenCalled()
|
|
expect(installHandler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('patch-remove rejects directory entries before deleting any patch', async () => {
|
|
const projectDir = path.join(tempRoot, 'project')
|
|
const goodPatch = path.join(projectDir, 'patches/good.patch')
|
|
const patchDir = path.join(projectDir, 'patches/not-a-file.patch')
|
|
fs.mkdirSync(patchDir, { recursive: true })
|
|
fs.writeFileSync(goodPatch, 'good patch', 'utf8')
|
|
|
|
await expect(patchRemove.handler(createOptions(projectDir, {
|
|
good: 'patches/good.patch',
|
|
bad: 'patches/not-a-file.patch',
|
|
}), ['good', 'bad'])).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_PATCH_FILE_IS_DIRECTORY',
|
|
})
|
|
|
|
expect(fs.existsSync(goodPatch)).toBe(true)
|
|
expect(updatePatchedDependenciesMock).not.toHaveBeenCalled()
|
|
expect(installHandler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
testOnNonWindows('patch-remove rejects a nested parent symlink outside the patches directory before unlinking a dangling target', async () => {
|
|
const projectDir = path.join(tempRoot, 'project')
|
|
const patchesDir = path.join(projectDir, 'patches')
|
|
const outsideDir = path.join(tempRoot, 'outside')
|
|
const outsideDanglingLink = path.join(outsideDir, 'dangling.patch')
|
|
fs.mkdirSync(patchesDir, { recursive: true })
|
|
fs.mkdirSync(outsideDir, { recursive: true })
|
|
fs.symlinkSync(outsideDir, path.join(patchesDir, 'linked-dir'), 'dir')
|
|
fs.symlinkSync(path.join(tempRoot, 'missing-target.patch'), outsideDanglingLink)
|
|
|
|
await expect(patchRemove.handler(createOptions(projectDir, {
|
|
bad: 'patches/linked-dir/dangling.patch',
|
|
}), ['bad'])).rejects.toMatchObject({
|
|
code: 'ERR_PNPM_PATCH_FILE_OUTSIDE_PATCHES_DIR',
|
|
})
|
|
|
|
expect(fs.lstatSync(outsideDanglingLink).isSymbolicLink()).toBe(true)
|
|
expect(updatePatchedDependenciesMock).not.toHaveBeenCalled()
|
|
expect(installHandler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
testOnNonWindows('patch-remove unlinks a final symlink inside the patches directory without touching its target', async () => {
|
|
const projectDir = path.join(tempRoot, 'project')
|
|
const patchesDir = path.join(projectDir, 'patches')
|
|
const outsideTarget = path.join(tempRoot, 'outside-target.patch')
|
|
const patchLink = path.join(patchesDir, 'linked.patch')
|
|
fs.mkdirSync(patchesDir, { recursive: true })
|
|
fs.writeFileSync(outsideTarget, 'outside target', 'utf8')
|
|
fs.symlinkSync(outsideTarget, patchLink)
|
|
|
|
await patchRemove.handler(createOptions(projectDir, {
|
|
pkg: 'patches/linked.patch',
|
|
}), ['pkg'])
|
|
|
|
expect(fs.existsSync(patchLink)).toBe(false)
|
|
expect(fs.readFileSync(outsideTarget, 'utf8')).toBe('outside target')
|
|
expect(updatePatchedDependenciesMock).toHaveBeenCalledWith({}, expect.any(Object))
|
|
expect(installHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
patchedDependencies: {},
|
|
}))
|
|
})
|
|
|
|
test('patch-remove allows a symlinked patches directory that resolves inside the project', async () => {
|
|
const projectDir = path.join(tempRoot, 'project')
|
|
const realPatchesDir = path.join(projectDir, 'real-patches')
|
|
const patchFile = path.join(realPatchesDir, 'pkg.patch')
|
|
fs.mkdirSync(realPatchesDir, { recursive: true })
|
|
fs.symlinkSync(realPatchesDir, path.join(projectDir, 'patches'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
fs.writeFileSync(patchFile, 'patch', 'utf8')
|
|
|
|
await patchRemove.handler(createOptions(projectDir, {
|
|
pkg: 'patches/pkg.patch',
|
|
}), ['pkg'])
|
|
|
|
expect(fs.existsSync(patchFile)).toBe(false)
|
|
expect(updatePatchedDependenciesMock).toHaveBeenCalledWith({}, expect.any(Object))
|
|
expect(installHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
patchedDependencies: {},
|
|
}))
|
|
})
|
|
|
|
test('patch-remove keeps missing patch files as no-ops', async () => {
|
|
const projectDir = path.join(tempRoot, 'project')
|
|
fs.mkdirSync(path.join(projectDir, 'patches'), { recursive: true })
|
|
|
|
await patchRemove.handler(createOptions(projectDir, {
|
|
pkg: 'patches/missing.patch',
|
|
}), ['pkg'])
|
|
|
|
expect(updatePatchedDependenciesMock).toHaveBeenCalledWith({}, expect.any(Object))
|
|
expect(installHandler).toHaveBeenCalledWith(expect.objectContaining({
|
|
patchedDependencies: {},
|
|
}))
|
|
})
|
|
|
|
function createOptions (
|
|
projectDir: string,
|
|
patchedDependencies: Record<string, string>
|
|
): PatchRemoveCommandOptions {
|
|
return {
|
|
dir: projectDir,
|
|
lockfileDir: projectDir,
|
|
patchedDependencies,
|
|
rootProjectManifest: {},
|
|
rootProjectManifestDir: projectDir,
|
|
} as PatchRemoveCommandOptions
|
|
}
|