返回 DeepSeek-Reasonix
cache_test.go
根目录 / internal / plugin / cache_test.go
1 package plugin
2
3 import (
4 "encoding/json"
5 "net/http"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/mcplaunch"
13 "reasonix/internal/sandbox"
14 "reasonix/internal/tool"
15 )
16
17 // redirectCache points config.CacheDir() at a fresh temp dir for the duration
18 // of the test (without it the tests write the real user cache).
19 // Returns the temp dir so a test can also poke into it (e.g. write a corrupted file).
20 func redirectCache(t *testing.T) string {
21 t.Helper()
22 dir := t.TempDir()
23 t.Setenv("REASONIX_CACHE_HOME", dir)
24 return dir
25 }
26
27 func sampleSpec() Spec {
28 return Spec{
29 Name: "my-server",
30 Type: "stdio",
31 Command: "/usr/bin/example",
32 Args: []string{"--flag", "x"},
33 Env: map[string]string{"FOO": "1", "BAR": "2"},
34 Headers: map[string]string{"X-Custom": "ok"},
35 Dir: "/work",
36 }
37 }
38
39 func sampleCachedSchema(key string) CachedSchema {
40 return CachedSchema{
41 CacheKey: key,
42 Capabilities: map[string]bool{"prompts": true, "resources": false},
43 Tools: []CachedTool{{
44 Name: "do_thing",
45 Description: "does a thing",
46 Schema: json.RawMessage(`{"type":"object"}`),
47 ReadOnly: true,
48 Destructive: true,
49 }},
50 }
51 }
52
53 func TestCacheRoundTrip(t *testing.T) {
54 redirectCache(t)
55 spec := sampleSpec()
56 key := SchemaCacheKey(spec)
57 cs := sampleCachedSchema(key)
58
59 if err := SaveCachedSchema(spec.Name, cs); err != nil {
60 t.Fatalf("SaveCachedSchema: %v", err)
61 }
62 body, err := os.ReadFile(cachePath(spec.Name))
63 if err != nil {
64 t.Fatal(err)
65 }
66 if !strings.Contains(string(body), `"spec_hash"`) || strings.Contains(string(body), `"cache_key"`) {
67 t.Fatalf("schema cache JSON compatibility changed: %s", body)
68 }
69 got, ok := LoadCachedSchema(spec.Name, key)
70 if !ok {
71 t.Fatal("LoadCachedSchema: miss after save")
72 }
73 if got.CacheKey != key {
74 t.Errorf("CacheKey: got %q want %q", got.CacheKey, key)
75 }
76 if len(got.Tools) != 1 || got.Tools[0].Name != "do_thing" {
77 t.Errorf("Tools: %+v", got.Tools)
78 }
79 if !got.Tools[0].ReadOnly {
80 t.Error("ReadOnly: lost across save/load")
81 }
82 if !got.Tools[0].Destructive {
83 t.Error("Destructive: lost across save/load")
84 }
85 if !got.Capabilities["prompts"] || got.Capabilities["resources"] {
86 t.Errorf("Capabilities: %+v", got.Capabilities)
87 }
88 if got.Version != cacheVersion {
89 t.Errorf("Version: got %d want %d", got.Version, cacheVersion)
90 }
91 if got.LastValidated.IsZero() {
92 t.Error("LastValidated: expected non-zero after save")
93 }
94 }
95
96 func TestCachePersistsDeclaredReaderIndependentlyOfServerAuthorization(t *testing.T) {
97 cached := cacheableToolsOf([]tool.Tool{&remoteTool{
98 rawName: "search", schema: json.RawMessage(`{"type":"object"}`),
99 declaredReadOnly: true, readOnly: false,
100 }})
101 if len(cached) != 1 || !cached[0].ReadOnly {
102 t.Fatalf("cached tool = %+v, want the server-declared reader snapshot", cached)
103 }
104 }
105
106 func TestCachedToolSafetyTracksReadOnlyAndDestructiveHintsOnly(t *testing.T) {
107 redirectCache(t)
108 spec := Spec{
109 Name: "cached-reader", Type: "http", URL: "https://example.com/mcp",
110 }
111 reader := CachedTool{
112 Name: "search", Schema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}}}`), ReadOnly: true,
113 }
114 if err := SaveCachedSchema(spec.Name, CachedSchema{CacheKey: SchemaCacheKey(spec), Tools: []CachedTool{reader}}); err != nil {
115 t.Fatal(err)
116 }
117 before, found := CachedToolSafetyForSpec(spec, "search")
118 if !found || !before.ReadOnly {
119 t.Fatalf("server hint = (%+v,%v), want reader metadata", before, found)
120 }
121 after, found := CachedToolSafetyForSpec(spec, "search")
122 if !found || !after.ReadOnly {
123 t.Fatalf("explicit reader = (%+v,%v), want reader metadata", after, found)
124 }
125
126 // Input/output schema changes are compatibility facts, not authorization or
127 // execution-safety decisions. The live server validates the current call and
128 // the refreshed schema cache becomes provider-visible next session.
129 reader.Schema = json.RawMessage(`{"type":"object","properties":{"q":{"type":"number"}}}`)
130 reader.Destructive = true
131 if err := SaveCachedSchema(spec.Name, CachedSchema{CacheKey: SchemaCacheKey(spec), Tools: []CachedTool{reader}}); err != nil {
132 t.Fatal(err)
133 }
134 updated, found := CachedToolSafetyForSpec(spec, "search")
135 if !found || !updated.ReadOnly || !updated.Destructive {
136 t.Fatalf("safety update = (%+v,%v), want read-only/destructive hints", updated, found)
137 }
138 }
139
140 func TestCacheLoadsLegacyToolWithoutDestructiveField(t *testing.T) {
141 redirectCache(t)
142 spec := sampleSpec()
143 hash := SchemaCacheKey(spec)
144 p := cachePath(spec.Name)
145 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
146 t.Fatal(err)
147 }
148 legacy := `{"version":2,"spec_hash":"` + hash + `","capabilities":{},"tools":[{"name":"read","description":"legacy","schema":{"type":"object"},"read_only":true}],"last_validated":"2026-01-01T00:00:00Z"}`
149 if err := os.WriteFile(p, []byte(legacy), 0o644); err != nil {
150 t.Fatal(err)
151 }
152
153 got, ok := LoadCachedSchema(spec.Name, hash)
154 if !ok || len(got.Tools) != 1 {
155 t.Fatalf("legacy cache = (%+v,%v), want one tool", got, ok)
156 }
157 if got.Tools[0].Destructive {
158 t.Fatal("legacy cache without destructive field must default to false")
159 }
160 }
161
162 func TestCacheLoadQuarantinesMalformedToolSchema(t *testing.T) {
163 redirectCache(t)
164 spec := sampleSpec()
165 hash := SchemaCacheKey(spec)
166 cs := sampleCachedSchema(hash)
167 cs.Tools = append(cs.Tools, CachedTool{
168 Name: "generate_yso_bytes",
169 Schema: json.RawMessage(`{
170 "type":"object",
171 "properties":{"options":{"type":"array","items":{"key":{"type":"string"},"type":{"type":"string"},"value":{"type":"string"}}}}
172 }`),
173 })
174
175 if err := SaveCachedSchema(spec.Name, cs); err != nil {
176 t.Fatalf("SaveCachedSchema: %v", err)
177 }
178 got, ok := LoadCachedSchema(spec.Name, hash)
179 if !ok {
180 t.Fatal("LoadCachedSchema: miss after save")
181 }
182 if len(got.Tools) != 1 || got.Tools[0].Name != "do_thing" {
183 t.Fatalf("cached tools = %+v, want only valid do_thing", got.Tools)
184 }
185 if schema := string(got.Tools[0].Schema); schema != `{"properties":{},"type":"object"}` {
186 t.Fatalf("valid cached schema = %s", schema)
187 }
188 }
189
190 func TestCacheInvalidatesOnSchemaCacheKeyMismatch(t *testing.T) {
191 redirectCache(t)
192 spec := sampleSpec()
193 key := SchemaCacheKey(spec)
194 if err := SaveCachedSchema(spec.Name, sampleCachedSchema(key)); err != nil {
195 t.Fatalf("SaveCachedSchema: %v", err)
196 }
197 if _, ok := LoadCachedSchema(spec.Name, "different-cache-key"); ok {
198 t.Fatal("LoadCachedSchema: hit despite mismatching expectedKey")
199 }
200 }
201
202 func TestSchemaCacheKeyIgnoresHostOnlyStartupTimeouts(t *testing.T) {
203 spec := sampleSpec()
204 want := SchemaCacheKey(spec)
205 spec.DefaultStartupTimeout = 30 * time.Second
206 spec.StartupTimeout = 90 * time.Second
207 if got := SchemaCacheKey(spec); got != want {
208 t.Fatalf("host-only startup timeout changed provider schema cache key: got %q want %q", got, want)
209 }
210 }
211
212 func TestCacheCorruptedFileReturnsFalse(t *testing.T) {
213 redirectCache(t)
214 p := cachePath("broken")
215 if p == "" {
216 t.Skip("cachePath unavailable in this environment")
217 }
218 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
219 t.Fatal(err)
220 }
221 if err := os.WriteFile(p, []byte("{this is not json"), 0o644); err != nil {
222 t.Fatal(err)
223 }
224 defer func() {
225 if r := recover(); r != nil {
226 t.Fatalf("LoadCachedSchema panicked on corrupt file: %v", r)
227 }
228 }()
229 if _, ok := LoadCachedSchema("broken", "any"); ok {
230 t.Fatal("LoadCachedSchema: hit on corrupt file")
231 }
232 }
233
234 func TestCacheVersionMismatchReturnsFalse(t *testing.T) {
235 // Pin the on-disk version to one we don't recognise: a future writer must
236 // not poison an older binary's cache reads.
237 redirectCache(t)
238 spec := sampleSpec()
239 hash := SchemaCacheKey(spec)
240 cs := sampleCachedSchema(hash)
241 cs.Version = cacheVersion + 99
242 p := cachePath(spec.Name)
243 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
244 t.Fatal(err)
245 }
246 b, err := json.Marshal(cs)
247 if err != nil {
248 t.Fatal(err)
249 }
250 if err := os.WriteFile(p, b, 0o644); err != nil {
251 t.Fatal(err)
252 }
253 if _, ok := LoadCachedSchema(spec.Name, hash); ok {
254 t.Fatal("LoadCachedSchema: hit on future-version cache file")
255 }
256 }
257
258 func TestSchemaCacheKeyStable(t *testing.T) {
259 spec := sampleSpec()
260 h1 := SchemaCacheKey(spec)
261 h2 := SchemaCacheKey(spec)
262 if h1 != h2 {
263 t.Fatalf("SchemaCacheKey not stable: %q vs %q", h1, h2)
264 }
265
266 // Reorder the env (build a new map; Go map iteration order is randomised
267 // but a fresh map can incidentally iterate the same way, so we run a few
268 // iterations to give the runtime a chance to shuffle).
269 reordered := spec
270 for range 32 {
271 reordered.Env = map[string]string{"BAR": "2", "FOO": "1"}
272 if got := SchemaCacheKey(reordered); got != h1 {
273 t.Fatalf("SchemaCacheKey changed when env was rebuilt: %q vs %q", got, h1)
274 }
275 }
276 }
277
278 func TestSchemaCacheKeyIgnoresHostLocalAuthorizationAndIsolation(t *testing.T) {
279 base := sampleSpec()
280 changed := base
281 changed.LaunchManager = mcplaunch.NewManager(filepath.Join(t.TempDir(), mcplaunch.StateFilename), "/workspace")
282 changed.ConfigSource = "project:.mcp.json"
283 changed.Package = "figma"
284 changed.Sandbox = sandbox.Spec{Mode: "enforce", Network: true, WriteRoots: []string{"/workspace"}, MinimalWrites: true}
285 changed.StateDir = "/host/state"
286 changed.OAuthHTTPClient = &http.Client{}
287 if got, want := SchemaCacheKey(changed), SchemaCacheKey(base); got != want {
288 t.Fatalf("host-local security state changed schema cache key: %q != %q", got, want)
289 }
290 }
291
292 func TestSchemaCacheKeyTracksNonSecretSpecIdentityOnly(t *testing.T) {
293 a := sampleSpec()
294 renamed := a
295 renamed.Name = "other-server"
296 if SchemaCacheKey(a) == SchemaCacheKey(renamed) {
297 t.Fatal("SchemaCacheKey did not change when server name changed")
298 }
299
300 b := a
301 b.Command = "/usr/local/bin/other"
302 if SchemaCacheKey(a) == SchemaCacheKey(b) {
303 t.Fatal("SchemaCacheKey did not change when Command changed")
304 }
305
306 c := a
307 c.Args = append([]string{}, a.Args...)
308 c.Args[0] = "--different"
309 if SchemaCacheKey(a) == SchemaCacheKey(c) {
310 t.Fatal("SchemaCacheKey did not change when Args changed")
311 }
312
313 d := a
314 d.Env = map[string]string{"FOO": "1", "BAR": "different"}
315 if SchemaCacheKey(a) != SchemaCacheKey(d) {
316 t.Fatal("SchemaCacheKey changed when an environment credential value rotated")
317 }
318
319 e := a
320 e.Env = map[string]string{"FOO": "1", "NEW_KEY": "2"}
321 if SchemaCacheKey(a) == SchemaCacheKey(e) {
322 t.Fatal("SchemaCacheKey did not change when environment key names changed")
323 }
324
325 f := a
326 f.Headers = map[string]string{"X-Custom": "rotated-secret"}
327 if SchemaCacheKey(a) != SchemaCacheKey(f) {
328 t.Fatal("SchemaCacheKey changed when a header credential value rotated")
329 }
330
331 g := a
332 g.Headers = map[string]string{"Authorization": "secret"}
333 if SchemaCacheKey(a) == SchemaCacheKey(g) {
334 t.Fatal("SchemaCacheKey did not change when header key names changed")
335 }
336
337 h := a
338 h.Type = "http"
339 h.URL = "https://user:secret@example.com/mcp?access_token=first&workspace=one"
340 i := h
341 i.URL = "https://other:rotated@example.com/mcp?access_token=second&workspace=two"
342 if SchemaCacheKey(h) == SchemaCacheKey(i) {
343 t.Fatal("SchemaCacheKey did not bind URL credential/query values")
344 }
345 }
346
347 func TestCacheMissForUnknownName(t *testing.T) {
348 redirectCache(t)
349 if _, ok := LoadCachedSchema("never-saved", "anything"); ok {
350 t.Fatal("LoadCachedSchema: hit for a name that was never saved")
351 }
352 }
353
354 func TestSlugSafeForFilesystem(t *testing.T) {
355 if got := slug("a_b-c"); got != "a_b-c" {
356 t.Fatalf("safe slug changed: %q", got)
357 }
358 inputs := []string{"My Server!", "my-server", "weird/name\\with:bad", "weird-name-with-bad", "", "------", "Foo", "foo"}
359 seen := map[string]string{}
360 for _, in := range inputs {
361 got := slug(in)
362 if got == "" || strings.ContainsAny(got, `/\\:`) {
363 t.Fatalf("slug(%q) is not filesystem-safe: %q", in, got)
364 }
365 if previous, exists := seen[got]; exists {
366 t.Fatalf("slug collision: %q and %q both became %q", previous, in, got)
367 }
368 seen[got] = in
369 }
370 }
371
372 func TestMCPStateDirSeparatesConfusableServerNames(t *testing.T) {
373 home, workspace := t.TempDir(), t.TempDir()
374 names := []string{"foo", "Foo", "foo bar", "foo-bar", "foo/bar", "foo\\bar"}
375 seen := map[string]string{}
376 for _, name := range names {
377 dir := MCPStateDir(home, workspace, name)
378 if previous, exists := seen[dir]; exists {
379 t.Fatalf("state-directory collision: %q and %q both use %q", previous, name, dir)
380 }
381 seen[dir] = name
382 }
383 }
384
385 func TestSlugAppendsHashForWindowsReservedDeviceNames(t *testing.T) {
386 for _, name := range []string{"con", "CON", "prn", "aux", "nul", "com1", "COM9", "lpt1", "LPT9"} {
387 got := slug(name)
388 lowered := strings.ToLower(name)
389 if got == lowered {
390 t.Errorf("slug(%q) = %q names a Windows device", name, got)
391 }
392 if !strings.HasPrefix(got, lowered+"-") {
393 t.Errorf("slug(%q) = %q, want %q plus a hash suffix", name, got, lowered)
394 }
395 }
396 // Ordinary safe names must stay byte-identical: existing cache, stats, and
397 // state paths depend on it.
398 for _, name := range []string{"github", "context7", "a_b-c", "console", "com10", "naux"} {
399 if got := slug(name); got != name {
400 t.Errorf("slug(%q) = %q, want unchanged", name, got)
401 }
402 }
403 }
404
405 func TestSchemaCacheKeyRedactsURLCredentialsButKeepsResourceScope(t *testing.T) {
406 base := sampleSpec()
407 base.Type = "http"
408 base.URL = "https://user:first@example.com/mcp?access_token=one&workspace=alpha&tenant=t1"
409
410 rotated := base
411 rotated.URL = "https://user:second@example.com/mcp?access_token=two&workspace=alpha&tenant=t1"
412 if SchemaCacheKey(base) != SchemaCacheKey(rotated) {
413 t.Fatal("credential rotation changed the schema cache key")
414 }
415
416 reordered := base
417 reordered.URL = "https://user:first@example.com/mcp?tenant=t1&workspace=alpha&access_token=one"
418 if SchemaCacheKey(base) != SchemaCacheKey(reordered) {
419 t.Fatal("query parameter order changed the schema cache key")
420 }
421
422 movedWorkspace := base
423 movedWorkspace.URL = "https://user:first@example.com/mcp?access_token=one&workspace=beta&tenant=t1"
424 if SchemaCacheKey(base) == SchemaCacheKey(movedWorkspace) {
425 t.Fatal("workspace scope change did not change the schema cache key")
426 }
427
428 // Case/separator variants of credential keys are still recognized: their
429 // values redact, so rotating them never moves the cache key. The key
430 // spelling itself remains identity-bearing.
431 variant := base
432 variant.URL = "https://user:first@example.com/mcp?ACCESS-TOKEN=three&workspace=alpha&tenant=t1"
433 variantRotated := base
434 variantRotated.URL = "https://user:first@example.com/mcp?ACCESS-TOKEN=four&workspace=alpha&tenant=t1"
435 if SchemaCacheKey(variant) != SchemaCacheKey(variantRotated) {
436 t.Fatal("variant-spelled credential key leaked its value into the schema cache key")
437 }
438 }
439
440 func TestLoadCachedSchemaForSpecMigratesLegacyURLCacheKey(t *testing.T) {
441 redirectCache(t)
442 spec := sampleSpec()
443 spec.Name = "legacy-url"
444 spec.Type = "http"
445 spec.URL = "https://example.com/mcp?access_token=secret&workspace=alpha"
446
447 legacy, ok := legacySchemaCacheKey(spec)
448 if !ok {
449 t.Fatal("legacy cache key unavailable for credential-bearing URL")
450 }
451 if err := SaveCachedSchema(spec.Name, CachedSchema{
452 CacheKey: legacy,
453 Capabilities: map[string]bool{"tools": true},
454 Tools: []CachedTool{{Name: "echo", Schema: json.RawMessage(`{"type":"object"}`)}},
455 }); err != nil {
456 t.Fatal(err)
457 }
458
459 cs, ok := LoadCachedSchemaForSpec(spec)
460 if !ok || len(cs.Tools) != 1 || cs.Tools[0].Name != "echo" {
461 t.Fatalf("legacy cache entry did not load: ok=%v cs=%+v", ok, cs)
462 }
463 if cs.CacheKey != SchemaCacheKey(spec) {
464 t.Fatalf("loaded cache kept legacy key %q", cs.CacheKey)
465 }
466 // The upgrade persists: a plain current-key load now succeeds.
467 if _, ok := LoadCachedSchema(spec.Name, SchemaCacheKey(spec)); !ok {
468 t.Fatal("legacy cache entry was not rewritten in place")
469 }
470 }
471
472 func TestNormalizeIdentityURLRedactsCredentialMaterial(t *testing.T) {
473 got := normalizeIdentityURL("HTTPS://User:Secret@Example.COM:443/mcp#frag?x=1")
474 if strings.Contains(got, "Secret") {
475 t.Fatalf("normalized URL leaked a password: %q", got)
476 }
477 rotatedA := normalizeIdentityURL("https://example.com/mcp?api_key=one&workspace=alpha")
478 rotatedB := normalizeIdentityURL("https://example.com/mcp?api_key=two&workspace=alpha")
479 if rotatedA != rotatedB {
480 t.Fatalf("credential rotation changed normalization: %q != %q", rotatedA, rotatedB)
481 }
482 if !strings.Contains(rotatedA, "workspace=alpha") {
483 t.Fatalf("non-sensitive query value dropped: %q", rotatedA)
484 }
485 userinfoOnly := normalizeIdentityURL("https://alice@example.com/mcp")
486 userinfoPassword := normalizeIdentityURL("https://alice:pw@example.com/mcp")
487 if userinfoOnly == userinfoPassword {
488 t.Fatal("userinfo structure (password presence) was not preserved")
489 }
490 if strings.Contains(userinfoOnly, "alice") || strings.Contains(userinfoPassword, "pw") {
491 t.Fatalf("userinfo values leaked: %q %q", userinfoOnly, userinfoPassword)
492 }
493 }
494
495 func TestCredentialURLQueryKeyMatrix(t *testing.T) {
496 credentials := []string{
497 "token", "access_token", "auth_token", "refresh_token", "id_token",
498 "api_token", "session_token", "bearer_token", "sas_token", "csrf-token",
499 "api_key", "x-api-key", "apikey", "API-KEY", "key", "access_key",
500 "secret_key", "private_key", "auth_key", "app_key", "client_key",
501 "subscription-key", "shared_key",
502 "secret", "client_secret", "app_secret", "api_secret",
503 "password", "passwd", "user_password",
504 "signature", "sas_signature", "sig",
505 "auth", "authorization", "bearer", "credential", "credentials",
506 }
507 for _, key := range credentials {
508 if !credentialURLQueryKey(key) {
509 t.Errorf("credential key %q was not classified as sensitive", key)
510 }
511 }
512 resources := []string{
513 "workspace", "tenant", "region", "resource", "project", "org",
514 "scope", "version", "monkey", "keyboard", "market", "environment",
515 }
516 for _, key := range resources {
517 if credentialURLQueryKey(key) {
518 t.Errorf("resource key %q was misclassified as a credential", key)
519 }
520 }
521 }
522
522 lines GO