返回 DeepSeek-Reasonix
release_unit_test.go
根目录 / desktop / cmd / update-helper / release_unit_test.go
1 package main
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/desktop/internal/update"
11 "reasonix/internal/repair"
12 )
13
14 func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
15 staging := t.TempDir()
16 for name, content := range map[string]string{
17 "reasonix-desktop.exe": "desktop-v2",
18 "reasonix-guard.exe": "guard-v2",
19 "reasonix-launcher.exe": "launcher-v2",
20 "reasonix-update-helper.exe": "helper-v2",
21 "reasonix-cli.exe": "cli-v2",
22 } {
23 if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
24 t.Fatal(err)
25 }
26 }
27 useTestWindowsPayloadManifest(t, staging, "v2")
28 installDir := t.TempDir()
29 claimed := &repair.UpdateTransaction{
30 SchemaVersion: 1,
31 ToVersion: "v2",
32 TargetKind: "file",
33 TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
34 Files: []repair.UpdateTransactionFile{
35 {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
36 {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
37 {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
38 {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
39 {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
40 {TargetPath: filepath.Join(installDir, "Reasonix.exe")},
41 },
42 }
43
44 members, err := loadWindowsStagedReleaseUnit(claimed, staging)
45 if err != nil {
46 t.Fatal(err)
47 }
48 if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
49 t.Fatalf("last published member = %q, want desktop", got)
50 }
51 var published []string
52 receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
53 published = append(published, filepath.Base(target)+"="+string(content))
54 return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
55 })
56 if err != nil {
57 t.Fatal(err)
58 }
59 if len(receipts) != len(members) {
60 t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
61 }
62 if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
63 t.Fatalf("portable alias did not reuse launcher payload: %s", got)
64 }
65 if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
66 t.Fatalf("publish order = %v", published)
67 }
68 }
69
70 func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
71 staging := t.TempDir()
72 if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
73 t.Fatal(err)
74 }
75 useTestWindowsPayloadManifest(t, staging, "v2")
76 installDir := t.TempDir()
77 claimed := &repair.UpdateTransaction{
78 SchemaVersion: 1,
79 ToVersion: "v2",
80 TargetKind: "file",
81 TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
82 Files: []repair.UpdateTransactionFile{
83 {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
84 {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
85 },
86 }
87 if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
88 t.Fatal("incomplete staged release unit was accepted")
89 }
90 }
91
92 func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
93 _, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
94 for i, file := range complete.Files {
95 t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
96 claimed := *complete
97 claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
98 claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
99 if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
100 !strings.Contains(err.Error(), "omits") {
101 t.Fatalf("missing target error = %v", err)
102 }
103 })
104 }
105 t.Run("extra", func(t *testing.T) {
106 claimed := *complete
107 claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
108 repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
109 if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
110 !strings.Contains(err.Error(), "unexpected") {
111 t.Fatalf("extra target error = %v", err)
112 }
113 })
114 t.Run("duplicate", func(t *testing.T) {
115 claimed := *complete
116 claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
117 if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
118 !strings.Contains(err.Error(), "duplicate") {
119 t.Fatalf("duplicate target error = %v", err)
120 }
121 })
122 t.Run("outside", func(t *testing.T) {
123 claimed := *complete
124 claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
125 claimed.Files[1].TargetPath = filepath.Join(t.TempDir(), filepath.Base(claimed.Files[1].TargetPath))
126 if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
127 !strings.Contains(err.Error(), "outside") {
128 t.Fatalf("outside target error = %v", err)
129 }
130 })
131 t.Run("primary", func(t *testing.T) {
132 claimed := *complete
133 claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
134 if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
135 !strings.Contains(err.Error(), "primary") {
136 t.Fatalf("primary target error = %v", err)
137 }
138 })
139 }
140
141 func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
142 staging := t.TempDir()
143 for _, name := range update.WindowsPayloadFileNames() {
144 content := "payload:" + name
145 if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
146 t.Fatal(err)
147 }
148 }
149 useTestWindowsPayloadManifest(t, staging, "v2")
150 installDir := t.TempDir()
151 claimed := &repair.UpdateTransaction{
152 SchemaVersion: 1,
153 ToVersion: "v2",
154 TargetKind: "file",
155 TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
156 Files: []repair.UpdateTransactionFile{
157 {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
158 {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
159 {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
160 {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
161 {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
162 {TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
163 },
164 }
165
166 members, err := loadWindowsStagedReleaseUnit(claimed, staging)
167 if err != nil {
168 t.Fatal(err)
169 }
170 for _, member := range members {
171 if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
172 t.Fatalf("missing portable alias was added to publish set: %+v", members)
173 }
174 }
175 }
176
177 func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
178 claimed := &repair.UpdateTransaction{TargetKind: "file"}
179 members := []stagedFileUpdateMember{
180 {targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
181 {targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
182 }
183 var published []string
184 receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
185 published = append(published, target)
186 return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
187 })
188 if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
189 t.Fatalf("publish error = %v", err)
190 }
191 if len(published) != 1 {
192 t.Fatalf("published members = %v, want one attempted member", published)
193 }
194 if len(receipts) != 0 {
195 t.Fatalf("failed first publish returned receipts: %+v", receipts)
196 }
197 }
198
199 func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
200 staging := t.TempDir()
201 for _, name := range []string{
202 "reasonix-desktop.exe",
203 "reasonix-guard.exe",
204 "reasonix-launcher.exe",
205 "reasonix-update-helper.exe",
206 "reasonix-cli.exe",
207 } {
208 if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
209 t.Fatal(err)
210 }
211 }
212 writeWindowsPayloadManifestForTest(t, staging, "v2")
213 acceptWindowsPayloadManifestForTest(t)
214 _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
215 original := readVerifiedWindowsStagedPayloadFn
216 readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
217 return nil, errors.New("payload signature rejected")
218 }
219 t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
220
221 if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
222 t.Fatal("unsigned staged payload must be rejected before publication")
223 }
224 }
225
226 func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
227 staging := t.TempDir()
228 for name, content := range map[string]string{
229 "reasonix-desktop.exe": "desktop-v2",
230 "reasonix-guard.exe": "guard-v2",
231 "reasonix-launcher.exe": "launcher-v2",
232 "reasonix-update-helper.exe": "helper-v2",
233 "reasonix-cli.exe": "cli-v2",
234 } {
235 if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
236 t.Fatal(err)
237 }
238 }
239 writeWindowsPayloadManifestForTest(t, staging, "v2")
240 acceptWindowsPayloadManifestForTest(t)
241 _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
242 var verified []string
243 original := readVerifiedWindowsStagedPayloadFn
244 readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
245 verified = append(verified, filepath.Base(path))
246 return os.ReadFile(path)
247 }
248 t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
249
250 if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
251 t.Fatal(err)
252 }
253 if len(verified) != len(update.WindowsPayloadFileNames()) {
254 t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
255 }
256 }
257
258 func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
259 staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
260 useUnverifiedPayloadReaderForTest(t)
261 if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
262 !strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
263 t.Fatalf("missing signed manifest error = %v", err)
264 }
265 }
266
267 func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
268 staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
269 writeWindowsPayloadManifestForTest(t, staging, "v2")
270 useUnverifiedPayloadReaderForTest(t)
271 if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
272 !strings.Contains(err.Error(), "verify signed release manifest") {
273 t.Fatalf("bad signed manifest error = %v", err)
274 }
275 }
276
277 func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
278 staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
279 writeWindowsPayloadManifestForTest(t, staging, "v3")
280 acceptWindowsPayloadManifestForTest(t)
281 useUnverifiedPayloadReaderForTest(t)
282 if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
283 !strings.Contains(err.Error(), "identity does not match") {
284 t.Fatalf("release version drift error = %v", err)
285 }
286 }
287
288 func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
289 staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
290 writeWindowsPayloadManifestForTest(t, staging, "v2")
291 acceptWindowsPayloadManifestForTest(t)
292 useUnverifiedPayloadReaderForTest(t)
293 if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
294 t.Fatal(err)
295 }
296 if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
297 !strings.Contains(err.Error(), "does not match the signed release manifest") {
298 t.Fatalf("release member drift error = %v", err)
299 }
300 }
301
302 func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
303 t.Helper()
304 staging := t.TempDir()
305 for _, name := range update.WindowsPayloadFileNames() {
306 if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
307 t.Fatal(err)
308 }
309 }
310 installDir := t.TempDir()
311 files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
312 for _, name := range update.WindowsPayloadFileNames() {
313 files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
314 }
315 files = append(files, repair.UpdateTransactionFile{
316 TargetPath: filepath.Join(installDir, "Reasonix.exe"),
317 MissingBefore: true,
318 })
319 return staging, &repair.UpdateTransaction{
320 SchemaVersion: 1,
321 ToVersion: version,
322 TargetKind: "file",
323 TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
324 Files: files,
325 }
326 }
327
328 func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
329 t.Helper()
330 hashes := make(map[string]string)
331 for _, name := range update.WindowsPayloadFileNames() {
332 content, err := os.ReadFile(filepath.Join(staging, name))
333 if os.IsNotExist(err) {
334 content = []byte("missing:" + name)
335 } else if err != nil {
336 t.Fatal(err)
337 }
338 hashes[name] = update.WindowsPayloadSHA256(content)
339 }
340 manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
341 if err != nil {
342 t.Fatal(err)
343 }
344 if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
345 t.Fatal(err)
346 }
347 if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
348 t.Fatal(err)
349 }
350 }
351
352 func acceptWindowsPayloadManifestForTest(t *testing.T) {
353 t.Helper()
354 original := verifyWindowsPayloadManifestFn
355 verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
356 if string(signature) != "test signature" {
357 return errors.New("unexpected test signature")
358 }
359 return nil
360 }
361 t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
362 }
363
364 func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
365 t.Helper()
366 writeWindowsPayloadManifestForTest(t, staging, version)
367 acceptWindowsPayloadManifestForTest(t)
368 useUnverifiedPayloadReaderForTest(t)
369 }
370
371 func useUnverifiedPayloadReaderForTest(t *testing.T) {
372 t.Helper()
373 original := readVerifiedWindowsStagedPayloadFn
374 readVerifiedWindowsStagedPayloadFn = os.ReadFile
375 t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
376 }
377
377 lines GO