| 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 |