| 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 | func TestArchiveSupersededPendingAppBundleUpdateLeavesModernRollbackForHealthCommit(t *testing.T) { |
| 140 | prepareSupersededAppUpdateFixture(t, true, true) |
| 141 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.7") |
| 142 | if err != nil || archived { |
| 143 | t.Fatalf("modern transaction archived=%v err=%v", archived, err) |
| 144 | } |
| 145 | if _, err := ReadPendingUpdate(); err != nil { |
| 146 | t.Fatalf("modern pending transaction changed: %v", err) |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | func TestArchiveSupersededPendingAppBundleUpdateLeavesCurrentProcessHandoff(t *testing.T) { |
| 151 | fixture := prepareSupersededAppUpdateFixture(t, false, false) |
| 152 | fixture.transaction.HandoffOwnerPID = os.Getpid() |
| 153 | fixture.transaction.HandoffStagingPath = filepath.Join(filepath.Dir(fixture.target), "handoff-stage") |
| 154 | fixture.transaction.HandoffAppPath = filepath.Join(fixture.transaction.HandoffStagingPath, "Reasonix.app") |
| 155 | fixture.transaction.HandoffAppTreeID = strings.Repeat("b", 64) |
| 156 | fixture.transaction.HandoffStagingTreeID = strings.Repeat("c", 64) |
| 157 | if err := overwritePendingUpdateForTest(fixture.transaction); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 161 | if err != nil || archived { |
| 162 | t.Fatalf("current handoff archived=%v err=%v", archived, err) |
| 163 | } |
| 164 | if _, err := readPendingUpdateUnchecked(); err != nil { |
| 165 | t.Fatalf("current handoff transaction changed: %v", err) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestArchiveSupersededPendingAppBundleUpdateRejectsUnrelatedOlderVersion(t *testing.T) { |
| 170 | fixture := prepareSupersededAppUpdateFixture(t, true, false) |
| 171 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.4") |
| 172 | if err == nil || archived { |
| 173 | t.Fatalf("older running version archived=%v err=%v", archived, err) |
| 174 | } |
| 175 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 176 | } |
| 177 | |
| 178 | func TestArchiveSupersededPendingAppBundleUpdateRestoresStateOnTargetDrift(t *testing.T) { |
| 179 | fixture := prepareSupersededAppUpdateFixture(t, true, false) |
| 180 | originalHook := supersededAppUpdateAfterBackupArchive |
| 181 | supersededAppUpdateAfterBackupArchive = func(string) { |
| 182 | if err := os.WriteFile(fixture.marker, []byte("changed concurrently"), 0o600); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | } |
| 186 | t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook }) |
| 187 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 188 | if err == nil || archived { |
| 189 | t.Fatalf("drifted target archived=%v err=%v", archived, err) |
| 190 | } |
| 191 | if _, err := ReadPendingUpdate(); err != nil { |
| 192 | t.Fatalf("pending transaction was not restored: %v", err) |
| 193 | } |
| 194 | if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" { |
| 195 | t.Fatalf("rollback backup was not restored=%q err=%v", body, err) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestArchiveSupersededPendingAppBundleUpdatePreservesRecreatedBackupPath(t *testing.T) { |
| 200 | fixture := prepareSupersededAppUpdateFixture(t, true, false) |
| 201 | originalHook := supersededAppUpdateAfterBackupArchive |
| 202 | supersededAppUpdateAfterBackupArchive = func(string) { |
| 203 | marker := filepath.Join(fixture.backup, "Contents", "Resources", "release.txt") |
| 204 | if err := os.MkdirAll(filepath.Dir(marker), 0o755); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if err := os.WriteFile(marker, []byte("concurrently recreated backup"), 0o600); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | } |
| 211 | t.Cleanup(func() { supersededAppUpdateAfterBackupArchive = originalHook }) |
| 212 | |
| 213 | archived, err := ArchiveSupersededPendingAppBundleUpdate("v1.19.5") |
| 214 | if err == nil || archived { |
| 215 | t.Fatalf("recreated backup archived=%v err=%v", archived, err) |
| 216 | } |
| 217 | if _, err := ReadPendingUpdate(); err != nil { |
| 218 | t.Fatalf("pending transaction was not restored: %v", err) |
| 219 | } |
| 220 | if body, err := os.ReadFile(filepath.Join(fixture.backup, "Contents", "Resources", "release.txt")); err != nil || string(body) != "concurrently recreated backup" { |
| 221 | t.Fatalf("recreated public backup changed=%q err=%v", body, err) |
| 222 | } |
| 223 | backups, err := filepath.Glob(fixture.backup + ".reasonix-retired-*") |
| 224 | if err != nil || len(backups) != 1 { |
| 225 | t.Fatalf("preserved archived backups=%v err=%v", backups, err) |
| 226 | } |
| 227 | if body, err := os.ReadFile(filepath.Join(backups[0], "Contents", "Resources", "release.txt")); err != nil || string(body) != "preserved previous app" { |
| 228 | t.Fatalf("archived original backup changed=%q err=%v", body, err) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | func prepareSupersededUpdateFixture(t *testing.T, pendingVersion, runningVersion string) supersededUpdateFixture { |
| 233 | t.Helper() |
| 234 | home := t.TempDir() |
| 235 | t.Setenv("REASONIX_HOME", home) |
| 236 | root := t.TempDir() |
| 237 | originalExecutable := repairExecutable |
| 238 | t.Cleanup(func() { repairExecutable = originalExecutable }) |
| 239 | |
| 240 | target := filepath.Join(root, installlayout.DesktopBinaryName()) |
| 241 | if err := os.WriteFile(target, []byte("old desktop"), 0o700); err != nil { |
| 242 | t.Fatal(err) |
| 243 | } |
| 244 | guardName := "reasonix-guard" |
| 245 | if runtime.GOOS == "windows" { |
| 246 | guardName += ".exe" |
| 247 | } |
| 248 | guard := filepath.Join(root, guardName) |
| 249 | if err := os.WriteFile(guard, []byte("old guard"), 0o700); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | repairExecutable = func() (string, error) { return guard, nil } |
| 253 | tx, err := PrepareFileUpdate("v1.18.0", pendingVersion, target, guard) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | pendingBody, err := os.ReadFile(PendingUpdatePath()) |
| 258 | if err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | |
| 262 | runningVersion = canonicalSemver(runningVersion) |
| 263 | activeDir := filepath.Join(root, filepath.FromSlash(installlayout.VersionDirRelative(runningVersion))) |
| 264 | if err := os.MkdirAll(activeDir, 0o755); err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | active := filepath.Join(activeDir, installlayout.DesktopBinaryName()) |
| 268 | if err := os.WriteFile(active, []byte("healthy versioned desktop"), 0o700); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if err := installlayout.WriteCurrent(root, installlayout.CurrentPointer{ |
| 272 | ActiveVersion: runningVersion, |
| 273 | ActiveDir: installlayout.VersionDirRelative(runningVersion), |
| 274 | }); err != nil { |
| 275 | t.Fatal(err) |
| 276 | } |
| 277 | repairExecutable = func() (string, error) { return active, nil } |
| 278 | |
| 279 | return supersededUpdateFixture{ |
| 280 | home: home, root: root, target: target, active: active, |
| 281 | backup: tx.BackupPath, pendingBody: pendingBody, transaction: tx, |
| 282 | running: runningVersion, |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | func TestArchiveSupersededPendingFileUpdateHealsGuardDirectoryMismatch(t *testing.T) { |
| 287 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 288 | if _, err := ReadPendingUpdate(); err == nil || !strings.Contains(err.Error(), "outside the current Guard installation") { |
| 289 | t.Fatalf("ReadPendingUpdate error = %v, want the reported Guard-directory mismatch", err) |
| 290 | } |
| 291 | |
| 292 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 293 | if err != nil || !archived { |
| 294 | t.Fatalf("archived=%v err=%v", archived, err) |
| 295 | } |
| 296 | if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) { |
| 297 | t.Fatalf("pending transaction survived: %v", err) |
| 298 | } |
| 299 | entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates")) |
| 300 | if err != nil || len(entries) != 1 { |
| 301 | t.Fatalf("archive entries=%v err=%v", entries, err) |
| 302 | } |
| 303 | archivedBody, err := os.ReadFile(filepath.Join(fixture.home, "repair", "legacy-updates", entries[0].Name())) |
| 304 | if err != nil || string(archivedBody) != string(fixture.pendingBody) { |
| 305 | t.Fatalf("archived transaction changed: err=%v\n got=%q\nwant=%q", err, archivedBody, fixture.pendingBody) |
| 306 | } |
| 307 | if body, err := os.ReadFile(fixture.backup); err != nil || string(body) != "old desktop" { |
| 308 | t.Fatalf("rollback backup=%q err=%v", body, err) |
| 309 | } |
| 310 | if len(fixture.transaction.Files) != 2 { |
| 311 | t.Fatalf("release-unit files=%d, want desktop and Guard", len(fixture.transaction.Files)) |
| 312 | } |
| 313 | if body, err := os.ReadFile(fixture.transaction.Files[1].BackupPath); err != nil || string(body) != "old guard" { |
| 314 | t.Fatalf("Guard rollback backup=%q err=%v", body, err) |
| 315 | } |
| 316 | if body, err := os.ReadFile(fixture.active); err != nil || string(body) != "healthy versioned desktop" { |
| 317 | t.Fatalf("active desktop=%q err=%v", body, err) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | func TestArchiveSupersededPendingFileUpdateAcceptsSameVersionReinstall(t *testing.T) { |
| 322 | fixture := prepareSupersededUpdateFixture(t, "v1.19.3", "v1.19.3") |
| 323 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 324 | if err != nil || !archived { |
| 325 | t.Fatalf("same-version healthy reinstall archived=%v err=%v", archived, err) |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | func TestArchiveSupersededPendingFileUpdateRejectsOlderRunningVersion(t *testing.T) { |
| 330 | fixture := prepareSupersededUpdateFixture(t, "v1.20.0", "v1.19.3") |
| 331 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 332 | t.Fatalf("archived=%v err=%v", archived, err) |
| 333 | } |
| 334 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 335 | } |
| 336 | |
| 337 | func TestArchiveSupersededPendingFileUpdateRejectsForeignInstall(t *testing.T) { |
| 338 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 339 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, t.TempDir()); err == nil || archived { |
| 340 | t.Fatalf("archived=%v err=%v", archived, err) |
| 341 | } |
| 342 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 343 | } |
| 344 | |
| 345 | func TestArchiveSupersededPendingFileUpdateRejectsNonActiveExecutable(t *testing.T) { |
| 346 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 347 | other := filepath.Join(filepath.Dir(fixture.active), "other-desktop") |
| 348 | if err := os.WriteFile(other, []byte("other"), 0o700); err != nil { |
| 349 | t.Fatal(err) |
| 350 | } |
| 351 | repairExecutable = func() (string, error) { return other, nil } |
| 352 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 353 | t.Fatalf("archived=%v err=%v", archived, err) |
| 354 | } |
| 355 | assertPendingTransactionUnchanged(t, fixture.pendingBody) |
| 356 | } |
| 357 | |
| 358 | func TestArchiveSupersededPendingFileUpdateRejectsPlatformMismatch(t *testing.T) { |
| 359 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 360 | changed := *fixture.transaction |
| 361 | changed.Platform = "foreign/amd64" |
| 362 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 363 | t.Fatal(err) |
| 364 | } |
| 365 | changedBody, err := os.ReadFile(PendingUpdatePath()) |
| 366 | if err != nil { |
| 367 | t.Fatal(err) |
| 368 | } |
| 369 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 370 | t.Fatalf("archived=%v err=%v", archived, err) |
| 371 | } |
| 372 | assertPendingTransactionUnchanged(t, changedBody) |
| 373 | } |
| 374 | |
| 375 | func TestArchiveSupersededPendingFileUpdateRejectsNestedLegacyTarget(t *testing.T) { |
| 376 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 377 | nestedDir := filepath.Join(fixture.root, "other") |
| 378 | if err := os.MkdirAll(nestedDir, 0o755); err != nil { |
| 379 | t.Fatal(err) |
| 380 | } |
| 381 | nestedTarget := filepath.Join(nestedDir, installlayout.DesktopBinaryName()) |
| 382 | changed := *fixture.transaction |
| 383 | changed.TargetPath = nestedTarget |
| 384 | changed.Files = append([]UpdateTransactionFile(nil), fixture.transaction.Files...) |
| 385 | for i := range changed.Files { |
| 386 | changed.Files[i].TargetPath = nestedTarget |
| 387 | } |
| 388 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 389 | t.Fatal(err) |
| 390 | } |
| 391 | changedBody, err := os.ReadFile(PendingUpdatePath()) |
| 392 | if err != nil { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | if archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root); err == nil || archived { |
| 396 | t.Fatalf("archived=%v err=%v", archived, err) |
| 397 | } |
| 398 | assertPendingTransactionUnchanged(t, changedBody) |
| 399 | } |
| 400 | |
| 401 | func TestArchiveSupersededPendingFileUpdateDetectsConcurrentReplacement(t *testing.T) { |
| 402 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 403 | originalAcquire := acquirePendingUpdateLock |
| 404 | attempted := make(chan struct{}) |
| 405 | release := make(chan struct{}) |
| 406 | acquirePendingUpdateLock = func() (func(), error) { |
| 407 | close(attempted) |
| 408 | <-release |
| 409 | return func() {}, nil |
| 410 | } |
| 411 | t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire }) |
| 412 | |
| 413 | type outcome struct { |
| 414 | archived bool |
| 415 | err error |
| 416 | } |
| 417 | done := make(chan outcome, 1) |
| 418 | go func() { |
| 419 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 420 | done <- outcome{archived: archived, err: err} |
| 421 | }() |
| 422 | <-attempted |
| 423 | changed := *fixture.transaction |
| 424 | changed.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano) |
| 425 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | close(release) |
| 429 | result := <-done |
| 430 | if result.err == nil || result.archived || !strings.Contains(result.err.Error(), "transaction changed while waiting") { |
| 431 | t.Fatalf("archived=%v err=%v", result.archived, result.err) |
| 432 | } |
| 433 | current, err := readPendingUpdateUnchecked() |
| 434 | if err != nil || UpdateTransactionID(current) != UpdateTransactionID(&changed) { |
| 435 | t.Fatalf("concurrent transaction was not preserved: current=%+v err=%v", current, err) |
| 436 | } |
| 437 | } |
| 438 | |
| 439 | func TestArchiveSupersededPendingFileUpdateRestoresLateConcurrentRewrite(t *testing.T) { |
| 440 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 441 | changed := *fixture.transaction |
| 442 | changed.CreatedAt = time.Now().UTC().Add(2 * time.Second).Format(time.RFC3339Nano) |
| 443 | originalHook := supersededUpdateBeforeArchive |
| 444 | supersededUpdateBeforeArchive = func(string) { |
| 445 | if err := overwritePendingUpdateForTest(&changed); err != nil { |
| 446 | t.Fatalf("rewrite pending transaction: %v", err) |
| 447 | } |
| 448 | } |
| 449 | t.Cleanup(func() { supersededUpdateBeforeArchive = originalHook }) |
| 450 | |
| 451 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 452 | if err == nil || archived || !strings.Contains(err.Error(), "transaction changed before archival") { |
| 453 | t.Fatalf("archived=%v err=%v", archived, err) |
| 454 | } |
| 455 | current, readErr := readPendingUpdateUnchecked() |
| 456 | if readErr != nil || UpdateTransactionID(current) != UpdateTransactionID(&changed) { |
| 457 | t.Fatalf("late replacement was not restored: current=%+v err=%v", current, readErr) |
| 458 | } |
| 459 | entries, readDirErr := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates")) |
| 460 | if readDirErr != nil || len(entries) != 0 { |
| 461 | t.Fatalf("unexpected archive after restored rewrite: entries=%v err=%v", entries, readDirErr) |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | func TestArchiveSupersededPendingFileUpdateSerializesConcurrentRecovery(t *testing.T) { |
| 466 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 467 | start := make(chan struct{}) |
| 468 | type outcome struct { |
| 469 | archived bool |
| 470 | err error |
| 471 | } |
| 472 | done := make(chan outcome, 2) |
| 473 | for range 2 { |
| 474 | go func() { |
| 475 | <-start |
| 476 | archived, err := ArchiveSupersededPendingFileUpdate(fixture.running, fixture.root) |
| 477 | done <- outcome{archived: archived, err: err} |
| 478 | }() |
| 479 | } |
| 480 | close(start) |
| 481 | archivedCount := 0 |
| 482 | for range 2 { |
| 483 | result := <-done |
| 484 | if result.err != nil { |
| 485 | t.Fatalf("concurrent recovery error: %v", result.err) |
| 486 | } |
| 487 | if result.archived { |
| 488 | archivedCount++ |
| 489 | } |
| 490 | } |
| 491 | if archivedCount != 1 { |
| 492 | t.Fatalf("archived count=%d, want exactly one", archivedCount) |
| 493 | } |
| 494 | entries, err := os.ReadDir(filepath.Join(fixture.home, "repair", "legacy-updates")) |
| 495 | if err != nil || len(entries) != 1 { |
| 496 | t.Fatalf("archive entries=%v err=%v", entries, err) |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | func TestArchiveSupersededPendingFileUpdateRejectsMalformedTransaction(t *testing.T) { |
| 501 | t.Setenv("REASONIX_HOME", t.TempDir()) |
| 502 | body := `{"schemaVersion":1,"toVersion":` |
| 503 | writePendingUpdateRaw(t, body) |
| 504 | if archived, err := ArchiveSupersededPendingFileUpdate("v1.20.0", t.TempDir()); err == nil || archived { |
| 505 | t.Fatalf("archived=%v err=%v", archived, err) |
| 506 | } |
| 507 | got, err := os.ReadFile(PendingUpdatePath()) |
| 508 | if err != nil || string(got) != body { |
| 509 | t.Fatalf("malformed transaction changed: got=%q err=%v", got, err) |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | func assertPendingTransactionUnchanged(t *testing.T, want []byte) { |
| 514 | t.Helper() |
| 515 | got, err := os.ReadFile(PendingUpdatePath()) |
| 516 | if err != nil || string(got) != string(want) { |
| 517 | t.Fatalf("pending transaction changed: err=%v\n got=%q\nwant=%q", err, got, want) |
| 518 | } |
| 519 | } |
| 520 | |
| 521 | func TestSupersededUpdateFixtureUsesCurrentPlatform(t *testing.T) { |
| 522 | fixture := prepareSupersededUpdateFixture(t, "v1.19.1", "v1.20.0") |
| 523 | if !strings.HasPrefix(fixture.transaction.Platform, runtime.GOOS+"/") { |
| 524 | t.Fatalf("transaction platform=%q, want %s", fixture.transaction.Platform, runtime.GOOS) |
| 525 | } |
| 526 | } |
| 527 |