| 1 | package plugin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "net/http/httptest" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "runtime" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "reasonix/internal/mcplaunch" |
| 15 | ) |
| 16 | |
| 17 | func TestStoredNPXLauncherLockUsesExactOfflinePackage(t *testing.T) { |
| 18 | manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace") |
| 19 | lock := mcplaunch.LauncherLock{ |
| 20 | Server: "search", Locator: digestText("@scope/server"), ResolvedVersion: "@scope/server@1.2.3", ContentSHA256: digestText("integrity"), |
| 21 | } |
| 22 | if err := manager.PutLauncherLock(lock); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | spec := Spec{Name: "search", Command: "npx", Args: []string{"-y", "@scope/server", "--stdio"}, LaunchManager: manager} |
| 26 | locked, err := applyStoredLauncherLock(spec) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | want := []string{"-y", "--offline", "@scope/server@1.2.3", "--stdio"} |
| 31 | if !reflect.DeepEqual(locked.LaunchArgs, want) { |
| 32 | t.Fatalf("launch args = %v, want %v", locked.LaunchArgs, want) |
| 33 | } |
| 34 | if wantIdentity := []string{"-y", "@scope/server@1.2.3", "--stdio"}; !reflect.DeepEqual(locked.LauncherIdentityArgs, wantIdentity) { |
| 35 | t.Fatalf("launcher identity args = %v, want %v", locked.LauncherIdentityArgs, wantIdentity) |
| 36 | } |
| 37 | if locked.LauncherDigest == "" { |
| 38 | t.Fatal("launcher digest is empty") |
| 39 | } |
| 40 | if SchemaCacheKey(locked) != SchemaCacheKey(spec) { |
| 41 | t.Fatal("host-local launcher lock changed the schema cache key") |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestStoredLauncherEnforcementFlagPreservesAuthorizedIdentity(t *testing.T) { |
| 46 | cases := []struct { |
| 47 | name, command, server, locator, resolved, enforcementFlag string |
| 48 | args []string |
| 49 | }{ |
| 50 | { |
| 51 | name: "npx", command: "npx", server: "chrome-devtools", |
| 52 | locator: "chrome-devtools-mcp@latest", resolved: "chrome-devtools-mcp@1.6.0", |
| 53 | enforcementFlag: "--offline", args: []string{"-y", "chrome-devtools-mcp@latest", "--slim"}, |
| 54 | }, |
| 55 | { |
| 56 | name: "bunx", command: "bunx", server: "browser", |
| 57 | locator: "browser-mcp@latest", resolved: "browser-mcp@2.3.4", |
| 58 | enforcementFlag: "--no-install", args: []string{"browser-mcp@latest", "--stdio"}, |
| 59 | }, |
| 60 | { |
| 61 | name: "uvx", command: "uvx", server: "python-tools", |
| 62 | locator: "python-tools", resolved: "python-tools==3.2.1", |
| 63 | enforcementFlag: "--offline", args: []string{"python-tools", "--stdio"}, |
| 64 | }, |
| 65 | } |
| 66 | for _, tc := range cases { |
| 67 | t.Run(tc.name, func(t *testing.T) { |
| 68 | dir := t.TempDir() |
| 69 | command := filepath.Join(dir, tc.command) |
| 70 | if runtime.GOOS == "windows" { |
| 71 | command += ".exe" |
| 72 | } |
| 73 | if err := os.WriteFile(command, []byte("test launcher"), 0o755); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), dir) |
| 77 | lock := mcplaunch.LauncherLock{ |
| 78 | Server: tc.server, Locator: digestText(tc.locator), |
| 79 | ResolvedVersion: tc.resolved, ContentSHA256: digestText("integrity"), |
| 80 | Workspace: manager.WorkspaceFingerprint(), |
| 81 | } |
| 82 | spec := Spec{ |
| 83 | Name: tc.server, Command: command, Args: tc.args, |
| 84 | LaunchManager: manager, ConfigSource: "project_config", RequireLaunchApproval: true, |
| 85 | } |
| 86 | locator, mutable := mutableLauncherLocator(spec) |
| 87 | if !mutable { |
| 88 | t.Fatalf("%s launcher was not recognized as mutable", tc.command) |
| 89 | } |
| 90 | preflight := spec |
| 91 | applyLauncherResolution(&preflight, locator, lock, false) |
| 92 | approvedIdentity, err := projectLaunchIdentityDigest(context.Background(), preflight) |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if err := manager.Authorize(spec.Name, spec.ConfigSource, approvedIdentity); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if err := manager.PutLauncherLock(lock); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | locked, err := applyStoredLauncherLock(spec) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | if !stringSliceContains(locked.LaunchArgs, tc.enforcementFlag) || stringSliceContains(locked.LauncherIdentityArgs, tc.enforcementFlag) { |
| 107 | t.Fatalf("launch args = %v, identity args = %v, enforcement flag = %q", locked.LaunchArgs, locked.LauncherIdentityArgs, tc.enforcementFlag) |
| 108 | } |
| 109 | restartIdentity, err := projectLaunchIdentityDigest(context.Background(), locked) |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if restartIdentity != approvedIdentity { |
| 114 | t.Fatalf("stored-lock identity changed after enforcement: approved=%s restart=%s", approvedIdentity, restartIdentity) |
| 115 | } |
| 116 | if authorized, changed, err := manager.LaunchAuthorized(spec.Name, spec.ConfigSource, restartIdentity); err != nil || !authorized || changed { |
| 117 | t.Fatalf("stored-lock launch authorization = (authorized=%v, changed=%v, err=%v)", authorized, changed, err) |
| 118 | } |
| 119 | }) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestStoredUVXFromLauncherLockKeepsFromValueAdjacent(t *testing.T) { |
| 124 | manager := mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace") |
| 125 | lock := mcplaunch.LauncherLock{ |
| 126 | Server: "python-tools", Locator: digestText("python-tools"), |
| 127 | ResolvedVersion: "python-tools==3.2.1", ContentSHA256: digestText("integrity"), |
| 128 | } |
| 129 | if err := manager.PutLauncherLock(lock); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | spec := Spec{ |
| 133 | Name: "python-tools", Command: "uvx", |
| 134 | Args: []string{"--from", "python-tools", "python-tools-server", "--stdio"}, |
| 135 | LaunchManager: manager, |
| 136 | } |
| 137 | locked, err := applyStoredLauncherLock(spec) |
| 138 | if err != nil { |
| 139 | t.Fatal(err) |
| 140 | } |
| 141 | wantLaunch := []string{"--offline", "--from", "python-tools==3.2.1", "python-tools-server", "--stdio"} |
| 142 | if !reflect.DeepEqual(locked.LaunchArgs, wantLaunch) { |
| 143 | t.Fatalf("launch args = %v, want %v", locked.LaunchArgs, wantLaunch) |
| 144 | } |
| 145 | wantIdentity := []string{"--from", "python-tools==3.2.1", "python-tools-server", "--stdio"} |
| 146 | if !reflect.DeepEqual(locked.LauncherIdentityArgs, wantIdentity) { |
| 147 | t.Fatalf("launcher identity args = %v, want %v", locked.LauncherIdentityArgs, wantIdentity) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func stringSliceContains(values []string, want string) bool { |
| 152 | for _, value := range values { |
| 153 | if value == want { |
| 154 | return true |
| 155 | } |
| 156 | } |
| 157 | return false |
| 158 | } |
| 159 | |
| 160 | func TestMutableLauncherRejectsAmbiguousFlagValue(t *testing.T) { |
| 161 | locator, mutable := mutableLauncherLocator(Spec{Command: "npx", Args: []string{"--node-options", "--inspect", "server"}}) |
| 162 | if !mutable || locator.value != "" { |
| 163 | t.Fatalf("ambiguous locator = %+v, mutable=%v", locator, mutable) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestResolvePyPIPackagePinsVersionAndFileDigests(t *testing.T) { |
| 168 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 169 | if r.URL.Path != "/demo/json" { |
| 170 | t.Fatalf("request path = %q", r.URL.Path) |
| 171 | } |
| 172 | _, _ = w.Write([]byte(`{"info":{"version":"2.4.1"},"urls":[{"digests":{"sha256":"bbb"}},{"digests":{"sha256":"aaa"}}]}`)) |
| 173 | })) |
| 174 | defer server.Close() |
| 175 | oldBase := pypiBaseURL |
| 176 | pypiBaseURL = server.URL |
| 177 | defer func() { pypiBaseURL = oldBase }() |
| 178 | resolved, digest, err := resolvePyPIPackage(context.Background(), "demo") |
| 179 | if err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | if resolved != "demo==2.4.1" || digest != digestText("aaa\nbbb") { |
| 183 | t.Fatalf("resolution = %q %q", resolved, digest) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestResolveExactGitLocatorDoesNotNeedNetwork(t *testing.T) { |
| 188 | commit := "0123456789abcdef0123456789abcdef01234567" |
| 189 | locator := "git+https://example.invalid/server.git@" + commit |
| 190 | resolved, digest, err := resolveGitLocator(context.Background(), Spec{}, locator) |
| 191 | if err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | if resolved != commit || digest != digestText(commit) { |
| 195 | t.Fatalf("resolution = %q %q", resolved, digest) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestGitLauncherLockDoesNotPersistCredentialedLocator(t *testing.T) { |
| 200 | home := t.TempDir() |
| 201 | manager := mcplaunch.NewManager(filepath.Join(home, mcplaunch.StateFilename), "/workspace") |
| 202 | locator := "git+https://user:secret-token@example.test/server.git@main" |
| 203 | commit := "0123456789abcdef0123456789abcdef01234567" |
| 204 | lock := mcplaunch.LauncherLock{ |
| 205 | Server: "git-server", Locator: digestText(locator), ResolvedVersion: commit, ContentSHA256: digestText(commit), |
| 206 | } |
| 207 | if err := manager.PutLauncherLock(lock); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | body, err := os.ReadFile(manager.Path()) |
| 211 | if err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | if strings.Contains(string(body), "secret-token") || strings.Contains(string(body), "user:") { |
| 215 | t.Fatal("launcher security state persisted URL credentials") |
| 216 | } |
| 217 | spec := Spec{Name: "git-server", Command: "npx", Args: []string{locator}, LaunchManager: manager} |
| 218 | got, err := applyStoredLauncherLock(spec) |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | want := "git+https://user:secret-token@example.test/server.git@" + commit |
| 223 | if len(got.LaunchArgs) != 2 || got.LaunchArgs[1] != want || got.LaunchArgs[0] != "--offline" { |
| 224 | t.Fatalf("reconstructed git launch args = %v, want offline + exact original locator", got.LaunchArgs) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func TestNPMPackageName(t *testing.T) { |
| 229 | cases := map[string]string{ |
| 230 | "server": "server", |
| 231 | "server@^1": "server", |
| 232 | "@scope/server": "@scope/server", |
| 233 | "@scope/server@1.2.3": "@scope/server", |
| 234 | "file:../server": "", |
| 235 | "github/acme": "", |
| 236 | } |
| 237 | for input, want := range cases { |
| 238 | if got := npmPackageName(input); got != want { |
| 239 | t.Errorf("npmPackageName(%q) = %q, want %q", input, got, want) |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | func TestFullGitCommitAcceptsOnlyCompleteObjectNames(t *testing.T) { |
| 245 | sha1Commit := strings.Repeat("0123456789", 4) // 40 hex |
| 246 | sha256Commit := strings.Repeat("0123456789abcdef", 4) // 64 hex |
| 247 | for value, want := range map[string]bool{ |
| 248 | sha1Commit: true, |
| 249 | sha256Commit: true, |
| 250 | sha1Commit[:39]: false, // abbreviation |
| 251 | sha1Commit + "a": false, // 41-hex custom ref: resolve via ls-remote |
| 252 | sha256Commit[:63]: false, |
| 253 | sha256Commit + "a": false, |
| 254 | "main": false, |
| 255 | "": false, |
| 256 | } { |
| 257 | if got := fullGitCommit.MatchString(value); got != want { |
| 258 | t.Errorf("fullGitCommit(%d hex %q...) = %v, want %v", len(value), value[:min(8, len(value))], got, want) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | } |
| 263 | |
| 264 | func TestResolvePyPIPackageRejectsWildcardBeforeNetwork(t *testing.T) { |
| 265 | if _, _, err := resolvePyPIPackage(context.Background(), "server==2.4.*"); err == nil || !strings.Contains(err.Error(), "wildcard") { |
| 266 | t.Fatalf("wildcard uvx locator resolved: %v", err) |
| 267 | } |
| 268 | } |
| 269 |