| 1 | //go:build windows |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/installlayout" |
| 11 | "reasonix/internal/repair" |
| 12 | ) |
| 13 | |
| 14 | func writeVersionedWindowsStaging(t *testing.T, dir, prefix, version string, names ...string) { |
| 15 | t.Helper() |
| 16 | if len(names) == 0 { |
| 17 | names = []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} |
| 18 | } |
| 19 | for _, name := range names { |
| 20 | path := filepath.Join(dir, filepath.FromSlash(name)) |
| 21 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | if err := os.WriteFile(path, []byte(prefix+name), 0o700); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | } |
| 28 | writeWindowsPayloadManifestForTest(t, dir, version) |
| 29 | } |
| 30 | |
| 31 | func versionedWindowsTransaction(installDir, version, createdAt string) *repair.UpdateTransaction { |
| 32 | return &repair.UpdateTransaction{ |
| 33 | SchemaVersion: 1, |
| 34 | ToVersion: version, |
| 35 | TargetKind: "file", |
| 36 | TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"), |
| 37 | CreatedAt: createdAt, |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | func TestPreferVersionedWindowsActivation(t *testing.T) { |
| 42 | staging := t.TempDir() |
| 43 | if preferVersionedWindowsActivation(staging) { |
| 44 | t.Fatal("empty staging must not prefer versioned") |
| 45 | } |
| 46 | for _, name := range []string{ |
| 47 | "reasonix-desktop.exe", |
| 48 | "reasonix-cli.exe", |
| 49 | "reasonix-update-helper.exe", |
| 50 | "reasonix-launcher.exe", |
| 51 | } { |
| 52 | if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | } |
| 56 | if !preferVersionedWindowsActivation(staging) { |
| 57 | t.Fatal("complete staging must prefer versioned") |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func TestActivateVersionedWindowsFromStaging(t *testing.T) { |
| 62 | acceptWindowsPayloadManifestForTest(t) |
| 63 | installDir := t.TempDir() |
| 64 | staging := t.TempDir() |
| 65 | writeVersionedWindowsStaging(t, staging, "payload:", "v1.20.0", |
| 66 | "reasonix-desktop.exe", |
| 67 | "reasonix-cli.exe", |
| 68 | "reasonix-update-helper.exe", |
| 69 | "reasonix-launcher.exe", |
| 70 | "reasonix-guard.exe", |
| 71 | ) |
| 72 | // Seed a flat desktop so cleanup is observable. |
| 73 | if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | |
| 80 | claimed := versionedWindowsTransaction(installDir, "v1.20.0", "2026-01-01T00:00:00Z") |
| 81 | if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if !installlayout.HasCurrent(installDir) { |
| 85 | t.Fatal("current.json missing") |
| 86 | } |
| 87 | ptr, err := installlayout.ReadCurrent(installDir) |
| 88 | if err != nil || ptr.ActiveVersion != "v1.20.0" { |
| 89 | t.Fatalf("pointer=%+v err=%v", ptr, err) |
| 90 | } |
| 91 | desktop, err := installlayout.ActiveDesktopPath(installDir) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | raw, err := os.ReadFile(desktop) |
| 96 | if err != nil || string(raw) != "payload:reasonix-desktop.exe" { |
| 97 | t.Fatalf("desktop payload = %q err=%v", raw, err) |
| 98 | } |
| 99 | for _, name := range []string{"Reasonix.exe", "reasonix-cli.exe"} { |
| 100 | if _, err := os.Stat(filepath.Join(installDir, name)); err != nil { |
| 101 | t.Fatalf("root entry %s: %v", name, err) |
| 102 | } |
| 103 | } |
| 104 | if _, err := os.Lstat(filepath.Join(installDir, "reasonix-launcher.exe")); !os.IsNotExist(err) { |
| 105 | t.Fatalf("new versioned root created a legacy entry: %v", err) |
| 106 | } |
| 107 | if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) { |
| 108 | t.Fatal("flat desktop should be removed") |
| 109 | } |
| 110 | if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) { |
| 111 | t.Fatal("flat guard should be removed") |
| 112 | } |
| 113 | if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "Reasonix.exe" { |
| 114 | t.Fatalf("prefer relaunch = %s", prefer) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestActivateVersionedWindowsFromStagingPublishesShellTree(t *testing.T) { |
| 119 | acceptWindowsPayloadManifestForTest(t) |
| 120 | installDir := t.TempDir() |
| 121 | staging := t.TempDir() |
| 122 | tree := []string{"app/Reasonix.exe", "app/resources/app.asar", "app/locales/en-US.pak"} |
| 123 | writeVersionedWindowsStaging(t, staging, "payload:", "v1.30.0", append([]string{ |
| 124 | "reasonix-desktop.exe", |
| 125 | "reasonix-cli.exe", |
| 126 | "reasonix-update-helper.exe", |
| 127 | "reasonix-launcher.exe", |
| 128 | }, tree...)...) |
| 129 | if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(installDir, "v1.30.0", "2026-01-01T00:00:00Z"), staging); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | versionDir := filepath.Join(installDir, installlayout.VersionsDirName, "v1.30.0") |
| 133 | for _, name := range tree { |
| 134 | raw, err := os.ReadFile(filepath.Join(versionDir, filepath.FromSlash(name))) |
| 135 | if err != nil || string(raw) != "payload:"+name { |
| 136 | t.Fatalf("tree member %s = %q err=%v", name, raw, err) |
| 137 | } |
| 138 | } |
| 139 | if _, err := os.Stat(filepath.Join(installDir, "app")); !os.IsNotExist(err) { |
| 140 | t.Fatalf("shell tree leaked into the install root: %v", err) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | func TestVersionedWindowsUpgradePreservesExistingLegacyEntry(t *testing.T) { |
| 145 | acceptWindowsPayloadManifestForTest(t) |
| 146 | root := t.TempDir() |
| 147 | if err := os.WriteFile(filepath.Join(root, "reasonix-launcher.exe"), []byte("old"), 0o700); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | for _, version := range []string{"v1.38.9", "v1.39.0"} { |
| 151 | staging := t.TempDir() |
| 152 | writeVersionedWindowsStaging(t, staging, version+":", version) |
| 153 | if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(root, version, version), staging); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | for _, name := range []string{"Reasonix.exe", "reasonix-launcher.exe"} { |
| 157 | body, err := os.ReadFile(filepath.Join(root, name)) |
| 158 | if err != nil || string(body) != version+":reasonix-launcher.exe" { |
| 159 | t.Fatalf("%s=%q (%v)", name, body, err) |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | func TestActivateVersionedWindowsUsesVerifiedThinCLIEntry(t *testing.T) { |
| 166 | acceptWindowsPayloadManifestForTest(t) |
| 167 | installDir := t.TempDir() |
| 168 | staging := t.TempDir() |
| 169 | const entry = "app/resources/bin/reasonix-cli-launcher.exe" |
| 170 | writeVersionedWindowsStaging(t, staging, "payload:", "v1.39.0", |
| 171 | "reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", |
| 172 | "reasonix-launcher.exe", "app/Reasonix.exe", entry) |
| 173 | if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(installDir, "v1.39.0", "2026-01-01T00:00:00Z"), staging); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | rootCLI, err := os.ReadFile(filepath.Join(installDir, "reasonix-cli.exe")) |
| 177 | if err != nil || string(rootCLI) != "payload:"+entry { |
| 178 | t.Fatalf("root CLI=%q err=%v, want verified thin entry", rootCLI, err) |
| 179 | } |
| 180 | fullCLI, err := os.ReadFile(filepath.Join(installDir, "versions", "v1.39.0", "reasonix-cli.exe")) |
| 181 | if err != nil || string(fullCLI) != "payload:reasonix-cli.exe" { |
| 182 | t.Fatalf("version CLI=%q err=%v, want full CLI", fullCLI, err) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | func TestActivateVersionedWindowsFromStagingRejectsManifestDrift(t *testing.T) { |
| 187 | acceptWindowsPayloadManifestForTest(t) |
| 188 | installDir := t.TempDir() |
| 189 | staging := t.TempDir() |
| 190 | writeVersionedWindowsStaging(t, staging, "payload:", "v1.30.0", |
| 191 | "reasonix-desktop.exe", |
| 192 | "reasonix-cli.exe", |
| 193 | "reasonix-update-helper.exe", |
| 194 | "reasonix-launcher.exe", |
| 195 | "app/Reasonix.exe", |
| 196 | ) |
| 197 | if err := os.WriteFile(filepath.Join(staging, "app", "Reasonix.exe"), []byte("tampered"), 0o700); err != nil { |
| 198 | t.Fatal(err) |
| 199 | } |
| 200 | if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(installDir, "v1.30.0", "2026-01-01T00:00:00Z"), staging); err == nil { |
| 201 | t.Fatal("staged tree member that differs from the signed manifest was activated") |
| 202 | } |
| 203 | if installlayout.HasCurrent(installDir) { |
| 204 | t.Fatal("current.json was written after a rejected payload") |
| 205 | } |
| 206 | if err := os.Remove(filepath.Join(staging, "reasonix-payload.json")); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(installDir, "v1.30.0", "2026-01-01T00:00:00Z"), staging); err == nil { |
| 210 | t.Fatal("staged payload without a signed manifest was activated") |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestPreferRelaunchPathIgnoresStaleVersionedDesktop(t *testing.T) { |
| 215 | acceptWindowsPayloadManifestForTest(t) |
| 216 | installDir := t.TempDir() |
| 217 | oldSrc := t.TempDir() |
| 218 | newSrc := t.TempDir() |
| 219 | writeVersionedWindowsStaging(t, oldSrc, "old-", "v1.24.0") |
| 220 | if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(installDir, "v1.24.0", "2026-01-01T00:00:00Z"), oldSrc); err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | oldDesktop, err := installlayout.ActiveDesktopPath(installDir) |
| 224 | if err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | writeVersionedWindowsStaging(t, newSrc, "new-", "v1.24.1") |
| 228 | if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(installDir, "v1.24.1", "2026-01-01T00:00:01Z"), newSrc); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | if _, err := os.Stat(oldDesktop); err != nil { |
| 232 | t.Fatalf("previous desktop should remain: %v", err) |
| 233 | } |
| 234 | got := preferRelaunchPath(oldDesktop, installDir) |
| 235 | if filepath.Base(got) != "Reasonix.exe" { |
| 236 | t.Fatalf("preferRelaunchPath(%s) = %s, want install-root launcher", oldDesktop, got) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) { |
| 241 | acceptWindowsPayloadManifestForTest(t) |
| 242 | installDir := t.TempDir() |
| 243 | staging := t.TempDir() |
| 244 | writeVersionedWindowsStaging(t, staging, "p:", "v1.20.0", |
| 245 | "reasonix-desktop.exe", |
| 246 | "reasonix-cli.exe", |
| 247 | "reasonix-update-helper.exe", |
| 248 | "reasonix-launcher.exe", |
| 249 | "reasonix-guard.exe", |
| 250 | ) |
| 251 | // Minimal claimed unit (versioned path does not need full flat file list). |
| 252 | claimed := versionedWindowsTransaction(installDir, "v1.20.0", "2026-01-01T00:00:00Z") |
| 253 | started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | if !started || receipts != nil { |
| 258 | t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts) |
| 259 | } |
| 260 | if !installlayout.HasCurrent(installDir) { |
| 261 | t.Fatal("versioned activation did not write current.json") |
| 262 | } |
| 263 | } |
| 264 |