返回 DeepSeek-Reasonix
update_legacy_test.go
根目录 / internal / repair / update_legacy_test.go
1 package repair
2
3 import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "strings"
8 "testing"
9 "time"
10
11 "reasonix/internal/installlayout"
12 )
13
14 type supersededUpdateFixture struct {
15 home string
16 root string
17 target string
18 active string
19 backup string
20 pendingBody []byte
21 transaction *UpdateTransaction
22 running string
23 }
24
25 type supersededAppUpdateFixture struct {
26 home string
27 target string
28 backup string
29 marker string
30 pendingBody []byte
31 transaction *UpdateTransaction
32 }
33
34 func prepareSupersededAppUpdateFixture(t *testing.T, withBackup, withBackupIdentity bool) supersededAppUpdateFixture {
35 t.Helper()
36 home := t.TempDir()
37 t.Setenv("REASONIX_HOME", home)
38 root := t.TempDir()
39 if resolved, err := filepath.EvalSymlinks(root); err == nil {
40 root = resolved
41 }
42 target := filepath.Join(root, "Reasonix.app")
43 marker := filepath.Join(target, "Contents", "Resources", "release.txt")
44 executable := filepath.Join(target, "Contents", "MacOS", "reasonix-desktop")
45 for _, path := range []string{marker, executable} {
46 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
47 t.Fatal(err)
48 }
49 if err := os.WriteFile(path, []byte("healthy current app"), 0o700); err != nil {
50 t.Fatal(err)
51 }
52 }
53 backup := target + ".reasonix-update-backup"
54 if withBackup {
55 backupMarker := filepath.Join(backup, "Contents", "Resources", "release.txt")
56 if err := os.MkdirAll(filepath.Dir(backupMarker), 0o755); err != nil {
57 t.Fatal(err)
58 }
59 if err := os.WriteFile(backupMarker, []byte("preserved previous app"), 0o600); err != nil {
60 t.Fatal(err)
61 }
62 }
63 originalExecutable := repairExecutable
64 repairExecutable = func() (string, error) { return executable, nil }
65 t.Cleanup(func() { repairExecutable = originalExecutable })
66 tx := &UpdateTransaction{
67 SchemaVersion: updateTransactionVersion,
68 FromVersion: "v1.19.5",
69 ToVersion: "v1.19.7",
70 Platform: runtime.GOOS + "/amd64",
71 TargetKind: "app-bundle",
72 TargetPath: target,
73 BackupPath: backup,
74 CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
75 }
76 if withBackupIdentity {
77 var err error
78 tx.BackupTreeID, err = repairPlanTreeContentStateID(backup)
79 if err != nil {
80 t.Fatal(err)
81 }
82 }
83 if err := createPendingUpdate(tx); err != nil {
84 t.Fatal(err)
85 }
86 pendingBody, err := os.ReadFile(PendingUpdatePath())
87 if err != nil {
88 t.Fatal(err)
89 }
90 return supersededAppUpdateFixture{
91 home: home, target: target, backup: backup, marker: marker,
92 pendingBody: pendingBody, transaction: tx,
93 }
94 }
95
96 func TestArchiveSupersededPendingAppBundleUpdatePreservesLegacyBackup(t *testing.T) {
97 fixture := prepareSupersededAppUpdateFixture(t, true, false)
98 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
99 if err != nil || !archived {
100 t.Fatalf("archived=%v err=%v", archived, err)
101 }
102 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
103 t.Fatalf("pending transaction survived: %v", err)
104 }
105 if _, err := os.Lstat(fixture.backup); !os.IsNotExist(err) {
106 t.Fatalf("public rollback path survived: %v", err)
107 }
108 backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*")
109 if err != nil || len(backups) != 1 {
110 t.Fatalf("archived backups=%v err=%v", backups, err)
111 }
112 if body, err := os.ReadFile(filepath.Join(backups[0], "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" {
113 t.Fatalf("archived backup body=%q err=%v", body, err)
114 }
115 entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates"))
116 if err != nil || len(entries) != 1 {
117 t.Fatalf("transaction archives=%v err=%v", entries, err)
118 }
119 if body, err := os.ReadFile(fixture.marker); err != nil || string(body) != "healthy current app" {
120 t.Fatalf("running app changed=%q err=%v", body, err)
121 }
122 }
123
124 func TestArchiveSupersededPendingAppBundleUpdateHealsMissingBackup(t *testing.T) {
125 fixture := prepareSupersededAppUpdateFixture(t, false, false)
126 fixture.transaction.BackupTreeID = strings.Repeat("a", 64)
127 if err := overwritePendingUpdateForTest(fixture.transaction); err != nil {
128 t.Fatal(err)
129 }
130 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
131 if err != nil || !archived {
132 t.Fatalf("archived=%v err=%v", archived, err)
133 }
134 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
135 t.Fatalf("missing-backup transaction survived: %v", err)
136 }
137 }
138
139 // recordHandoff gives tx the metadata PrepareAppBundleUpdateHandoff writes, so
140 // ReadPendingUpdate accepts it.
141 func recordHandoff(t *testing.T, tx *UpdateTransaction, ownerPID int, appTreeID string) {
142 t.Helper()
143 tx.HandoffOwnerPID = ownerPID
144 tx.HandoffStagingPath = filepath.Join(os.TempDir(), "reasonix-mac-update-legacy-test")
145 tx.HandoffAppPath = filepath.Join(tx.HandoffStagingPath, "Reasonix.app")
146 tx.HandoffAppTreeID = appTreeID
147 tx.HandoffStagingTreeID = strings.Repeat("c", 64)
148 if err := overwritePendingUpdateForTest(tx); err != nil {
149 t.Fatal(err)
150 }
151 if _, err := ReadPendingUpdate(); err != nil {
152 t.Fatalf("recorded handoff is not readable: %v", err)
153 }
154 }
155
156 func TestArchiveSupersededPendingAppBundleUpdateLeavesModernRollbackForHealthCommit(t *testing.T) {
157 fixture := prepareSupersededAppUpdateFixture(t, true, true)
158 installed, err := repairPlanTreeContentStateID(fixture.target)
159 if err != nil {
160 t.Fatal(err)
161 }
162 recordHandoff(t, fixture.transaction, 1<<30, installed)
163 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.7")
164 if err != nil || archived {
165 t.Fatalf("modern transaction archived=%v err=%v", archived, err)
166 }
167 if _, err := ReadPendingUpdate(); err != nil {
168 t.Fatalf("modern pending transaction changed: %v", err)
169 }
170 }
171
172 func TestArchiveSupersededPendingAppBundleUpdateLeavesCurrentProcessHandoff(t *testing.T) {
173 fixture := prepareSupersededAppUpdateFixture(t, false, false)
174 recordHandoff(t, fixture.transaction, os.Getpid(), strings.Repeat("b", 64))
175 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
176 if err != nil || archived {
177 t.Fatalf("current handoff archived=%v err=%v", archived, err)
178 }
179 if _, err := ReadPendingUpdate(); err != nil {
180 t.Fatalf("current handoff transaction changed: %v", err)
181 }
182 }
183
184 // In host mode the recorded owner is the Electron parent, not the service
185 // process that runs the shutdown archival (#10732).
186 func TestArchiveSupersededPendingAppBundleUpdateLeavesHostParentHandoff(t *testing.T) {
187 fixture := prepareSupersededAppUpdateFixture(t, false, false)
188 recordHandoff(t, fixture.transaction, os.Getppid(), strings.Repeat("b", 64))
189 eligible, err := validateSupersededPendingAppBundleUpdate(fixture.transaction, "v1.19.5")
190 if err != nil || eligible {
191 t.Fatalf("host-parent handoff eligible=%v err=%v", eligible, err)
192 }
193 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
194 if err != nil || archived {
195 t.Fatalf("host-parent handoff archived=%v err=%v", archived, err)
196 }
197 if _, err := ReadPendingUpdate(); err != nil {
198 t.Fatalf("host-parent handoff transaction changed: %v", err)
199 }
200 }
201
202 func TestArchiveSupersededPendingAppBundleUpdateRetiresUnverifiableBackupAtTarget(t *testing.T) {
203 fixture := prepareSupersededAppUpdateFixture(t, true, true)
204 if err := os.WriteFile(filepath.Join(fixture.backup, ".DS_Store"), []byte("finder"), 0o600); err != nil {
205 t.Fatal(err)
206 }
207 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.7")
208 if err != nil || !archived {
209 t.Fatalf("archived=%v err=%v", archived, err)
210 }
211 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
212 t.Fatalf("pending transaction survived: %v", err)
213 }
214 backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*")
215 if err != nil || len(backups) != 1 {
216 t.Fatalf("archived backups=%v err=%v", backups, err)
217 }
218 if _, err := os.Stat(filepath.Join(backups[0], ".DS_Store")); err != nil {
219 t.Fatalf("archived backup lost its contents: %v", err)
220 }
221 }
222
223 func TestArchiveSupersededPendingAppBundleUpdateRetiresSurvivingBackupPastTarget(t *testing.T) {
224 prepareSupersededAppUpdateFixture(t, true, true)
225 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.20.0")
226 if err != nil || !archived {
227 t.Fatalf("archived=%v err=%v", archived, err)
228 }
229 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
230 t.Fatalf("pending transaction survived: %v", err)
231 }
232 }
233
234 func TestArchiveSupersededPendingAppBundleUpdateRejectsUnrelatedOlderVersion(t *testing.T) {
235 fixture := prepareSupersededAppUpdateFixture(t, true, false)
236 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.4")
237 if err == nil || archived {
238 t.Fatalf("older running version archived=%v err=%v", archived, err)
239 }
240 assertPendingTransactionUnchanged(t, fixture.pendingBody)
241 }
242
243 func TestArchiveSupersededPendingAppBundleUpdateRestoresStateOnTargetDrift(t *testing.T) {
244 fixture := prepareSupersededAppUpdateFixture(t, true, false)
245 originalHook := supersededAppUpdateAfterBackupArchive
246 supersededAppUpdateAfterBackupArchive = func(string) {
247 if err := os.WriteFile(fixture.marker, []byte("changed concurrently"), 0o600); err != nil {
248 t.Fatal(err)
249 }
250 }
251 t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook })
252 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
253 if err == nil || archived {
254 t.Fatalf("drifted target archived=%v err=%v", archived, err)
255 }
256 if _, err := ReadPendingUpdate(); err != nil {
257 t.Fatalf("pending transaction was not restored: %v", err)
258 }
259 if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" {
260 t.Fatalf("rollback backup was not restored=%q err=%v", body, err)
261 }
262 }
263
264 func TestArchiveSupersededPendingAppBundleUpdatePreservesRecreatedBackupPath(t *testing.T) {
265 fixture := prepareSupersededAppUpdateFixture(t, true, false)
266 originalHook := supersededAppUpdateAfterBackupArchive
267 supersededAppUpdateAfterBackupArchive = func(string) {
268 marker := filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")
269 if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil {
270 t.Fatal(err)
271 }
272 if err := os.WriteFile(marker, []byte("concurrently recreated backup"), 0o600); err != nil {
273 t.Fatal(err)
274 }
275 }
276 t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook })
277
278 archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5")
279 if err == nil || archived {
280 t.Fatalf("recreated backup archived=%v err=%v", archived, err)
281 }
282 if _, err := ReadPendingUpdate(); err != nil {
283 t.Fatalf("pending transaction was not restored: %v", err)
284 }
285 if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "concurrently recreated backup" {
286 t.Fatalf("recreated public backup changed=%q err=%v", body, err)
287 }
288 backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*")
289 if err != nil || len(backups) != 1 {
290 t.Fatalf("preserved archived backups=%v err=%v", backups, err)
291 }
292 if body, err := os.ReadFile(filepath.Join(backups[0], "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" {
293 t.Fatalf("archived original backup changed=%q err=%v", body, err)
294 }
295 }
296
297 func prepareSupersededUpdateFixture(t *testing.T, pendingVersion, runningVersion string) supersededUpdateFixture {
298 t.Helper()
299 home := t.TempDir()
300 t.Setenv("REASONIX_HOME", home)
301 root := t.TempDir()
302 originalExecutable := repairExecutable
303 t.Cleanup(func() { repairExecutable = originalExecutable })
304
305 target := filepath.Join(root, installlayout.DesktopBinaryName())
306 if err := os.WriteFile(target, []byte("old desktop"), 0o700); err != nil {
307 t.Fatal(err)
308 }
309 guardName := "reasonix-guard"
310 if runtime.GOOS == "windows" {
311 guardName += ".exe"
312 }
313 guard := filepath.Join(root, guardName)
314 if err := os.WriteFile(guard, []byte("old guard"), 0o700); err != nil {
315 t.Fatal(err)
316 }
317 repairExecutable = func() (string, error) { return guard, nil }
318 tx, err := PrepareFileUpdate("v1.18.0", pendingVersion, target, guard)
319 if err != nil {
320 t.Fatal(err)
321 }
322 pendingBody, err := os.ReadFile(PendingUpdatePath())
323 if err != nil {
324 t.Fatal(err)
325 }
326
327 runningVersion = canonicalSemver(runningVersion)
328 activeDir := filepath.Join(root, filepath.FromSlash(installlayout.VersionDirRelative(runningVersion)))
329 if err := os.MkdirAll(activeDir, 0o755); err != nil {
330 t.Fatal(err)
331 }
332 active := filepath.Join(activeDir, installlayout.DesktopBinaryName())
333 if err := os.WriteFile(active, []byte("healthy versioned desktop"), 0o700); err != nil {
334 t.Fatal(err)
335 }
336 if err := installlayout.WriteCurrent(root, installlayout.CurrentPointer{
337 ActiveVersion: runningVersion,
338 ActiveDir: installlayout.VersionDirRelative(runningVersion),
339 }); err != nil {
340 t.Fatal(err)
341 }
342 repairExecutable = func() (string, error) { return active, nil }
343
344 return supersededUpdateFixture{
345 home: home, root: root, target: target, active: active,
346 backup: tx.BackupPath, pendingBody: pendingBody, transaction: tx,
347 running: runningVersion,
348 }
349 }
350
351 func TestArchiveSupersededPendingFileUpdateHealsGuardDirectoryMismatch(t *testing.T) {
352 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
353 if _, err := ReadPendingUpdate(); err == nil || !strings.Contains(err.Error(), "outside the current Guard installation") {
354 t.Fatalf("ReadPendingUpdate error = %v, want the reported Guard-directory mismatch", err)
355 }
356
357 archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root)
358 if err != nil || !archived {
359 t.Fatalf("archived=%v err=%v", archived, err)
360 }
361 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
362 t.Fatalf("pending transaction survived: %v", err)
363 }
364 entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates"))
365 if err != nil || len(entries) != 1 {
366 t.Fatalf("archive entries=%v err=%v", entries, err)
367 }
368 archivedBody, err := os.ReadFile(filepath.Join(fixture.home, "repair", "legacy-updates", entries[0].Name()))
369 if err != nil || string(archivedBody) != string(fixture.pendingBody) {
370 t.Fatalf("archived transaction changed: err=%v\n got=%q\nwant=%q", err, archivedBody, fixture.pendingBody)
371 }
372 if body, err := os.ReadFile(fixture.backup); err != nil || string(body) != "old desktop" {
373 t.Fatalf("rollback backup=%q err=%v", body, err)
374 }
375 if len(fixture.transaction.Files) != 2 {
376 t.Fatalf("release-unit files=%d, want desktop and Guard", len(fixture.transaction.Files))
377 }
378 if body, err := os.ReadFile(fixture.transaction.Files[1].BackupPath); err != nil || string(body) != "old guard" {
379 t.Fatalf("Guard rollback backup=%q err=%v", body, err)
380 }
381 if body, err := os.ReadFile(fixture.active); err != nil || string(body) != "healthy versioned desktop" {
382 t.Fatalf("active desktop=%q err=%v", body, err)
383 }
384 }
385
386 func TestArchiveSupersededPendingFileUpdateAcceptsSameVersionReinstall(t *testing.T) {
387 fixture := prepareSupersededUpdateFixture(t, "v1.19.3", "v1.19.3")
388 archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root)
389 if err != nil || !archived {
390 t.Fatalf("same-version healthy reinstall archived=%v err=%v", archived, err)
391 }
392 }
393
394 func TestArchiveSupersededPendingFileUpdateRejectsOlderRunningVersion(t *testing.T) {
395 fixture := prepareSupersededUpdateFixture(t, "v1.20.0", "v1.19.3")
396 if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived {
397 t.Fatalf("archived=%v err=%v", archived, err)
398 }
399 assertPendingTransactionUnchanged(t, fixture.pendingBody)
400 }
401
402 func TestArchiveSupersededPendingFileUpdateRejectsForeignInstall(t *testing.T) {
403 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
404 if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, t.TempDir()); err == nil || archived {
405 t.Fatalf("archived=%v err=%v", archived, err)
406 }
407 assertPendingTransactionUnchanged(t, fixture.pendingBody)
408 }
409
410 func TestArchiveSupersededPendingFileUpdateRejectsNonActiveExecutable(t *testing.T) {
411 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
412 other := filepath.Join(filepath.Dir(fixture.active), "other-desktop")
413 if err := os.WriteFile(other, []byte("other"), 0o700); err != nil {
414 t.Fatal(err)
415 }
416 repairExecutable = func() (string, error) { return other, nil }
417 if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived {
418 t.Fatalf("archived=%v err=%v", archived, err)
419 }
420 assertPendingTransactionUnchanged(t, fixture.pendingBody)
421 }
422
423 func TestArchiveSupersededPendingFileUpdateRejectsPlatformMismatch(t *testing.T) {
424 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
425 changed := *fixture.transaction
426 changed.Platform = "foreign/amd64"
427 if err := overwritePendingUpdateForTest(&changed); err != nil {
428 t.Fatal(err)
429 }
430 changedBody, err := os.ReadFile(PendingUpdatePath())
431 if err != nil {
432 t.Fatal(err)
433 }
434 if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived {
435 t.Fatalf("archived=%v err=%v", archived, err)
436 }
437 assertPendingTransactionUnchanged(t, changedBody)
438 }
439
440 func TestArchiveSupersededPendingFileUpdateRejectsNestedLegacyTarget(t *testing.T) {
441 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
442 nestedDir := filepath.Join(fixture.root, "other")
443 if err := os.MkdirAll(nestedDir, 0o755); err != nil {
444 t.Fatal(err)
445 }
446 nestedTarget := filepath.Join(nestedDir, installlayout.DesktopBinaryName())
447 changed := *fixture.transaction
448 changed.TargetPath = nestedTarget
449 changed.Files = append([]UpdateTransactionFile(nil), fixture.transaction.Files...)
450 for i := range changed.Files {
451 changed.Files[i].TargetPath = nestedTarget
452 }
453 if err := overwritePendingUpdateForTest(&changed); err != nil {
454 t.Fatal(err)
455 }
456 changedBody, err := os.ReadFile(PendingUpdatePath())
457 if err != nil {
458 t.Fatal(err)
459 }
460 if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived {
461 t.Fatalf("archived=%v err=%v", archived, err)
462 }
463 assertPendingTransactionUnchanged(t, changedBody)
464 }
465
466 func TestArchiveSupersededPendingFileUpdateDetectsConcurrentReplacement(t *testing.T) {
467 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
468 originalAcquire := acquirePendingUpdateLock
469 attempted := make(chan struct{})
470 release := make(chan struct{})
471 acquirePendingUpdateLock = func() (func(), error) {
472 close(attempted)
473 <-release
474 return func() {}, nil
475 }
476 t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire })
477
478 type outcome struct {
479 archived bool
480 err error
481 }
482 done := make(chan outcome, 1)
483 go func() {
484 archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root)
485 done <- outcome{archived: archived, err: err}
486 }()
487 <-attempted
488 changed := *fixture.transaction
489 changed.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano)
490 if err := overwritePendingUpdateForTest(&changed); err != nil {
491 t.Fatal(err)
492 }
493 close(release)
494 result := <-done
495 if result.err == nil || result.archived || !strings.Contains(result.err.Error(), "transaction changed while waiting") {
496 t.Fatalf("archived=%v err=%v", result.archived, result.err)
497 }
498 current, err := readPendingUpdateUnchecked()
499 if err != nil || UpdateTransactionID(current) != UpdateTransactionID(&changed) {
500 t.Fatalf("concurrent transaction was not preserved: current=%+v err=%v", current, err)
501 }
502 }
503
504 func TestArchiveSupersededPendingFileUpdateRestoresLateConcurrentRewrite(t *testing.T) {
505 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
506 changed := *fixture.transaction
507 changed.CreatedAt = time.Now().UTC().Add(2 * time.Second).Format(time.RFC3339Nano)
508 originalHook := supersededUpdateBeforeArchive
509 supersededUpdateBeforeArchive = func(string) {
510 if err := overwritePendingUpdateForTest(&changed); err != nil {
511 t.Fatalf("rewrite pending transaction: %v", err)
512 }
513 }
514 t.Cleanup(func() { supersededUpdateBeforeArchive = originalHook })
515
516 archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root)
517 if err == nil || archived || !strings.Contains(err.Error(), "transaction changed before archival") {
518 t.Fatalf("archived=%v err=%v", archived, err)
519 }
520 current, readErr := readPendingUpdateUnchecked()
521 if readErr != nil || UpdateTransactionID(current) != UpdateTransactionID(&changed) {
522 t.Fatalf("late replacement was not restored: current=%+v err=%v", current, readErr)
523 }
524 entries, readDirErr := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates"))
525 if readDirErr != nil || len(entries) != 0 {
526 t.Fatalf("unexpected archive after restored rewrite: entries=%v err=%v", entries, readDirErr)
527 }
528 }
529
530 func TestArchiveSupersededPendingFileUpdateSerializesConcurrentRecovery(t *testing.T) {
531 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
532 start := make(chan struct{})
533 type outcome struct {
534 archived bool
535 err error
536 }
537 done := make(chan outcome, 2)
538 for range 2 {
539 go func() {
540 <-start
541 archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root)
542 done <- outcome{archived: archived, err: err}
543 }()
544 }
545 close(start)
546 archivedCount := 0
547 for range 2 {
548 result := <-done
549 if result.err != nil {
550 t.Fatalf("concurrent recovery error: %v", result.err)
551 }
552 if result.archived {
553 archivedCount++
554 }
555 }
556 if archivedCount != 1 {
557 t.Fatalf("archived count=%d, want exactly one", archivedCount)
558 }
559 entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates"))
560 if err != nil || len(entries) != 1 {
561 t.Fatalf("archive entries=%v err=%v", entries, err)
562 }
563 }
564
565 func TestArchiveSupersededPendingFileUpdateRejectsMalformedTransaction(t *testing.T) {
566 t.Setenv("REASONIX_HOME", t.TempDir())
567 body := `{"schemaVersion":1,"toVersion":`
568 writePendingUpdateRaw(t, body)
569 if archived, err := ArchiveSupersededPendingFileUpdate("v1.20.0", t.TempDir()); err == nil || archived {
570 t.Fatalf("archived=%v err=%v", archived, err)
571 }
572 got, err := os.ReadFile(PendingUpdatePath())
573 if err != nil || string(got) != body {
574 t.Fatalf("malformed transaction changed: got=%q err=%v", got, err)
575 }
576 }
577
578 func assertPendingTransactionUnchanged(t *testing.T, want []byte) {
579 t.Helper()
580 got, err := os.ReadFile(PendingUpdatePath())
581 if err != nil || string(got) != string(want) {
582 t.Fatalf("pending transaction changed: err=%v\n got=%q\nwant=%q", err, got, want)
583 }
584 }
585
586 func TestSupersededUpdateFixtureUsesCurrentPlatform(t *testing.T) {
587 fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0")
588 if !strings.HasPrefix(fixture.transaction.Platform, runtime.GOOS+"/") {
589 t.Fatalf("transaction platform=%q, want %s", fixture.transaction.Platform, runtime.GOOS)
590 }
591 }
592
592 lines GO