| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "runtime" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/fileutil" |
| 11 | ) |
| 12 | |
| 13 | func TestAuditRepairVerificationPreservesConcurrentSave(t *testing.T) { |
| 14 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 15 | path := UserCredentialsPath() |
| 16 | if err := os.WriteFile(path, []byte("OLD=one\n"), 0600); err != nil { |
| 17 | t.Fatal(err) |
| 18 | } |
| 19 | oldHook := fileutil.CrashPoint |
| 20 | t.Cleanup(func() { fileutil.CrashPoint = oldHook }) |
| 21 | // Publish before verification and assert verification performs no writes. |
| 22 | if err := os.WriteFile(path, []byte("OLD=one\nNEW=two\n"), 0600); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | writes := 0 |
| 26 | fileutil.CrashPoint = func(_, target string) { |
| 27 | if target == path { |
| 28 | writes++ |
| 29 | } |
| 30 | } |
| 31 | if err := probeCredentialTarget(path); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if writes != 0 { |
| 35 | t.Fatal("verification rewrote the credential file") |
| 36 | } |
| 37 | raw, err := os.ReadFile(path) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | if !strings.Contains(string(raw), "NEW=two") { |
| 42 | t.Fatal("repair verification erased a concurrent credential save") |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func auditCommitFixture(t *testing.T) (*Config, string) { |
| 47 | t.Helper() |
| 48 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 49 | path := UserConfigPath() |
| 50 | c := Default() |
| 51 | c.Providers = []ProviderEntry{{Name: "one", Kind: "openai", BaseURL: "https://one.invalid/v1", Model: "chat", APIKeyEnv: "OLD_KEY"}} |
| 52 | if err := c.SaveTo(path); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | unlock := LockUserConfigEdits() |
| 56 | t.Cleanup(unlock) |
| 57 | unlockCredentials, err := LockUserCredentialEdits() |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | t.Cleanup(unlockCredentials) |
| 62 | return c, path |
| 63 | } |
| 64 | |
| 65 | func TestAuditRecoverCommittedPreferenceReceipt(t *testing.T) { |
| 66 | c, path := auditCommitFixture(t) |
| 67 | if err := c.BeginModelCredentialCommitLocked(path, "preference", "digest"); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | c.Language = "zh" |
| 71 | if err := c.SaveTo(path); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | if err := c.MarkModelCredentialConfigCommittedLocked(path); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if err := RecoverModelCredentialCommitsLocked(path); err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | if _, ok := LookupModelSettingsReceipt("preference"); !ok { |
| 81 | t.Fatal("committed preference without credential slots has no recovered receipt") |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestAuditRecoverDoesNotInventReceiptAfterExternalEdit(t *testing.T) { |
| 86 | c, path := auditCommitFixture(t) |
| 87 | if err := c.BeginModelCredentialCommitLocked(path, "ambiguous", "digest"); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | slot, err := c.StageModelCredentialLocked("new-secret") |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | c.Providers[0].APIKeyEnv = slot |
| 95 | if err := c.SaveTo(path); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | // Crash after publishing config, before the committed journal. An external |
| 99 | // editor then changes the model but keeps the slot reference. |
| 100 | external, err := LoadForEditReadOnlyStrict(path) |
| 101 | if err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | external.Providers[0].Model = "external-model" |
| 105 | if err := external.SaveTo(path); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | if err := RecoverModelCredentialCommitsLocked(path); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | if _, ok := LookupModelSettingsReceipt("ambiguous"); ok { |
| 112 | t.Fatal("recovery certified an externally modified config as this request's committed result") |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestAuditCleanupRetainsEvidenceOnFailedRemoval(t *testing.T) { |
| 117 | c, path := auditCommitFixture(t) |
| 118 | if err := c.BeginModelCredentialCommitLocked(path, "cleanup", "digest"); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if _, err := c.StageModelCredentialLocked("new-secret"); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | journal := c.modelCredentialCommit.journalPath |
| 125 | if err := os.Remove(UserCredentialsPath()); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if err := os.Mkdir(UserCredentialsPath(), 0700); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | c.CleanupStagedModelCredentialsLocked(path) |
| 132 | if _, err := os.Stat(journal); err != nil { |
| 133 | t.Fatalf("failed cleanup discarded recovery evidence: %v", err) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestAuditRepairRefusesLinkedHome(t *testing.T) { |
| 138 | outside := t.TempDir() |
| 139 | path := filepath.Join(outside, ".env") |
| 140 | if err := os.WriteFile(path, []byte("KEY=value\n"), 0400); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | linked := filepath.Join(t.TempDir(), "home") |
| 144 | if err := os.Symlink(outside, linked); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | t.Setenv("REASONIX_HOME", linked) |
| 148 | report, err := DiagnoseCredentials(CredentialDiagnosticOptions{Repair: true}) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | if runtime.GOOS == "windows" { |
| 153 | if diagnosticStatus(report, "repair") != "failed" { |
| 154 | t.Fatalf("linked home repair status = %s, want failed", diagnosticStatus(report, "repair")) |
| 155 | } |
| 156 | return |
| 157 | } |
| 158 | info, err := os.Stat(path) |
| 159 | if err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | if info.Mode().Perm() != 0400 { |
| 163 | t.Fatalf("repair followed linked home and changed outside permissions; status=%s", diagnosticStatus(report, "repair")) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestRepairPreservesCredentialContentsAndFileIdentity(t *testing.T) { |
| 168 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 169 | path := UserCredentialsPath() |
| 170 | body := []byte("# unrelated content\nEXISTING_KEY=unchanged\n") |
| 171 | if err := os.WriteFile(path, body, 0400); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | before, err := os.Stat(path) |
| 175 | if err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | report, err := DiagnoseCredentials(CredentialDiagnosticOptions{Repair: true}) |
| 179 | if err != nil || (runtime.GOOS != "windows" && diagnosticStatus(report, "repair") != "passed") { |
| 180 | t.Fatalf("repair: %+v, %v", report, err) |
| 181 | } |
| 182 | after, err := os.Stat(path) |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | raw, err := os.ReadFile(path) |
| 187 | if err != nil || string(raw) != string(body) || !os.SameFile(before, after) { |
| 188 | t.Fatal("repair replaced or changed credential content") |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | func TestReceiptQueryRecoversPublishedPreferenceBeforeMark(t *testing.T) { |
| 193 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 194 | path := UserConfigPath() |
| 195 | c := Default() |
| 196 | if err := c.SaveTo(path); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | func() { |
| 200 | unlock := LockUserConfigEdits() |
| 201 | defer unlock() |
| 202 | unlockCredentials, err := LockUserCredentialEdits() |
| 203 | if err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | defer unlockCredentials() |
| 207 | if err := c.BeginModelCredentialCommitLocked(path, "published-preference", "digest"); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | c.Language = "zh" |
| 211 | if err := c.SaveTo(path); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | // Stop before Mark/Complete, like a process killed just after rename. |
| 215 | }() |
| 216 | if _, ok := RecoverModelSettingsReceipt("published-preference"); !ok { |
| 217 | t.Fatal("query did not recover proven preference publication") |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestProviderEditPathRespectsProjectOverrideOfBuiltins(t *testing.T) { |
| 222 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 223 | root := t.TempDir() |
| 224 | path := filepath.Join(root, "reasonix.toml") |
| 225 | if err := os.WriteFile(path, []byte("[[providers]]\nname='deepseek'\nkind='openai'\nmodel='project-chat'\nbase_url='https://project.invalid/v1'\n"), 0600); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | c, err := LoadForRootReadOnly(root) |
| 229 | if err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | actual, err := c.ProviderEditPath(root, "deepseek") |
| 233 | if err != nil || actual != path { |
| 234 | t.Fatalf("target = %q, %v", actual, err) |
| 235 | } |
| 236 | } |
| 237 |