返回 DeepSeek-Reasonix
credentials_keyring_timeout_test.go
根目录 / internal / config / credentials_keyring_timeout_test.go
1 package config
2
3 import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11 )
12
13 func TestLookupLegacyKeyringBatchInProcessFourState(t *testing.T) {
14 oldLookup := legacyKeyringProbeLookup
15 t.Cleanup(func() { legacyKeyringProbeLookup = oldLookup })
16
17 legacyKeyringProbeLookup = func(_ context.Context, key string) legacyKeyringOutcome {
18 switch key {
19 case "FOUND":
20 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "secret"}
21 case "EMPTY":
22 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: ""}
23 case "ERR":
24 return legacyKeyringOutcome{Status: legacyKeyringError}
25 default:
26 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
27 }
28 }
29 got := lookupLegacyKeyringBatch([]string{"FOUND", "EMPTY", "MISSING", "ERR"}, time.Second)
30 if got["FOUND"].Status != legacyKeyringFound || got["FOUND"].Value != "" {
31 t.Fatalf("FOUND = %+v, want found with scrubbed value", got["FOUND"])
32 }
33 if !credentialCurrentStoreHasKey("FOUND") {
34 t.Fatal("FOUND secret should have been stored via store-if-absent")
35 }
36 if got["EMPTY"].Status != legacyKeyringAbsent {
37 t.Fatalf("EMPTY = %+v, want absent", got["EMPTY"])
38 }
39 if got["MISSING"].Status != legacyKeyringAbsent {
40 t.Fatalf("MISSING = %+v, want absent", got["MISSING"])
41 }
42 if got["ERR"].Status != legacyKeyringError {
43 t.Fatalf("ERR = %+v, want error", got["ERR"])
44 }
45 }
46
47 func TestLegacyKeyringErrorDoesNotWriteMarker(t *testing.T) {
48 home := t.TempDir()
49 t.Setenv("REASONIX_HOME", home)
50 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
51
52 oldLookup := legacyKeyringProbeLookup
53 t.Cleanup(func() { legacyKeyringProbeLookup = oldLookup })
54
55 legacyKeyringProbeLookup = func(context.Context, string) legacyKeyringOutcome {
56 return legacyKeyringOutcome{Status: legacyKeyringError}
57 }
58 outcomes := lookupLegacyKeyringBatch([]string{"DEEPSEEK_API_KEY"}, time.Second)
59 if outcomes["DEEPSEEK_API_KEY"].Status != legacyKeyringError {
60 t.Fatalf("status = %+v", outcomes["DEEPSEEK_API_KEY"])
61 }
62 if outcomes["DEEPSEEK_API_KEY"].Status == legacyKeyringAbsent {
63 _ = markLegacyKeyringMigrationDone("DEEPSEEK_API_KEY")
64 }
65 if legacyKeyringMigrationDone("DEEPSEEK_API_KEY") {
66 t.Fatal("error outcome must not write a migration marker")
67 }
68 }
69
70 func TestLookupLegacyKeyringBatchSharedContextTimeout(t *testing.T) {
71 oldLookup := legacyKeyringProbeLookup
72 oldTimeout := legacyKeyringLookupTimeout
73 legacyKeyringLookupTimeout = 40 * time.Millisecond
74 t.Cleanup(func() {
75 legacyKeyringProbeLookup = oldLookup
76 legacyKeyringLookupTimeout = oldTimeout
77 })
78
79 legacyKeyringProbeLookup = func(ctx context.Context, key string) legacyKeyringOutcome {
80 if key == "FAST" {
81 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
82 }
83 // Block until the shared budget cancels; must not leave a hung goroutine
84 // after the batch returns (probe returns when ctx is done).
85 <-ctx.Done()
86 return legacyKeyringOutcome{Status: legacyKeyringTimeout}
87 }
88
89 start := time.Now()
90 got := lookupLegacyKeyringBatch([]string{"FAST", "SLOW"}, legacyKeyringLookupTimeout)
91 elapsed := time.Since(start)
92 if got["FAST"].Status != legacyKeyringAbsent {
93 t.Fatalf("FAST = %+v", got["FAST"])
94 }
95 if got["SLOW"].Status != legacyKeyringTimeout {
96 t.Fatalf("SLOW = %+v, want timeout", got["SLOW"])
97 }
98 if elapsed > 250*time.Millisecond {
99 t.Fatalf("elapsed %v, shared budget did not bound the scan", elapsed)
100 }
101 }
102
103 // TestLookupLegacyKeyringBatchDoesNotClobberUserWrite forces the production
104 // probe→store window: the batch has already decided the key needs import
105 // (probe returns found), then the user writes a new value before store-if-absent.
106 func TestLookupLegacyKeyringBatchDoesNotClobberUserWrite(t *testing.T) {
107 home := t.TempDir()
108 t.Setenv("REASONIX_HOME", home)
109 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
110
111 oldLookup := legacyKeyringProbeLookup
112 t.Cleanup(func() { legacyKeyringProbeLookup = oldLookup })
113
114 probeReached := make(chan struct{})
115 releaseProbe := make(chan struct{})
116 var once sync.Once
117 legacyKeyringProbeLookup = func(ctx context.Context, key string) legacyKeyringOutcome {
118 if key != "DEEPSEEK_API_KEY" {
119 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
120 }
121 once.Do(func() { close(probeReached) })
122 select {
123 case <-releaseProbe:
124 case <-ctx.Done():
125 return legacyKeyringOutcome{Status: legacyKeyringTimeout}
126 }
127 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "sk-old-keyring"}
128 }
129
130 done := make(chan map[string]legacyKeyringOutcome, 1)
131 go func() {
132 done <- lookupLegacyKeyringBatch([]string{"DEEPSEEK_API_KEY"}, time.Second)
133 }()
134
135 select {
136 case <-probeReached:
137 case <-time.After(2 * time.Second):
138 t.Fatal("probe did not reach the interleaving checkpoint")
139 }
140 if _, err := SetCredential("DEEPSEEK_API_KEY", "sk-user-new"); err != nil {
141 t.Fatal(err)
142 }
143 close(releaseProbe)
144
145 got := <-done
146 if got["DEEPSEEK_API_KEY"].Status != legacyKeyringFound {
147 t.Fatalf("batch status = %+v, want found (store skipped)", got["DEEPSEEK_API_KEY"])
148 }
149 val, ok := envFileValue(UserCredentialsPath(), "DEEPSEEK_API_KEY")
150 if !ok || val != "sk-user-new" {
151 t.Fatalf("credential = (%q, %v), want sk-user-new (keyring must not clobber)", val, ok)
152 }
153 }
154
155 // TestLookupLegacyKeyringBatchDoesNotReviveTombstone forces the same production
156 // probe→store window against a cleared tombstone written after the probe starts.
157 func TestLookupLegacyKeyringBatchDoesNotReviveTombstone(t *testing.T) {
158 home := t.TempDir()
159 t.Setenv("REASONIX_HOME", home)
160 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
161
162 // Start empty: no value and no tombstone so the batch will probe.
163 oldLookup := legacyKeyringProbeLookup
164 t.Cleanup(func() { legacyKeyringProbeLookup = oldLookup })
165
166 probeReached := make(chan struct{})
167 releaseProbe := make(chan struct{})
168 var once sync.Once
169 legacyKeyringProbeLookup = func(ctx context.Context, key string) legacyKeyringOutcome {
170 if key != "DEEPSEEK_API_KEY" {
171 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
172 }
173 once.Do(func() { close(probeReached) })
174 select {
175 case <-releaseProbe:
176 case <-ctx.Done():
177 return legacyKeyringOutcome{Status: legacyKeyringTimeout}
178 }
179 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: "sk-old-keyring"}
180 }
181
182 done := make(chan map[string]legacyKeyringOutcome, 1)
183 go func() {
184 done <- lookupLegacyKeyringBatch([]string{"DEEPSEEK_API_KEY"}, time.Second)
185 }()
186
187 select {
188 case <-probeReached:
189 case <-time.After(2 * time.Second):
190 t.Fatal("probe did not reach the interleaving checkpoint")
191 }
192 // Write then clear while probe is blocked: tombstone must win.
193 if _, err := SetCredential("DEEPSEEK_API_KEY", "sk-temp"); err != nil {
194 t.Fatal(err)
195 }
196 if err := RemoveCredential("DEEPSEEK_API_KEY"); err != nil {
197 t.Fatal(err)
198 }
199 if !credentialCurrentStoreClearedKey("DEEPSEEK_API_KEY") {
200 t.Fatal("expected cleared tombstone before releasing probe")
201 }
202 close(releaseProbe)
203
204 got := <-done
205 if got["DEEPSEEK_API_KEY"].Status != legacyKeyringFound {
206 // store-if-absent skipped → still reported found (already handled)
207 t.Fatalf("batch status = %+v", got["DEEPSEEK_API_KEY"])
208 }
209 if credentialCurrentStoreHasKey("DEEPSEEK_API_KEY") {
210 t.Fatal("keyring import revived a tombstoned credential")
211 }
212 if !credentialCurrentStoreClearedKey("DEEPSEEK_API_KEY") {
213 t.Fatal("tombstone missing after batch")
214 }
215 }
216
217 func TestLegacyKeyringMarkerUsesRawURLBase64(t *testing.T) {
218 home := t.TempDir()
219 t.Setenv("REASONIX_HOME", home)
220 key := "A/B+C="
221 path := legacyKeyringMigrationMarkerPath(key)
222 wantName := base64.RawURLEncoding.EncodeToString([]byte(key))
223 if !strings.HasSuffix(path, wantName) {
224 t.Fatalf("marker path = %q, want suffix %q", path, wantName)
225 }
226 other := legacyKeyringMigrationMarkerPath("A_B_C_")
227 if path == other {
228 t.Fatalf("marker collision between %q and A_B_C_", key)
229 }
230 }
231
232 func TestLegacyKeyringGetBoundedReturnsValue(t *testing.T) {
233 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
234 defer cancel()
235
236 value, err, timedOut := legacyKeyringGetBounded(ctx, func() (string, error) {
237 return "sk-secret", nil
238 })
239 if timedOut || err != nil || value != "sk-secret" {
240 t.Fatalf("value=%q err=%v timedOut=%v, want found value", value, err, timedOut)
241 }
242 }
243
244 func TestLegacyKeyringGetBoundedReturnsError(t *testing.T) {
245 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
246 defer cancel()
247
248 probeErr := errors.New("platform keychain unavailable")
249 value, err, timedOut := legacyKeyringGetBounded(ctx, func() (string, error) {
250 return "", probeErr
251 })
252 if timedOut || !errors.Is(err, probeErr) || value != "" {
253 t.Fatalf("value=%q err=%v timedOut=%v, want wrapped error", value, err, timedOut)
254 }
255 }
256
257 // TestLegacyKeyringGetBoundedTimesOutHangingGet: the whole point of #8129 —
258 // an uncancellable platform Get (locked macOS keychain, hung Windows
259 // Credential Manager) must not hang migration forever, even though the OS
260 // call itself cannot be interrupted.
261 func TestLegacyKeyringGetBoundedTimesOutHangingGet(t *testing.T) {
262 ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
263 defer cancel()
264
265 done := make(chan bool, 1)
266 go func() {
267 value, err, timedOut := legacyKeyringGetBounded(ctx, func() (string, error) {
268 <-ctx.Done() // platform Get never returns; it only notices when the OS does
269 return "", errors.New("eventually wedged")
270 })
271 done <- timedOut && err == nil && value == ""
272 }()
273 select {
274 case ok := <-done:
275 if !ok {
276 t.Fatal("hanging get did not report timeout")
277 }
278 case <-time.After(250 * time.Millisecond):
279 t.Fatal("hanging get exceeded the shared budget")
280 }
281 }
282
283 // TestLegacyKeyringGetBoundedNeverDemotesFoundValue pins the helper's
284 // responsibility boundary: when the result channel wins the select, the value
285 // is returned as-is. Demotion of a found value to timeout must never happen
286 // here (only the per-platform probe may classify err as timeout), even if ctx
287 // expires in the same instant the result arrives.
288 func TestLegacyKeyringGetBoundedNeverDemotesFoundValue(t *testing.T) {
289 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
290 defer cancel()
291
292 value, err, timedOut := legacyKeyringGetBounded(ctx, func() (string, error) {
293 return "late-but-real", nil
294 })
295 if timedOut || err != nil || value != "late-but-real" {
296 t.Fatalf("value=%q err=%v timedOut=%v, want found value preserved", value, err, timedOut)
297 }
298 }
299
299 lines GO