| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "os/exec" |
| 6 | "path/filepath" |
| 7 | "reflect" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func TestCCSwitchRowsToPlugins(t *testing.T) { |
| 12 | rows := []ccSwitchMCPRow{ |
| 13 | {ID: "docs-id", Name: "docs", ServerConfig: `{"type":"http","url":"https://mcp.example.test","headers":{"Authorization":"Bearer ${TOKEN}"}}`}, |
| 14 | {Name: "fs", ServerConfig: `{"command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","."]}`}, |
| 15 | } |
| 16 | got, err := ccSwitchRowsToPlugins(rows) |
| 17 | if err != nil { |
| 18 | t.Fatalf("ccSwitchRowsToPlugins: %v", err) |
| 19 | } |
| 20 | if got[0].Name != "docs-id" || got[0].Type != "http" || got[0].URL != "https://mcp.example.test" { |
| 21 | t.Fatalf("http entry = %+v", got[0]) |
| 22 | } |
| 23 | if got[0].Headers["Authorization"] != "Bearer ${TOKEN}" { |
| 24 | t.Errorf("header was not preserved: %+v", got[0].Headers) |
| 25 | } |
| 26 | if got[1].Name != "fs" || got[1].Command != "npx" || |
| 27 | !reflect.DeepEqual(got[1].Args, []string{"-y", "@modelcontextprotocol/server-filesystem", "."}) { |
| 28 | t.Fatalf("stdio entry = %+v", got[1]) |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | func TestCCSwitchRowsPreferIDForDuplicateDisplayNames(t *testing.T) { |
| 33 | rows := []ccSwitchMCPRow{ |
| 34 | {ID: "search-code", Name: "search", ServerConfig: `{"command":"node","args":["code.js"]}`}, |
| 35 | {ID: "search-docs", Name: "search", ServerConfig: `{"command":"node","args":["docs.js"]}`}, |
| 36 | } |
| 37 | got, err := ccSwitchRowsToPlugins(rows) |
| 38 | if err != nil { |
| 39 | t.Fatalf("ccSwitchRowsToPlugins: %v", err) |
| 40 | } |
| 41 | if got[0].Name != "search-code" || got[1].Name != "search-docs" { |
| 42 | t.Fatalf("names = %q, %q; want stable ids", got[0].Name, got[1].Name) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestCCSwitchImportClassifiesRiskyServers(t *testing.T) { |
| 47 | rows := []ccSwitchMCPRow{ |
| 48 | {Name: "@modelcontextprotocol/server-chrome-devtools", ServerConfig: `{"command":"npx","args":["-y","chrome-devtools-mcp@latest"]}`}, |
| 49 | {Name: "legacy", ServerConfig: `{"type":"sse","url":"https://example.test/sse"}`}, |
| 50 | } |
| 51 | got, err := ccSwitchRowsToPlugins(rows) |
| 52 | if err != nil { |
| 53 | t.Fatalf("ccSwitchRowsToPlugins: %v", err) |
| 54 | } |
| 55 | for _, e := range got { |
| 56 | candidate := classifyMCPImportCandidate(e) |
| 57 | if candidate.Recommended { |
| 58 | t.Fatalf("%s should not be recommended: %+v", e.Name, candidate) |
| 59 | } |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestLoadCCSwitchLegacyConfig(t *testing.T) { |
| 64 | dir := t.TempDir() |
| 65 | path := filepath.Join(dir, "config.json.migrated") |
| 66 | body := `{ |
| 67 | "mcp": { |
| 68 | "servers": { |
| 69 | "off": { |
| 70 | "name": "off", |
| 71 | "server": {"command": "node", "args": ["off.js"]}, |
| 72 | "apps": {"codex": false} |
| 73 | }, |
| 74 | "time": { |
| 75 | "name": "@modelcontextprotocol/server-time", |
| 76 | "server": {"type":"stdio", "command": "uvx", "args": ["mcp-server-time"]}, |
| 77 | "apps": {"codex": true} |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | }` |
| 82 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | got, err := loadCCSwitchLegacyConfig(path) |
| 86 | if err != nil { |
| 87 | t.Fatalf("loadCCSwitchLegacyConfig: %v", err) |
| 88 | } |
| 89 | if len(got) != 1 { |
| 90 | t.Fatalf("entries = %d, want 1: %+v", len(got), got) |
| 91 | } |
| 92 | if got[0].Name != "@modelcontextprotocol/server-time" || got[0].Command != "uvx" { |
| 93 | t.Fatalf("entry = %+v", got[0]) |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | func TestLoadCCSwitchLegacyConfigPrefersReasonixFlag(t *testing.T) { |
| 98 | dir := t.TempDir() |
| 99 | path := filepath.Join(dir, "config.json") |
| 100 | body := `{ |
| 101 | "mcp": { |
| 102 | "servers": { |
| 103 | "legacy": { |
| 104 | "name": "legacy", |
| 105 | "server": {"command": "node", "args": ["legacy.js"]}, |
| 106 | "apps": {"codex": true} |
| 107 | }, |
| 108 | "reasonix-off": { |
| 109 | "name": "reasonix-off", |
| 110 | "server": {"command": "node", "args": ["off.js"]}, |
| 111 | "apps": {"codex": true, "reasonix": false} |
| 112 | }, |
| 113 | "reasonix-on": { |
| 114 | "name": "reasonix-on", |
| 115 | "server": {"command": "node", "args": ["on.js"]}, |
| 116 | "apps": {"codex": false, "reasonix": true} |
| 117 | } |
| 118 | } |
| 119 | } |
| 120 | }` |
| 121 | if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | got, err := loadCCSwitchLegacyConfig(path) |
| 125 | if err != nil { |
| 126 | t.Fatalf("loadCCSwitchLegacyConfig: %v", err) |
| 127 | } |
| 128 | if len(got) != 2 || got[0].Name != "legacy" || got[1].Name != "reasonix-on" { |
| 129 | t.Fatalf("entries = %+v, want legacy fallback and explicit Reasonix enablement", got) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | func TestLoadCCSwitchMCPDBPrefersReasonixColumn(t *testing.T) { |
| 134 | if _, err := exec.LookPath("sqlite3"); err != nil { |
| 135 | t.Skip("sqlite3 not available") |
| 136 | } |
| 137 | dbPath := filepath.Join(t.TempDir(), "cc-switch.db") |
| 138 | setup := `CREATE TABLE mcp_servers ( |
| 139 | id TEXT PRIMARY KEY, |
| 140 | name TEXT NOT NULL, |
| 141 | server_config TEXT NOT NULL, |
| 142 | enabled_codex BOOLEAN NOT NULL DEFAULT 0, |
| 143 | enabled_reasonix BOOLEAN NOT NULL DEFAULT 0 |
| 144 | ); |
| 145 | INSERT INTO mcp_servers VALUES ('codex-only', 'codex-only', '{"command":"node","args":["codex.js"]}', 1, 0); |
| 146 | INSERT INTO mcp_servers VALUES ('reasonix-only', 'reasonix-only', '{"command":"node","args":["reasonix.js"]}', 0, 1);` |
| 147 | if out, err := exec.Command("sqlite3", dbPath, setup).CombinedOutput(); err != nil { |
| 148 | t.Fatalf("create sqlite db: %v\n%s", err, out) |
| 149 | } |
| 150 | got, err := loadCCSwitchMCPDB(dbPath) |
| 151 | if err != nil { |
| 152 | t.Fatalf("loadCCSwitchMCPDB: %v", err) |
| 153 | } |
| 154 | if len(got) != 1 || got[0].Name != "reasonix-only" { |
| 155 | t.Fatalf("entries = %+v, want only explicit Reasonix enablement", got) |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | func TestLoadCCSwitchMCPDBFallsBackToCodexWithoutReasonixColumn(t *testing.T) { |
| 160 | if _, err := exec.LookPath("sqlite3"); err != nil { |
| 161 | t.Skip("sqlite3 not available") |
| 162 | } |
| 163 | dbPath := filepath.Join(t.TempDir(), "cc-switch.db") |
| 164 | setup := `CREATE TABLE mcp_servers ( |
| 165 | id TEXT PRIMARY KEY, |
| 166 | name TEXT NOT NULL, |
| 167 | server_config TEXT NOT NULL, |
| 168 | enabled_codex BOOLEAN NOT NULL DEFAULT 0 |
| 169 | ); |
| 170 | INSERT INTO mcp_servers VALUES ('codex-on', 'codex-on', '{"command":"node","args":["on.js"]}', 1); |
| 171 | INSERT INTO mcp_servers VALUES ('codex-off', 'codex-off', '{"command":"node","args":["off.js"]}', 0);` |
| 172 | if out, err := exec.Command("sqlite3", dbPath, setup).CombinedOutput(); err != nil { |
| 173 | t.Fatalf("create sqlite db: %v\n%s", err, out) |
| 174 | } |
| 175 | got, err := loadCCSwitchMCPDB(dbPath) |
| 176 | if err != nil { |
| 177 | t.Fatalf("loadCCSwitchMCPDB: %v", err) |
| 178 | } |
| 179 | if len(got) != 1 || got[0].Name != "codex-on" { |
| 180 | t.Fatalf("entries = %+v, want legacy Codex fallback", got) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func TestLoadCCSwitchMCPEmptyDBDoesNotReadLegacyBackups(t *testing.T) { |
| 185 | if _, err := exec.LookPath("sqlite3"); err != nil { |
| 186 | t.Skip("sqlite3 not available") |
| 187 | } |
| 188 | root := t.TempDir() |
| 189 | dbPath := filepath.Join(root, "cc-switch.db") |
| 190 | if out, err := exec.Command("sqlite3", dbPath, `CREATE TABLE mcp_servers ( |
| 191 | id TEXT PRIMARY KEY, |
| 192 | name TEXT NOT NULL, |
| 193 | server_config TEXT NOT NULL, |
| 194 | enabled_codex BOOLEAN NOT NULL DEFAULT 0 |
| 195 | );`).CombinedOutput(); err != nil { |
| 196 | t.Fatalf("create sqlite db: %v\n%s", err, out) |
| 197 | } |
| 198 | stale := `{"mcp":{"servers":{"stale":{"name":"stale","server":{"command":"node","args":["stale.js"]},"apps":{"codex":true}}}}}` |
| 199 | if err := os.WriteFile(filepath.Join(root, "config.json.migrated"), []byte(stale), 0o644); err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | got, err := loadCCSwitchMCPFromRoot(root) |
| 203 | if err != nil { |
| 204 | t.Fatalf("loadCCSwitchMCPFromRoot: %v", err) |
| 205 | } |
| 206 | if len(got) != 0 { |
| 207 | t.Fatalf("empty sqlite db should be authoritative, got legacy entries: %+v", got) |
| 208 | } |
| 209 | } |
| 210 |