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