返回 DeepSeek-Reasonix
transaction_test.go
根目录 / internal / repair / transaction_test.go
1 package repair
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/config"
12 )
13
14 func TestReadLastRepairAcceptsLegacyTransactionWithoutJournalFields(t *testing.T) {
15 home := t.TempDir()
16 t.Setenv("REASONIX_HOME", home)
17 target := filepath.Join(home, "desktop-window.json")
18 previous := target + ".reasonix-rebuild-20260729T000000Z"
19 legacy := map[string]any{
20 "schemaVersion": 1,
21 "id": "repair-legacy",
22 "createdAt": "2026-07-29T00:00:00Z",
23 "changes": []map[string]any{{
24 "scope": "derived:window",
25 "targetPath": target,
26 "previousPath": previous,
27 "previousStateId": strings.Repeat("a", 64),
28 }},
29 }
30 b, err := json.Marshal(legacy)
31 if err != nil {
32 t.Fatal(err)
33 }
34 if err := os.MkdirAll(filepath.Dir(repairTransactionPath()), 0o700); err != nil {
35 t.Fatal(err)
36 }
37 if err := os.WriteFile(repairTransactionPath(), b, 0o600); err != nil {
38 t.Fatal(err)
39 }
40 tx, err := ReadLastRepair()
41 if err != nil || tx.ID != "repair-legacy" || len(tx.Changes) != 1 {
42 t.Fatalf("legacy transaction = %+v, %v", tx, err)
43 }
44 }
45
46 func TestReadLastRepairRejectsPendingOnlyJournalFields(t *testing.T) {
47 home := t.TempDir()
48 t.Setenv("REASONIX_HOME", home)
49 target := filepath.Join(home, "desktop-window.json")
50 tx := newRepairTransaction(time.Now())
51 tx.PreparedLastRepairStateID = strings.Repeat("a", 64)
52 tx.Changes = []RepairChange{{
53 Scope: "derived:window",
54 TargetPath: target,
55 PreviousPath: target + ".reasonix-rebuild-20260729T000000Z",
56 PreviousStateID: strings.Repeat("b", 64),
57 Prepared: true,
58 }}
59 b, err := json.Marshal(tx)
60 if err != nil {
61 t.Fatal(err)
62 }
63 if err := os.MkdirAll(filepath.Dir(repairTransactionPath()), 0o700); err != nil {
64 t.Fatal(err)
65 }
66 if err := os.WriteFile(repairTransactionPath(), b, 0o600); err != nil {
67 t.Fatal(err)
68 }
69 if _, err := ReadLastRepair(); err == nil ||
70 !strings.Contains(err.Error(), "pending-only state") {
71 t.Fatalf("ReadLastRepair error = %v", err)
72 }
73 }
74
75 // TestUndoLastRepairKeepsBackupUntilProgressPersisted pins the crash-window
76 // contract: a change that was fully restored but whose progress record never
77 // reached disk (simulated by an unmarked change whose target already matches
78 // the backup) must remain retryable, and backups are only removed after the
79 // per-change progress is persisted.
80 func TestUndoLastRepairKeepsBackupUntilProgressPersisted(t *testing.T) {
81 home := t.TempDir()
82 t.Setenv("REASONIX_HOME", home)
83 windowPath := filepath.Join(home, "desktop-window.json")
84 quarantine := windowPath + ".reasonix-rebuild-20260714T000000Z"
85 // Simulate a crash after the restore copy but before markUndone: the
86 // target already holds the restored bytes, the backup still exists, and
87 // the change is not marked undone.
88 for path, body := range map[string]string{
89 windowPath: "old-window",
90 quarantine: "old-window",
91 } {
92 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
93 t.Fatal(err)
94 }
95 }
96 tx := newRepairTransaction(time.Now())
97 tx.Changes = []RepairChange{repairChangeForPrevious("derived:window", windowPath, quarantine)}
98 if err := persistRepairTransaction(tx); err != nil {
99 t.Fatal(err)
100 }
101 undone, err := UndoLastRepair()
102 if err != nil {
103 t.Fatalf("retry after simulated crash failed: %v", err)
104 }
105 if !undone.Undone {
106 t.Fatalf("transaction not marked undone: %+v", undone)
107 }
108 if got, _ := os.ReadFile(windowPath); string(got) != "old-window" {
109 t.Fatalf("window state = %q", got)
110 }
111 if _, err := os.Stat(quarantine); !os.IsNotExist(err) {
112 t.Fatalf("backup not cleaned up after completed undo: %v", err)
113 }
114 }
115
116 func TestUndoLastRepairRejectsTransactionReplacedWhileWaitingForLock(t *testing.T) {
117 home := t.TempDir()
118 t.Setenv("REASONIX_HOME", home)
119 windowPath := filepath.Join(home, "desktop-window.json")
120 windowBackup := windowPath + ".reasonix-rebuild-20260729T000000Z"
121 tabsPath := filepath.Join(home, "desktop-tabs.json")
122 tabsBackup := tabsPath + ".reasonix-rebuild-20260729T000001Z"
123 for path, body := range map[string]string{
124 windowPath: "current-window",
125 windowBackup: "previous-window",
126 tabsPath: "current-tabs",
127 tabsBackup: "previous-tabs",
128 } {
129 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
130 t.Fatal(err)
131 }
132 }
133 first := newRepairTransaction(time.Now())
134 first.Changes = []RepairChange{repairChangeForPrevious("derived:window", windowPath, windowBackup)}
135 if err := persistRepairTransaction(first); err != nil {
136 t.Fatal(err)
137 }
138 second := newRepairTransaction(time.Now().Add(time.Second))
139 second.Changes = []RepairChange{repairChangeForPrevious("derived:tabs", tabsPath, tabsBackup)}
140
141 transactionKey := repairMutationTestKey(repairTransactionPath())
142 originalHook := repairMutationBeforeLock
143 var replaceErr error
144 replaced := false
145 repairMutationBeforeLock = func(paths []string) {
146 if replaced || len(paths) != 1 || paths[0] != transactionKey {
147 return
148 }
149 replaced = true
150 replaceErr = persistRepairTransaction(second)
151 }
152 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
153
154 if _, err := UndoLastRepair(); err == nil ||
155 !strings.Contains(err.Error(), "transaction changed while waiting") {
156 t.Fatalf("undo replaced transaction = %v", err)
157 }
158 if replaceErr != nil {
159 t.Fatalf("replace last repair: %v", replaceErr)
160 }
161 if got, err := os.ReadFile(windowPath); err != nil || string(got) != "current-window" {
162 t.Fatalf("first target changed: %q, %v", got, err)
163 }
164 if got, err := os.ReadFile(tabsPath); err != nil || string(got) != "current-tabs" {
165 t.Fatalf("second target changed: %q, %v", got, err)
166 }
167 last, err := ReadLastRepair()
168 if err != nil || last.ID != second.ID {
169 t.Fatalf("last repair = %+v, %v; want replacement", last, err)
170 }
171 }
172
173 func TestUndoLastRepairRejectsLegacyBackupWithoutStateIdentity(t *testing.T) {
174 home := t.TempDir()
175 t.Setenv("REASONIX_HOME", home)
176 windowPath := filepath.Join(home, "desktop-window.json")
177 backup := windowPath + ".reasonix-rebuild-20260729T000000Z"
178 if err := os.WriteFile(windowPath, []byte("current-window"), 0o600); err != nil {
179 t.Fatal(err)
180 }
181 if err := os.WriteFile(backup, []byte("previous-window"), 0o600); err != nil {
182 t.Fatal(err)
183 }
184 tx := newRepairTransaction(time.Now())
185 tx.Changes = []RepairChange{{
186 Scope: "derived:window",
187 TargetPath: windowPath,
188 PreviousPath: backup,
189 }}
190 if err := persistRepairTransaction(tx); err != nil {
191 t.Fatal(err)
192 }
193
194 if _, err := UndoLastRepair(); err == nil ||
195 !strings.Contains(err.Error(), "legacy transaction cannot be undone safely") {
196 t.Fatalf("legacy undo error = %v", err)
197 }
198 if got, err := os.ReadFile(windowPath); err != nil || string(got) != "current-window" {
199 t.Fatalf("legacy target changed: %q, %v", got, err)
200 }
201 if got, err := os.ReadFile(backup); err != nil || string(got) != "previous-window" {
202 t.Fatalf("legacy backup changed: %q, %v", got, err)
203 }
204 }
205
206 func TestUndoLastRepairRejectsBytesDifferentFromVerifiedBackup(t *testing.T) {
207 home := t.TempDir()
208 t.Setenv("REASONIX_HOME", home)
209 windowPath := filepath.Join(home, "desktop-window.json")
210 quarantine := windowPath + ".reasonix-rebuild-20260714T000000Z"
211 if err := os.WriteFile(windowPath, []byte("current-window"), 0o600); err != nil {
212 t.Fatal(err)
213 }
214 if err := os.WriteFile(quarantine, []byte("confirmed-window"), 0o600); err != nil {
215 t.Fatal(err)
216 }
217 tx := newRepairTransaction(time.Now())
218 tx.Changes = []RepairChange{repairChangeForPrevious("derived:window", windowPath, quarantine)}
219 if err := persistRepairTransaction(tx); err != nil {
220 t.Fatal(err)
221 }
222
223 originalRead := readRepairPreviousFile
224 readRepairPreviousFile = func(path string) ([]byte, error) {
225 if path == quarantine {
226 return []byte("transient-unconfirmed-window"), nil
227 }
228 return os.ReadFile(path)
229 }
230 t.Cleanup(func() { readRepairPreviousFile = originalRead })
231
232 if _, err := UndoLastRepair(); err == nil ||
233 !strings.Contains(err.Error(), "previous file bytes changed") {
234 t.Fatalf("undo error = %v, want exact-read state rejection", err)
235 }
236 if got, err := os.ReadFile(windowPath); err != nil || string(got) != "current-window" {
237 t.Fatalf("current target was not compensated: %q, %v", got, err)
238 }
239 if got, err := os.ReadFile(quarantine); err != nil || string(got) != "confirmed-window" {
240 t.Fatalf("confirmed backup changed: %q, %v", got, err)
241 }
242 last, err := ReadLastRepair()
243 if err != nil || last.Undone || last.Changes[0].Undone {
244 t.Fatalf("failed undo advanced transaction: %+v, %v", last, err)
245 }
246 }
247
248 func TestUndoLastRepairRejectsLinkDifferentFromVerifiedBackup(t *testing.T) {
249 home := t.TempDir()
250 t.Setenv("REASONIX_HOME", home)
251 configPath := config.UserConfigPath()
252 linkTarget := filepath.Join(t.TempDir(), "confirmed-config.toml")
253 if err := os.WriteFile(linkTarget, []byte("confirmed"), 0o600); err != nil {
254 t.Fatal(err)
255 }
256 quarantine := configPath + ".reasonix-quarantine-20260714T000000Z"
257 if err := os.Symlink(linkTarget, quarantine); err != nil {
258 t.Fatal(err)
259 }
260 if err := os.WriteFile(configPath, []byte("current"), 0o600); err != nil {
261 t.Fatal(err)
262 }
263 tx := newRepairTransaction(time.Now())
264 tx.Changes = []RepairChange{repairChangeForPrevious("global", configPath, quarantine)}
265 if err := persistRepairTransaction(tx); err != nil {
266 t.Fatal(err)
267 }
268
269 originalReadlink := readRepairPreviousLink
270 readRepairPreviousLink = func(path string) (string, error) {
271 if path == quarantine {
272 return filepath.Join(t.TempDir(), "transient-config.toml"), nil
273 }
274 return os.Readlink(path)
275 }
276 t.Cleanup(func() { readRepairPreviousLink = originalReadlink })
277
278 if _, err := UndoLastRepair(); err == nil ||
279 !strings.Contains(err.Error(), "previous link read changed") {
280 t.Fatalf("undo error = %v, want exact-link state rejection", err)
281 }
282 if got, err := os.ReadFile(configPath); err != nil || string(got) != "current" {
283 t.Fatalf("current config was not compensated: %q, %v", got, err)
284 }
285 if got, err := os.Readlink(quarantine); err != nil || got != linkTarget {
286 t.Fatalf("confirmed symlink changed: %q, %v", got, err)
287 }
288 }
289
290 func TestReadLastRepairRejectsRestoreBackupParentSymlinkEscape(t *testing.T) {
291 home := t.TempDir()
292 t.Setenv("REASONIX_HOME", home)
293 restoreRoot := filepath.Join(home, "repair", "restore-backups")
294 if err := os.MkdirAll(filepath.Dir(restoreRoot), 0o700); err != nil {
295 t.Fatal(err)
296 }
297 outside := t.TempDir()
298 if err := os.Symlink(outside, restoreRoot); err != nil {
299 t.Fatal(err)
300 }
301 previous := filepath.Join(restoreRoot, "forged.toml")
302 if err := os.WriteFile(filepath.Join(outside, "forged.toml"), []byte("outside"), 0o600); err != nil {
303 t.Fatal(err)
304 }
305 tx := newRepairTransaction(time.Now())
306 tx.Changes = []RepairChange{{
307 Scope: "global",
308 TargetPath: config.UserConfigPath(),
309 PreviousPath: previous,
310 }}
311 if err := persistRepairTransaction(tx); err != nil {
312 t.Fatal(err)
313 }
314
315 if _, err := ReadLastRepair(); err == nil ||
316 !strings.Contains(err.Error(), "previous path is invalid") {
317 t.Fatalf("ReadLastRepair error = %v, want parent symlink escape rejection", err)
318 }
319 if got, err := os.ReadFile(filepath.Join(outside, "forged.toml")); err != nil || string(got) != "outside" {
320 t.Fatalf("outside node changed: %q, %v", got, err)
321 }
322 }
323
324 // TestUndoLastRepairRestoresSymlink pins that undoing a repair of a
325 // symlink-managed config (dotfiles setups) restores the symlink itself: the
326 // quarantine rename moved the link, so undo must recreate a link, not
327 // materialize the followed content as a regular file.
328 func TestUndoLastRepairRestoresSymlink(t *testing.T) {
329 home := t.TempDir()
330 t.Setenv("REASONIX_HOME", home)
331 configPath := config.UserConfigPath()
332 linkTarget := filepath.Join(t.TempDir(), "dotfiles-config.toml")
333 if err := os.WriteFile(linkTarget, []byte("linked"), 0o600); err != nil {
334 t.Fatal(err)
335 }
336 quarantine := configPath + ".reasonix-quarantine-20260714T000000Z"
337 // The repair's os.Rename moves the link itself into quarantine.
338 if err := os.Symlink(linkTarget, quarantine); err != nil {
339 t.Fatal(err)
340 }
341 // The repair then materialized a regular replacement config.
342 if err := os.WriteFile(configPath, []byte("repaired"), 0o600); err != nil {
343 t.Fatal(err)
344 }
345 tx := newRepairTransaction(time.Now())
346 tx.Changes = []RepairChange{repairChangeForPrevious("global", configPath, quarantine)}
347 if err := persistRepairTransaction(tx); err != nil {
348 t.Fatal(err)
349 }
350 if _, err := UndoLastRepair(); err != nil {
351 t.Fatal(err)
352 }
353 info, err := os.Lstat(configPath)
354 if err != nil {
355 t.Fatal(err)
356 }
357 if info.Mode()&os.ModeSymlink == 0 {
358 t.Fatalf("undo materialized a regular file, want symlink (mode %v)", info.Mode())
359 }
360 if got, err := os.Readlink(configPath); err != nil || got != linkTarget {
361 t.Fatalf("restored link target = %q (%v), want %q", got, err, linkTarget)
362 }
363 if got, _ := os.ReadFile(configPath); string(got) != "linked" {
364 t.Fatalf("config content through link = %q", got)
365 }
366 if _, err := os.Lstat(quarantine); !os.IsNotExist(err) {
367 t.Fatalf("quarantined link not cleaned up: %v", err)
368 }
369 }
370
371 // A dangling quarantined symlink must still be restorable: preflight and
372 // restore must not follow the link when judging its presence.
373 func TestUndoLastRepairRestoresDanglingSymlink(t *testing.T) {
374 home := t.TempDir()
375 t.Setenv("REASONIX_HOME", home)
376 configPath := config.UserConfigPath()
377 linkTarget := filepath.Join(t.TempDir(), "missing-config.toml")
378 quarantine := configPath + ".reasonix-quarantine-20260714T000000Z"
379 if err := os.Symlink(linkTarget, quarantine); err != nil {
380 t.Fatal(err)
381 }
382 tx := newRepairTransaction(time.Now())
383 tx.Changes = []RepairChange{repairChangeForPrevious("global", configPath, quarantine)}
384 if err := persistRepairTransaction(tx); err != nil {
385 t.Fatal(err)
386 }
387 if _, err := UndoLastRepair(); err != nil {
388 t.Fatal(err)
389 }
390 if got, err := os.Readlink(configPath); err != nil || got != linkTarget {
391 t.Fatalf("restored dangling link target = %q (%v), want %q", got, err, linkTarget)
392 }
393 }
394
395 // TestUndoLastRepairKeepsDistinctRedoCopiesForSharedTarget pins that one undo
396 // touching the same target twice (quarantine + snapshot restore) retains a
397 // separate redo copy per change instead of silently overwriting the first.
398 func TestUndoLastRepairKeepsDistinctRedoCopiesForSharedTarget(t *testing.T) {
399 home := t.TempDir()
400 t.Setenv("REASONIX_HOME", home)
401 configPath := config.UserConfigPath()
402 quarantine := configPath + ".reasonix-quarantine-20260714T000000Z"
403 restoreBackup := filepath.Join(home, "repair", "restore-backups", "repair-2.toml")
404 if err := os.MkdirAll(filepath.Dir(restoreBackup), 0o700); err != nil {
405 t.Fatal(err)
406 }
407 for path, body := range map[string]string{
408 configPath: "current",
409 quarantine: "original",
410 restoreBackup: "pre-restore",
411 } {
412 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
413 t.Fatal(err)
414 }
415 }
416 tx := newRepairTransaction(time.Now())
417 tx.Changes = []RepairChange{
418 repairChangeForPrevious("global", configPath, quarantine),
419 repairChangeForPrevious("global", configPath, restoreBackup),
420 }
421 if err := persistRepairTransaction(tx); err != nil {
422 t.Fatal(err)
423 }
424 if _, err := UndoLastRepair(); err != nil {
425 t.Fatal(err)
426 }
427 if got, _ := os.ReadFile(configPath); string(got) != "original" {
428 t.Fatalf("config after undo = %q", got)
429 }
430 redos, err := filepath.Glob(configPath + ".reasonix-redo-*")
431 if err != nil {
432 t.Fatal(err)
433 }
434 if len(redos) != 2 {
435 t.Fatalf("redo copies = %v, want one per change", redos)
436 }
437 }
438
439 // TestUndoLastRepairResumesAfterPartialFailure pins the recoverable-undo
440 // contract: a multi-change undo that fails partway persists per-change
441 // progress, and a retry finishes the remaining changes instead of failing the
442 // preflight on the consumed backups of the changes already restored.
443 func TestUndoLastRepairResumesAfterPartialFailure(t *testing.T) {
444 home := t.TempDir()
445 t.Setenv("REASONIX_HOME", home)
446 configPath := config.UserConfigPath()
447 windowPath := filepath.Join(home, "desktop-window.json")
448 windowQuarantine := windowPath + ".reasonix-rebuild-20260714T000000Z"
449 restoreBackup := filepath.Join(home, "repair", "restore-backups", "repair-1.toml")
450 if err := os.MkdirAll(filepath.Dir(restoreBackup), 0o700); err != nil {
451 t.Fatal(err)
452 }
453 for path, body := range map[string]string{
454 configPath: "current-config",
455 windowPath: "current-window",
456 windowQuarantine: "old-window",
457 restoreBackup: "old-config",
458 } {
459 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
460 t.Fatal(err)
461 }
462 }
463 tx := newRepairTransaction(time.Now())
464 tx.Changes = []RepairChange{
465 repairChangeForPrevious("global", configPath, restoreBackup),
466 repairChangeForPrevious("derived:window", windowPath, windowQuarantine),
467 }
468 if err := persistRepairTransaction(tx); err != nil {
469 t.Fatal(err)
470 }
471
472 originalRead := readRepairPreviousFile
473 failConfigRead := true
474 readRepairPreviousFile = func(path string) ([]byte, error) {
475 if path == restoreBackup && failConfigRead {
476 failConfigRead = false
477 return nil, os.ErrPermission
478 }
479 return os.ReadFile(path)
480 }
481 t.Cleanup(func() { readRepairPreviousFile = originalRead })
482
483 if _, err := UndoLastRepair(); err == nil {
484 t.Fatal("undo succeeded despite unreadable restore backup")
485 }
486 partial, err := ReadLastRepair()
487 if err != nil {
488 t.Fatal(err)
489 }
490 if partial.Undone || !partial.Changes[1].Undone || partial.Changes[0].Undone {
491 t.Fatalf("partial undo progress not persisted: %+v", partial)
492 }
493 if got, _ := os.ReadFile(windowPath); string(got) != "old-window" {
494 t.Fatalf("derived state not restored before failure: %q", got)
495 }
496
497 // Retry with the transient read failure gone: the already-undone change
498 // must be skipped even though its quarantine file was consumed.
499 undone, err := UndoLastRepair()
500 if err != nil {
501 t.Fatal(err)
502 }
503 if !undone.Undone {
504 t.Fatalf("transaction not marked undone: %+v", undone)
505 }
506 if got, _ := os.ReadFile(configPath); string(got) != "old-config" {
507 t.Fatalf("config not restored on retry: %q", got)
508 }
509 if got, _ := os.ReadFile(windowPath); string(got) != "old-window" {
510 t.Fatalf("derived state clobbered by retry: %q", got)
511 }
512 }
513
513 lines GO