返回 DeepSeek-Reasonix
mcp_apps_test.go
根目录 / desktop / mcp_apps_test.go
1 package main
2
3 import (
4 "net/http"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "reasonix/internal/control"
10 "reasonix/internal/plugin"
11 )
12
13 func TestMCPAppBindingSurvivesActiveTabSwitchAndClosesOriginalHost(t *testing.T) {
14 app := NewApp()
15 hostA := plugin.NewHostWithProfile(plugin.HostProfileDesktopApps)
16 hostB := plugin.NewHostWithProfile(plugin.HostProfileDesktopApps)
17 ctrlA := control.New(control.Options{Host: hostA})
18 ctrlB := control.New(control.Options{Host: hostB})
19 app.mu.Lock()
20 app.tabs = map[string]*WorkspaceTab{
21 "tab-a": {ID: "tab-a", Ctrl: ctrlA},
22 "tab-b": {ID: "tab-b", Ctrl: ctrlB},
23 }
24 app.tabOrder = []string{"tab-a", "tab-b"}
25 app.activeTabID = "tab-b"
26 app.mu.Unlock()
27
28 inst := hostA.RegisterAppInstance("srv", "tool", 3, "call", "ui://x/a.html")
29 if !hostA.BindAppResource(inst.Token, "<html>a</html>", "text/html", "digest-a", nil) {
30 t.Fatal("bind resource")
31 }
32 app.mcpAppsSandbox.bind(inst.Token, mcpAppBinding{
33 tabID: "tab-a", server: "srv", host: hostA, ctrl: ctrlA,
34 })
35
36 if _, err := app.MCPAppResourceDigestForTab("tab-b", inst.Token); err == nil {
37 t.Fatal("cross-tab digest lookup was accepted")
38 }
39 if digest, err := app.MCPAppResourceDigestForTab("tab-a", inst.Token); err != nil || digest != "digest-a" {
40 t.Fatalf("digest = %q, err = %v", digest, err)
41 }
42 if err := app.MCPCloseAppInstanceForTab("tab-b", inst.Token); err == nil {
43 t.Fatal("cross-tab close was accepted")
44 }
45 if _, ok := hostA.LookupAppInstance(inst.Token); !ok {
46 t.Fatal("wrong-tab close released original instance")
47 }
48 if err := app.MCPCloseAppInstanceForTab("tab-a", inst.Token); err != nil {
49 t.Fatal(err)
50 }
51 if _, ok := hostA.LookupAppInstance(inst.Token); ok {
52 t.Fatal("original host instance was not released")
53 }
54 }
55
56 func TestMCPAppSandboxRelayIsBidirectionalAndEmbeddable(t *testing.T) {
57 origin := &mcpAppOrigin{nonce: "test-nonce"}
58 req := httptest.NewRequest(http.MethodGet, "/sandbox?nonce=test-nonce", nil)
59 rec := httptest.NewRecorder()
60 origin.serveRelayPage(rec, req)
61 if rec.Code != http.StatusOK {
62 t.Fatalf("status = %d", rec.Code)
63 }
64 if got := rec.Header().Get("X-Frame-Options"); got != "" {
65 t.Fatalf("outer relay cannot be embedded: X-Frame-Options=%q", got)
66 }
67 body := rec.Body.String()
68 for _, required := range []string{
69 "event.source === window.parent",
70 "inner.contentWindow.postMessage(event.data",
71 "event.source !== inner.contentWindow",
72 "window.parent.postMessage(event.data",
73 "new TextEncoder().encode(text).byteLength",
74 } {
75 if !strings.Contains(body, required) {
76 t.Fatalf("relay missing %q", required)
77 }
78 }
79 }
80
81 func TestMCPAppSandboxServesOnlyTheBoundDigest(t *testing.T) {
82 app := NewApp()
83 host := plugin.NewHostWithProfile(plugin.HostProfileDesktopApps)
84 inst := host.RegisterAppInstance("srv", "tool", 3, "call", "ui://x/a.html")
85 if !host.BindAppResource(inst.Token, "<html>frozen</html>", "text/html", "digest-a", map[string][]string{
86 "connectDomains": {"https://api.example.test", "https://*.blocked.test"},
87 "resourceDomains": {"https://cdn.example.test"},
88 }) {
89 t.Fatal("bind resource")
90 }
91 app.mcpAppsSandbox.bind(inst.Token, mcpAppBinding{tabID: "tab-a", server: "srv", host: host})
92 handler := (&mcpAppOrigin{server: "srv"}).serveResource(app)
93
94 bad := httptest.NewRecorder()
95 handler(bad, httptest.NewRequest(http.MethodGet, "/resource?token="+inst.Token+"&digest=other", nil))
96 if bad.Code != http.StatusBadGateway {
97 t.Fatalf("wrong digest status = %d", bad.Code)
98 }
99
100 good := httptest.NewRecorder()
101 handler(good, httptest.NewRequest(http.MethodGet, "/resource?token="+inst.Token+"&digest=digest-a", nil))
102 if good.Code != http.StatusOK || good.Body.String() != "<html>frozen</html>" {
103 t.Fatalf("bound resource status/body = %d %q", good.Code, good.Body.String())
104 }
105 if got := good.Header().Get("X-App-Sha256"); got != "digest-a" {
106 t.Fatalf("digest header = %q", got)
107 }
108 csp := good.Header().Get("Content-Security-Policy")
109 if !strings.Contains(csp, "connect-src https://api.example.test") || strings.Contains(csp, "blocked.test") {
110 t.Fatalf("CSP = %q", csp)
111 }
112 if !strings.Contains(csp, "script-src 'unsafe-inline' https://cdn.example.test") {
113 t.Fatalf("resourceDomains not mapped into CSP: %q", csp)
114 }
115 }
116
117 func TestValidatedMCPAppLinkRejectsUnsafeTargets(t *testing.T) {
118 for _, raw := range []string{
119 "javascript:alert(1)",
120 "file:///tmp/secret",
121 "https://user:pass@example.test/private",
122 "//example.test/no-scheme",
123 } {
124 if _, err := validatedAppLink(raw); err == nil {
125 t.Fatalf("unsafe URL accepted: %q", raw)
126 }
127 }
128 if got, err := validatedAppLink("https://example.test/path?q=1"); err != nil || got.Host != "example.test" {
129 t.Fatalf("safe URL rejected: %v, %v", got, err)
130 }
131 }
132
132 lines GO