| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | func TestMCPActivationStoreDefaultsAndOverrides(t *testing.T) { |
| 13 | home := t.TempDir() |
| 14 | store := NewMCPActivationStore(home) |
| 15 | |
| 16 | entry := PluginEntry{Name: "chrome-devtools", Source: MCPSourceUserConfig} |
| 17 | enabled, err := store.IsEnabled(entry, "") |
| 18 | if err != nil || !enabled { |
| 19 | t.Fatalf("default user install should be enabled: enabled=%v err=%v", enabled, err) |
| 20 | } |
| 21 | |
| 22 | disabled := false |
| 23 | entry.AutoStart = &disabled |
| 24 | enabled, err = store.IsEnabled(entry, "") |
| 25 | if err != nil || enabled { |
| 26 | t.Fatalf("auto_start=false without override should disable: enabled=%v err=%v", enabled, err) |
| 27 | } |
| 28 | |
| 29 | if err := store.SetEnabled(MCPActivationOverride{ |
| 30 | Scope: MCPActivationGlobal, |
| 31 | Source: string(MCPSourceUserConfig), |
| 32 | Server: "chrome-devtools", |
| 33 | Enabled: true, |
| 34 | }); err != nil { |
| 35 | t.Fatalf("SetEnabled: %v", err) |
| 36 | } |
| 37 | enabled, err = store.IsEnabled(entry, "") |
| 38 | if err != nil || !enabled { |
| 39 | t.Fatalf("override should re-enable despite auto_start=false: enabled=%v err=%v", enabled, err) |
| 40 | } |
| 41 | |
| 42 | path := MCPActivationPath(home) |
| 43 | info, err := os.Stat(path) |
| 44 | if err != nil { |
| 45 | t.Fatalf("stat activation file: %v", err) |
| 46 | } |
| 47 | if perm := info.Mode().Perm(); runtime.GOOS != "windows" && perm != 0o600 { |
| 48 | t.Fatalf("activation file mode = %o, want 0600", perm) |
| 49 | } |
| 50 | |
| 51 | // Atomic rewrite should keep a valid JSON document. |
| 52 | raw, err := os.ReadFile(path) |
| 53 | if err != nil { |
| 54 | t.Fatalf("read activation: %v", err) |
| 55 | } |
| 56 | if len(raw) == 0 || raw[0] != '{' { |
| 57 | t.Fatalf("activation JSON = %q", raw) |
| 58 | } |
| 59 | if filepath.Base(path) != "mcp-activation.json" { |
| 60 | t.Fatalf("unexpected activation path base %q", path) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestProjectMCPIsTrustedButActivationRemainsWorkspaceScoped(t *testing.T) { |
| 65 | for _, source := range []MCPConfigSource{MCPSourceProjectConfig, MCPSourceProjectMCPJSON} { |
| 66 | entry := PluginEntry{Name: "project-mcp", Source: source} |
| 67 | if !source.UserAuthorized() || !source.ProjectScoped() { |
| 68 | t.Fatalf("project source %q policy = authorized:%v scoped:%v, want trusted and workspace scoped", |
| 69 | source, source.UserAuthorized(), source.ProjectScoped()) |
| 70 | } |
| 71 | scopeA, workspaceA, gotSource, _ := ActivationIdentity(entry, "/workspace/a") |
| 72 | scopeB, workspaceB, _, _ := ActivationIdentity(entry, "/workspace/b") |
| 73 | if scopeA != MCPActivationWorkspace || scopeB != MCPActivationWorkspace || workspaceA == "" || workspaceA == workspaceB { |
| 74 | t.Fatalf("project activation identities = (%q,%q,%q) and (%q,%q), want distinct workspace scopes", |
| 75 | scopeA, workspaceA, gotSource, scopeB, workspaceB) |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func TestMCPActivationStoreConcurrentIndependentWriters(t *testing.T) { |
| 81 | home := t.TempDir() |
| 82 | const writers = 24 |
| 83 | stores := make([]*MCPActivationStore, writers) |
| 84 | for i := range stores { |
| 85 | stores[i] = NewMCPActivationStore(home) |
| 86 | } |
| 87 | |
| 88 | start := make(chan struct{}) |
| 89 | var wg sync.WaitGroup |
| 90 | for i, store := range stores { |
| 91 | wg.Add(1) |
| 92 | go func() { |
| 93 | defer wg.Done() |
| 94 | <-start |
| 95 | if err := store.SetEnabled(MCPActivationOverride{ |
| 96 | Scope: MCPActivationGlobal, |
| 97 | Server: fmt.Sprintf("server-%02d", i), |
| 98 | Enabled: true, |
| 99 | }); err != nil { |
| 100 | t.Errorf("SetEnabled(%d): %v", i, err) |
| 101 | } |
| 102 | }() |
| 103 | } |
| 104 | close(start) |
| 105 | wg.Wait() |
| 106 | |
| 107 | file, err := NewMCPActivationStore(home).Load() |
| 108 | if err != nil { |
| 109 | t.Fatalf("Load: %v", err) |
| 110 | } |
| 111 | if len(file.Overrides) != writers { |
| 112 | t.Fatalf("activation overrides = %d, want %d (lost update)", len(file.Overrides), writers) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestEnabledPluginsHonorsActivation(t *testing.T) { |
| 117 | home := t.TempDir() |
| 118 | t.Setenv("REASONIX_HOME", home) |
| 119 | store := NewMCPActivationStore(home) |
| 120 | if err := store.SetEnabled(MCPActivationOverride{ |
| 121 | Scope: MCPActivationGlobal, |
| 122 | Source: string(MCPSourceUserConfig), |
| 123 | Server: "a", |
| 124 | Enabled: false, |
| 125 | }); err != nil { |
| 126 | t.Fatalf("SetEnabled: %v", err) |
| 127 | } |
| 128 | cfg := &Config{Plugins: []PluginEntry{ |
| 129 | {Name: "a", Source: MCPSourceUserConfig}, |
| 130 | {Name: "b", Source: MCPSourceUserConfig}, |
| 131 | }} |
| 132 | got := cfg.EnabledPlugins("", store) |
| 133 | if len(got) != 1 || got[0].Name != "b" { |
| 134 | t.Fatalf("EnabledPlugins = %+v, want [b]", got) |
| 135 | } |
| 136 | } |
| 137 |