|
| 1 | +package detector |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/base64" |
| 6 | + "path/filepath" |
| 7 | + "testing" |
| 8 | + |
| 9 | + "github.com/step-security/dev-machine-guard/internal/executor" |
| 10 | + "github.com/step-security/dev-machine-guard/internal/progress" |
| 11 | +) |
| 12 | + |
| 13 | +func newTestScanner(exec *executor.Mock) *NodeScanner { |
| 14 | + log := progress.NewLogger(false) |
| 15 | + return NewNodeScanner(exec, log) |
| 16 | +} |
| 17 | + |
| 18 | +func TestNodeScanner_ScanNPMGlobal(t *testing.T) { |
| 19 | + mock := executor.NewMock() |
| 20 | + mock.SetPath("npm", "/usr/local/bin/npm") |
| 21 | + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") |
| 22 | + mock.SetCommand("/usr/local\n", "", 0, "npm", "config", "get", "prefix") |
| 23 | + mock.SetCommand(`{"dependencies":{"express":{"version":"4.18.2"}}}`, "", 0, "npm", "list", "-g", "--json", "--depth=3") |
| 24 | + |
| 25 | + scanner := newTestScanner(mock) |
| 26 | + results := scanner.ScanGlobalPackages(context.Background()) |
| 27 | + |
| 28 | + npmFound := false |
| 29 | + for _, r := range results { |
| 30 | + if r.PackageManager == "npm" { |
| 31 | + npmFound = true |
| 32 | + if r.ProjectPath != "/usr/local" { |
| 33 | + t.Errorf("expected ProjectPath /usr/local, got %s", r.ProjectPath) |
| 34 | + } |
| 35 | + if r.PMVersion != "10.2.0" { |
| 36 | + t.Errorf("expected PMVersion 10.2.0, got %s", r.PMVersion) |
| 37 | + } |
| 38 | + if r.ExitCode != 0 { |
| 39 | + t.Errorf("expected ExitCode 0, got %d", r.ExitCode) |
| 40 | + } |
| 41 | + decoded, _ := base64.StdEncoding.DecodeString(r.RawStdoutBase64) |
| 42 | + if len(decoded) == 0 { |
| 43 | + t.Error("expected non-empty RawStdoutBase64") |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | + if !npmFound { |
| 48 | + t.Fatal("expected npm in global scan results") |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +func TestNodeScanner_ScanNPMGlobal_Windows(t *testing.T) { |
| 53 | + mock := executor.NewMock() |
| 54 | + mock.SetGOOS("windows") |
| 55 | + mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`) |
| 56 | + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") |
| 57 | + // npm config get prefix returns a Windows-style path on real Windows. |
| 58 | + // The code stores it directly (no filepath.* processing), so the mock |
| 59 | + // value flows through unchanged. |
| 60 | + mock.SetCommand(`C:\Users\dev\AppData\Roaming\npm`+"\n", "", 0, "npm", "config", "get", "prefix") |
| 61 | + mock.SetCommand(`{"dependencies":{"express":{"version":"4.18.2"}}}`, "", 0, "npm", "list", "-g", "--json", "--depth=3") |
| 62 | + |
| 63 | + scanner := newTestScanner(mock) |
| 64 | + results := scanner.ScanGlobalPackages(context.Background()) |
| 65 | + |
| 66 | + npmFound := false |
| 67 | + for _, r := range results { |
| 68 | + if r.PackageManager == "npm" { |
| 69 | + npmFound = true |
| 70 | + if r.ProjectPath != `C:\Users\dev\AppData\Roaming\npm` { |
| 71 | + t.Errorf("expected Windows npm prefix, got %s", r.ProjectPath) |
| 72 | + } |
| 73 | + if r.PMVersion != "10.2.0" { |
| 74 | + t.Errorf("expected PMVersion 10.2.0, got %s", r.PMVersion) |
| 75 | + } |
| 76 | + if r.ExitCode != 0 { |
| 77 | + t.Errorf("expected ExitCode 0, got %d", r.ExitCode) |
| 78 | + } |
| 79 | + } |
| 80 | + } |
| 81 | + if !npmFound { |
| 82 | + t.Fatal("expected npm in global scan results on Windows") |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +func TestNodeScanner_ScanYarnGlobal_Windows(t *testing.T) { |
| 87 | + mock := executor.NewMock() |
| 88 | + mock.SetGOOS("windows") |
| 89 | + mock.SetPath("yarn", `C:\Program Files\nodejs\yarn.cmd`) |
| 90 | + mock.SetCommand("1.22.19\n", "", 0, "yarn", "--version") |
| 91 | + mock.SetCommand(`C:\Users\dev\AppData\Local\Yarn\Data\global`+"\n", "", 0, "yarn", "global", "dir") |
| 92 | + // runShellCmd dispatches to cmd /c on Windows; platformShellQuote uses double quotes |
| 93 | + mock.SetCommand(`{"type":"tree","data":{"trees":[]}}`, "", 0, |
| 94 | + "cmd", "/c", `cd "C:\Users\dev\AppData\Local\Yarn\Data\global" && yarn list --json --depth=0`) |
| 95 | + |
| 96 | + scanner := newTestScanner(mock) |
| 97 | + results := scanner.ScanGlobalPackages(context.Background()) |
| 98 | + |
| 99 | + yarnFound := false |
| 100 | + for _, r := range results { |
| 101 | + if r.PackageManager == "yarn" { |
| 102 | + yarnFound = true |
| 103 | + if r.ProjectPath != `C:\Users\dev\AppData\Local\Yarn\Data\global` { |
| 104 | + t.Errorf("expected Windows yarn global dir, got %s", r.ProjectPath) |
| 105 | + } |
| 106 | + if r.PMVersion != "1.22.19" { |
| 107 | + t.Errorf("expected PMVersion 1.22.19, got %s", r.PMVersion) |
| 108 | + } |
| 109 | + } |
| 110 | + } |
| 111 | + if !yarnFound { |
| 112 | + t.Fatal("expected yarn in global scan results on Windows") |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +func TestNodeScanner_ScanPnpmGlobal_Windows(t *testing.T) { |
| 117 | + mock := executor.NewMock() |
| 118 | + mock.SetGOOS("windows") |
| 119 | + mock.SetPath("pnpm", `C:\Users\dev\AppData\Local\pnpm\pnpm.cmd`) |
| 120 | + mock.SetCommand("9.1.0\n", "", 0, "pnpm", "--version") |
| 121 | + // pnpm root -g returns the global node_modules dir. The code calls |
| 122 | + // filepath.Dir on it. Since filepath.Dir is host-OS dependent, we use |
| 123 | + // forward slashes here so the test works on macOS hosts too. |
| 124 | + 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 | + |
| 127 | + scanner := newTestScanner(mock) |
| 128 | + results := scanner.ScanGlobalPackages(context.Background()) |
| 129 | + |
| 130 | + pnpmFound := false |
| 131 | + for _, r := range results { |
| 132 | + if r.PackageManager == "pnpm" { |
| 133 | + pnpmFound = true |
| 134 | + // filepath.Dir strips the last component (node_modules) |
| 135 | + expected := "C:/Users/dev/AppData/Local/pnpm/global/5" |
| 136 | + if r.ProjectPath != expected { |
| 137 | + t.Errorf("expected ProjectPath %s, got %s", expected, r.ProjectPath) |
| 138 | + } |
| 139 | + if r.PMVersion != "9.1.0" { |
| 140 | + t.Errorf("expected PMVersion 9.1.0, got %s", r.PMVersion) |
| 141 | + } |
| 142 | + } |
| 143 | + } |
| 144 | + if !pnpmFound { |
| 145 | + t.Fatal("expected pnpm in global scan results on Windows") |
| 146 | + } |
| 147 | +} |
| 148 | + |
| 149 | +func TestNodeScanner_ScanProject_Windows(t *testing.T) { |
| 150 | + mock := executor.NewMock() |
| 151 | + mock.SetGOOS("windows") |
| 152 | + mock.SetPath("npm", `C:\Program Files\nodejs\npm.cmd`) |
| 153 | + mock.SetCommand("10.2.0\n", "", 0, "npm", "--version") |
| 154 | + // DetectProjectPM uses filepath.Join which is host-dependent; |
| 155 | + // construct the mock file path the same way the code will. |
| 156 | + mock.SetFile(filepath.Join(`C:\Users\dev\myapp`, "package-lock.json"), []byte{}) |
| 157 | + mock.SetCommand(`{"dependencies":{"lodash":{"version":"4.17.21"}}}`, "", 0, |
| 158 | + "cmd", "/c", `cd "C:\Users\dev\myapp" && npm ls --json --depth=3`) |
| 159 | + |
| 160 | + scanner := newTestScanner(mock) |
| 161 | + result := scanner.scanProject(context.Background(), `C:\Users\dev\myapp`) |
| 162 | + |
| 163 | + if result.PackageManager != "npm" { |
| 164 | + t.Errorf("expected npm, got %s", result.PackageManager) |
| 165 | + } |
| 166 | + if result.ProjectPath != `C:\Users\dev\myapp` { |
| 167 | + t.Errorf("expected project path C:\\Users\\dev\\myapp, got %s", result.ProjectPath) |
| 168 | + } |
| 169 | + if result.ExitCode != 0 { |
| 170 | + t.Errorf("expected ExitCode 0, got %d", result.ExitCode) |
| 171 | + } |
| 172 | + if result.PMVersion != "10.2.0" { |
| 173 | + t.Errorf("expected PMVersion 10.2.0, got %s", result.PMVersion) |
| 174 | + } |
| 175 | + decoded, _ := base64.StdEncoding.DecodeString(result.RawStdoutBase64) |
| 176 | + if len(decoded) == 0 { |
| 177 | + t.Error("expected non-empty RawStdoutBase64") |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +func TestNodeScanner_ScanProject_YarnBerry_Windows(t *testing.T) { |
| 182 | + mock := executor.NewMock() |
| 183 | + mock.SetGOOS("windows") |
| 184 | + mock.SetPath("yarn", `C:\Program Files\nodejs\yarn.cmd`) |
| 185 | + mock.SetCommand("4.1.0\n", "", 0, "yarn", "--version") |
| 186 | + // Use filepath.Join to construct mock file paths matching the code's behavior. |
| 187 | + projectDir := `C:\Users\dev\myapp` |
| 188 | + mock.SetFile(filepath.Join(projectDir, "yarn.lock"), []byte{}) |
| 189 | + mock.SetFile(filepath.Join(projectDir, ".yarnrc.yml"), []byte{}) |
| 190 | + mock.SetCommand(`{"name":"myapp","children":[]}`, "", 0, |
| 191 | + "cmd", "/c", `cd "C:\Users\dev\myapp" && yarn info --all --json`) |
| 192 | + |
| 193 | + scanner := newTestScanner(mock) |
| 194 | + result := scanner.scanProject(context.Background(), projectDir) |
| 195 | + |
| 196 | + if result.PackageManager != "yarn-berry" { |
| 197 | + t.Errorf("expected yarn-berry, got %s", result.PackageManager) |
| 198 | + } |
| 199 | + if result.PMVersion != "4.1.0" { |
| 200 | + t.Errorf("expected PMVersion 4.1.0, got %s", result.PMVersion) |
| 201 | + } |
| 202 | + if result.ExitCode != 0 { |
| 203 | + t.Errorf("expected ExitCode 0, got %d", result.ExitCode) |
| 204 | + } |
| 205 | +} |
| 206 | + |
| 207 | +func TestNodeScanner_ScanGlobalPackages_NoneInstalled(t *testing.T) { |
| 208 | + mock := executor.NewMock() |
| 209 | + scanner := newTestScanner(mock) |
| 210 | + results := scanner.ScanGlobalPackages(context.Background()) |
| 211 | + |
| 212 | + if len(results) != 0 { |
| 213 | + t.Errorf("expected 0 results when no PMs installed, got %d", len(results)) |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +func TestIsInsideNodeModules(t *testing.T) { |
| 218 | + tests := []struct { |
| 219 | + path string |
| 220 | + want bool |
| 221 | + }{ |
| 222 | + // Unix-style paths |
| 223 | + {"/Users/dev/node_modules/foo", true}, |
| 224 | + {"/Users/dev/myapp", false}, |
| 225 | + {"/Users/dev/node_modules_backup/foo", false}, |
| 226 | + {"/node_modules/", true}, |
| 227 | + // Windows-style paths (backslashes) |
| 228 | + {`C:\Users\dev\node_modules\foo`, true}, |
| 229 | + {`C:\Users\dev\myapp`, false}, |
| 230 | + {`C:\node_modules\pkg`, true}, |
| 231 | + {`\node_modules\`, true}, |
| 232 | + // Edge cases |
| 233 | + {"node_modules", false}, |
| 234 | + {"", false}, |
| 235 | + } |
| 236 | + for _, tt := range tests { |
| 237 | + t.Run(tt.path, func(t *testing.T) { |
| 238 | + got := isInsideNodeModules(tt.path) |
| 239 | + if got != tt.want { |
| 240 | + t.Errorf("isInsideNodeModules(%q) = %v, want %v", tt.path, got, tt.want) |
| 241 | + } |
| 242 | + }) |
| 243 | + } |
| 244 | +} |
0 commit comments