77 "testing"
88
99 "github.com/step-security/dev-machine-guard/internal/executor"
10+ "github.com/step-security/dev-machine-guard/internal/model"
1011 "github.com/step-security/dev-machine-guard/internal/progress"
1112)
1213
@@ -122,7 +123,7 @@ func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) {
122123 // filepath.Dir on it. Since filepath.Dir is host-OS dependent, we use
123124 // forward slashes here so the test works on macOS hosts too.
124125 mock .SetCommand ("C:/Users/dev/AppData/Local/pnpm/global/5/node_modules\n " , "" , 0 , "pnpm" , "root" , "-g" )
125- mock .SetCommand (`{"dependencies":{"typescript":{"version":"5.4.0"}}}` , "" , 0 , "pnpm" , "list" , "-g" , "--json" , "--depth=3" )
126+ mock .SetCommand (`{"dependencies":{"typescript":{"version":"5.4.0"}}}` , "" , 0 , "pnpm" , "list" , "-g" , "--json" )
126127
127128 scanner := newTestScanner (mock )
128129 results := scanner .ScanGlobalPackages (context .Background ())
@@ -139,13 +140,128 @@ func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) {
139140 if r .PMVersion != "9.1.0" {
140141 t .Errorf ("expected PMVersion 9.1.0, got %s" , r .PMVersion )
141142 }
143+ if r .ExitCode != 0 {
144+ t .Errorf ("expected ExitCode 0 (mock stub matched), got %d — check that production args still match the SetCommand stub" , r .ExitCode )
145+ }
142146 }
143147 }
144148 if ! pnpmFound {
145149 t .Fatal ("expected pnpm in global scan results on Windows" )
146150 }
147151}
148152
153+ // TestNodeScanner_ScanPnpmGlobal_Delegated exercises the root → user delegation
154+ // path (macOS-as-root or Linux-as-root with a logged-in user). Verifies that
155+ // the inline `PATH='…':$PATH pnpm <args>` shell prefix reaches the delegated
156+ // command intact. Without this prefix, pnpm v11 hard-fails each `-g` call on
157+ // hosts where sudo strips the caller's PATH (Linux `secure_path`, hardened
158+ // macOS sudoers).
159+ func TestNodeScanner_ScanPnpmGlobal_Delegated (t * testing.T ) {
160+ mock := executor .NewMock ()
161+ mock .SetGOOS ("darwin" )
162+ mock .SetIsRoot (true )
163+ mock .SetEnv ("HOME" , "/Users/testuser" )
164+
165+ // checkPath in delegation mode runs `which pnpm` through RunAsUser, which
166+ // the Mock dispatches as `bash -c "<cmd>"`.
167+ mock .SetCommand ("/opt/homebrew/bin/pnpm\n " , "" , 0 , "bash" , "-c" , "which pnpm" )
168+
169+ // All pnpm calls in scanPnpmGlobal are built as
170+ // PATH='<extra>':$PATH pnpm <args>
171+ // then sent through runCmd → RunAsUser → bash -c "<cmd>".
172+ prefix := `PATH='/Users/testuser/Library/pnpm/bin':$PATH pnpm`
173+ mock .SetCommand ("11.1.2\n " , "" , 0 , "bash" , "-c" , prefix + " --version" )
174+ mock .SetCommand ("/Users/testuser/Library/pnpm/global/v11/node_modules\n " , "" , 0 , "bash" , "-c" , prefix + " root -g" )
175+ mock .SetCommand (`{"dependencies":{"typescript":{"version":"5.4.0"}}}` , "" , 0 , "bash" , "-c" , prefix + " list -g --json" )
176+
177+ log := progress .NewLogger (progress .LevelInfo )
178+ scanner := NewNodeScanner (mock , log , "testuser" )
179+
180+ results := scanner .ScanGlobalPackages (context .Background ())
181+
182+ var pnpm * model.NodeScanResult
183+ for i , r := range results {
184+ if r .PackageManager == "pnpm" {
185+ pnpm = & results [i ]
186+ break
187+ }
188+ }
189+ if pnpm == nil {
190+ t .Fatal ("expected pnpm in delegated scan results" )
191+ }
192+ if pnpm .PMVersion != "11.1.2" {
193+ t .Errorf ("PMVersion = %q, want 11.1.2 — PATH= prefix likely missing from `pnpm --version` invocation" , pnpm .PMVersion )
194+ }
195+ if pnpm .ProjectPath != "/Users/testuser/Library/pnpm/global/v11" {
196+ t .Errorf ("ProjectPath = %q, want /Users/testuser/Library/pnpm/global/v11 — PATH= prefix likely missing from `pnpm root -g` invocation" , pnpm .ProjectPath )
197+ }
198+ if pnpm .ExitCode != 0 {
199+ t .Errorf ("ExitCode = %d, want 0 — PATH= prefix likely missing from `pnpm list -g` invocation" , pnpm .ExitCode )
200+ }
201+ }
202+
203+ // TestDefaultPnpmBinDir pins pnpm's per-platform global bin-dir layout. macOS
204+ // uses a /bin subdirectory; Linux and Windows place global binaries directly
205+ // in PNPM_HOME (no /bin). This asymmetry matches pnpm's own `pnpm setup`.
206+ func TestDefaultPnpmBinDir (t * testing.T ) {
207+ tests := []struct {
208+ name string
209+ goos string
210+ envs map [string ]string
211+ want string
212+ }{
213+ {
214+ name : "darwin with HOME → bin subdir" ,
215+ goos : "darwin" ,
216+ envs : map [string ]string {"HOME" : "/Users/foo" },
217+ want : "/Users/foo/Library/pnpm/bin" ,
218+ },
219+ {
220+ name : "darwin without HOME → empty" ,
221+ goos : "darwin" ,
222+ envs : map [string ]string {},
223+ want : "" ,
224+ },
225+ {
226+ name : "linux with HOME → no bin suffix" ,
227+ goos : "linux" ,
228+ envs : map [string ]string {"HOME" : "/home/foo" },
229+ want : "/home/foo/.local/share/pnpm" ,
230+ },
231+ {
232+ name : "linux without HOME → empty" ,
233+ goos : "linux" ,
234+ envs : map [string ]string {},
235+ want : "" ,
236+ },
237+ {
238+ name : "windows without LOCALAPPDATA → empty" ,
239+ goos : "windows" ,
240+ envs : map [string ]string {},
241+ want : "" ,
242+ },
243+ {
244+ name : "unrecognized OS → empty" ,
245+ goos : "freebsd" ,
246+ envs : map [string ]string {"HOME" : "/home/foo" },
247+ want : "" ,
248+ },
249+ }
250+ for _ , tt := range tests {
251+ t .Run (tt .name , func (t * testing.T ) {
252+ mock := executor .NewMock ()
253+ mock .SetGOOS (tt .goos )
254+ for k , v := range tt .envs {
255+ mock .SetEnv (k , v )
256+ }
257+ got := defaultPnpmBinDir (mock )
258+ if got != tt .want {
259+ t .Errorf ("defaultPnpmBinDir() = %q, want %q" , got , tt .want )
260+ }
261+ })
262+ }
263+ }
264+
149265func TestNodeScanner_ScanProject_Windows (t * testing.T ) {
150266 mock := executor .NewMock ()
151267 mock .SetGOOS ("windows" )
@@ -206,89 +322,6 @@ func TestNodeScanner_ScanProject_YarnBerry_Windows(t *testing.T) {
206322 }
207323}
208324
209- func TestPnpmDepthArg (t * testing.T ) {
210- tests := []struct {
211- version string
212- want string
213- }{
214- {"" , "--depth=3" },
215- {"unknown" , "--depth=3" },
216- {"9.1.0" , "--depth=3" },
217- {"10.12.4" , "--depth=3" },
218- {"11.0.0" , "--depth=Infinity" },
219- {"11.1.1" , "--depth=Infinity" },
220- {"v12.0.0\n " , "--depth=Infinity" },
221- }
222- for _ , tt := range tests {
223- t .Run (tt .version , func (t * testing.T ) {
224- got := pnpmDepthArg (tt .version )
225- if got != tt .want {
226- t .Errorf ("pnpmDepthArg(%q) = %q, want %q" , tt .version , got , tt .want )
227- }
228- })
229- }
230- }
231-
232- func TestNodeScanner_ScanPnpmGlobal_V11 (t * testing.T ) {
233- mock := executor .NewMock ()
234- mock .SetPath ("pnpm" , "/opt/homebrew/bin/pnpm" )
235- mock .SetCommand ("11.1.1\n " , "" , 0 , "pnpm" , "--version" )
236- mock .SetCommand ("/Users/dev/Library/pnpm/global/v11/abc/node_modules\n " , "" , 0 , "pnpm" , "root" , "-g" )
237- // v11 must use --depth=Infinity; --depth=3 would fail in real life.
238- mock .SetCommand (`{"dependencies":{"jest":{"version":"29.7.0"}}}` , "" , 0 , "pnpm" , "list" , "-g" , "--json" , "--depth=Infinity" )
239-
240- scanner := newTestScanner (mock )
241- results := scanner .ScanGlobalPackages (context .Background ())
242-
243- pnpmFound := false
244- for _ , r := range results {
245- if r .PackageManager == "pnpm" {
246- pnpmFound = true
247- if r .PMVersion != "11.1.1" {
248- t .Errorf ("expected PMVersion 11.1.1, got %s" , r .PMVersion )
249- }
250- if r .ExitCode != 0 {
251- t .Errorf ("expected ExitCode 0, got %d" , r .ExitCode )
252- }
253- decoded , _ := base64 .StdEncoding .DecodeString (r .RawStdoutBase64 )
254- if len (decoded ) == 0 {
255- t .Error ("expected non-empty RawStdoutBase64" )
256- }
257- }
258- }
259- if ! pnpmFound {
260- t .Fatal ("expected pnpm in global scan results for v11" )
261- }
262- }
263-
264- func TestNodeScanner_ScanPnpmGlobal_V10_StillUsesDepth3 (t * testing.T ) {
265- mock := executor .NewMock ()
266- mock .SetPath ("pnpm" , "/usr/local/bin/pnpm" )
267- mock .SetCommand ("10.12.4\n " , "" , 0 , "pnpm" , "--version" )
268- mock .SetCommand ("/Users/dev/.local/share/pnpm/global/5/node_modules\n " , "" , 0 , "pnpm" , "root" , "-g" )
269- // v10 must still use --depth=3 (proves we didn't break the legacy path).
270- mock .SetCommand (`{"dependencies":{"typescript":{"version":"5.4.0"}}}` , "" , 0 , "pnpm" , "list" , "-g" , "--json" , "--depth=3" )
271-
272- scanner := newTestScanner (mock )
273- results := scanner .ScanGlobalPackages (context .Background ())
274-
275- pnpmFound := false
276- for _ , r := range results {
277- if r .PackageManager == "pnpm" {
278- pnpmFound = true
279- if r .PMVersion != "10.12.4" {
280- t .Errorf ("expected PMVersion 10.12.4, got %s" , r .PMVersion )
281- }
282- if r .ExitCode != 0 {
283- t .Errorf ("expected ExitCode 0, got %d" , r .ExitCode )
284- }
285- }
286- }
287- if ! pnpmFound {
288- t .Fatal ("expected pnpm in global scan results for v10" )
289- }
290- }
291-
292325func TestNodeScanner_ScanGlobalPackages_NoneInstalled (t * testing.T ) {
293326 mock := executor .NewMock ()
294327 scanner := newTestScanner (mock )
0 commit comments