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