| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | tea "charm.land/bubbletea/v2" |
| 11 | "reasonix/internal/config" |
| 12 | "reasonix/internal/control" |
| 13 | ) |
| 14 | |
| 15 | func TestAuditSetupEditsEffectiveProviderSource(t *testing.T) { |
| 16 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 17 | root := t.TempDir() |
| 18 | global := config.Default() |
| 19 | global.Providers = []config.ProviderEntry{{Name: "relay", Kind: "openai", BaseURL: "https://global.invalid/v1", Model: "chat", APIKeyEnv: "GLOBAL_KEY"}} |
| 20 | if err := global.SaveTo(config.UserConfigPath()); err != nil { |
| 21 | t.Fatal(err) |
| 22 | } |
| 23 | project := filepath.Join(root, "reasonix.toml") |
| 24 | if err := os.WriteFile(project, []byte("[[providers]]\nname = \"relay\"\nkind = \"openai\"\nbase_url = \"https://project.invalid/v1\"\nmodel = \"chat\"\napi_key_env = \"PROJECT_KEY\"\n"), 0600); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | effective, err := config.LoadForRootReadOnly(root) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | entry, ok := effective.Provider("relay") |
| 32 | if !ok || entry.BaseURL != "https://global.invalid/v1" { |
| 33 | t.Fatal("fixture did not resolve global provider") |
| 34 | } |
| 35 | ctrl := control.New(control.Options{WorkspaceRoot: root}) |
| 36 | t.Cleanup(ctrl.Close) |
| 37 | m := newTestChatTUI() |
| 38 | m.ctrl = ctrl |
| 39 | m.beginConnectionKeyEdit("relay") |
| 40 | if m.setup.configPath != config.UserConfigPath() { |
| 41 | t.Fatalf("setup edits shadowed project config %s instead of active global config", m.setup.configPath) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestAuditSetupPasteDoesNotEnterChat(t *testing.T) { |
| 46 | for _, native := range []bool{false, true} { |
| 47 | t.Run(fmt.Sprint(native), func(t *testing.T) { |
| 48 | m := newTestChatTUI() |
| 49 | m.setup = &connectionSetup{providerName: "relay"} |
| 50 | m.input.SetValue("user draft") |
| 51 | var msg tea.Msg = tea.PasteMsg{Content: "sk-audit-secret-value"} |
| 52 | if native { |
| 53 | msg = clipboardTextPasteMsg{text: "sk-audit-secret-value"} |
| 54 | } |
| 55 | updated, _ := m.Update(msg) |
| 56 | m = updated.(chatTUI) |
| 57 | if strings.Contains(m.input.Value(), "sk-audit-secret-value") { |
| 58 | t.Fatal("pasting into the credential editor inserted the secret into the chat composer") |
| 59 | } |
| 60 | if m.setup.key != "sk-audit-secret-value" { |
| 61 | t.Fatal("paste did not reach masked credential field") |
| 62 | } |
| 63 | }) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestAuditShellSetupKeyOnlyEditPersists(t *testing.T) { |
| 68 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 69 | c := config.Default() |
| 70 | c.Providers = []config.ProviderEntry{{Name: "relay", Kind: "openai", BaseURL: "https://relay.invalid/v1", Model: "chat", APIKeyEnv: "OLD_KEY"}} |
| 71 | path := config.UserConfigPath() |
| 72 | if err := c.SaveTo(path); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | c, err := config.LoadForEditReadOnlyStrict(path) |
| 76 | if err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | s := newProviderSetupSessionForPath(c, path) |
| 80 | if err := s.setCredentialForProviders([]string{"relay"}, "OLD_KEY", "updated-secret"); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | written, err := commitProviderSetupSession(s, path) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | if !written { |
| 88 | t.Fatal("updating only an existing key silently skipped the commit") |
| 89 | } |
| 90 | saved, err := config.LoadForEditReadOnlyStrict(path) |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | entry, _ := saved.Provider("relay") |
| 95 | if entry.APIKeyEnv == "OLD_KEY" || !config.CredentialStored(entry.APIKeyEnv) { |
| 96 | t.Fatal("new credential was not published") |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestAuditShellSetupDistinctConnectionKeysStaySeparate(t *testing.T) { |
| 101 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 102 | c := config.Default() |
| 103 | c.Providers = []config.ProviderEntry{{Name: "a", Kind: "openai", BaseURL: "https://relay.invalid/v1", Model: "chat", APIKeyEnv: "SHARED_KEY"}, {Name: "b", Kind: "openai", BaseURL: "https://relay.invalid/v1", Model: "chat", APIKeyEnv: "SHARED_KEY"}} |
| 104 | c.DefaultModel = "a/chat" |
| 105 | path := config.UserConfigPath() |
| 106 | if err := c.SaveTo(path); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | c, err := config.LoadForEditReadOnlyStrict(path) |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | s := newProviderSetupSessionForPath(c, path) |
| 114 | // Make another legitimate edit so this exercises the commit rather than the |
| 115 | // independent key-only no-op defect. |
| 116 | if err := s.setDefaultModel("b/chat"); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if err := s.setCredentialForProviders([]string{"a"}, "SHARED_KEY", "key-for-a"); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | if err := s.setCredentialForProviders([]string{"b"}, "SHARED_KEY", "key-for-b"); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if _, err := commitProviderSetupSession(s, path); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | saved, err := config.LoadForEditReadOnlyStrict(path) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | a, _ := saved.Provider("a") |
| 133 | b, _ := saved.Provider("b") |
| 134 | if a.APIKeyEnv == b.APIKeyEnv { |
| 135 | t.Fatal("separate edits for A and B were coalesced into B's credential slot") |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | func TestShellCredentialOnlyEditRejectsConcurrentRotation(t *testing.T) { |
| 140 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 141 | path := config.UserConfigPath() |
| 142 | c := config.Default() |
| 143 | c.Providers = []config.ProviderEntry{{Name: "relay", Kind: "openai", Model: "chat", BaseURL: "https://relay.invalid/v1", APIKeyEnv: "OLD_KEY"}} |
| 144 | if err := c.SaveTo(path); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | c, err := config.LoadForEditReadOnlyStrict(path) |
| 148 | if err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | s := newProviderSetupSessionForPath(c, path) |
| 152 | if err := s.setCredentialForProviders([]string{"relay"}, "OLD_KEY", "stale-draft"); err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | result, err := config.CommitConnectionCredential(config.ConnectionCredentialRequest{RequestID: "other-writer", ConfigPath: path, ProviderNames: []string{"relay"}, Key: "concurrent-key"}) |
| 156 | if err != nil || !result.Persisted { |
| 157 | t.Fatalf("concurrent save: %+v, %v", result, err) |
| 158 | } |
| 159 | if _, err := commitProviderSetupSession(s, path); err == nil { |
| 160 | t.Fatal("stale credential draft overwrote concurrent rotation") |
| 161 | } |
| 162 | current, err := config.LoadForEditReadOnlyStrict(path) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | entry, _ := current.Provider("relay") |
| 167 | if entry.APIKeyEnv != result.Slot { |
| 168 | t.Fatal("concurrent slot reference changed") |
| 169 | } |
| 170 | } |
| 171 |