| 1 | package appidentity |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestShortcutRepairPolicy(t *testing.T) { |
| 10 | root := t.TempDir() |
| 11 | launcher := filepath.Join(root, "reasonix-launcher.exe") |
| 12 | if err := os.WriteFile(launcher, []byte("launcher"), 0o600); err != nil { |
| 13 | t.Fatal(err) |
| 14 | } |
| 15 | electron := filepath.Join(root, "versions", "v1.38.6", "app", "Reasonix.exe") |
| 16 | goDesktop := filepath.Join(root, "versions", "v1.20.0", "reasonix-desktop.exe") |
| 17 | flat := filepath.Join(root, "reasonix-desktop.exe") |
| 18 | customIcon := filepath.Join(root, "custom.ico") |
| 19 | for _, tt := range []struct { |
| 20 | name, target, icon, id string |
| 21 | want shortcutRepair |
| 22 | }{ |
| 23 | {"new pin", launcher, "", "", shortcutRepair{identity: AppUserModelID}}, |
| 24 | {"legacy pin", launcher, "", "Reasonix", shortcutRepair{identity: AppUserModelID}}, |
| 25 | {"healthy", launcher, launcher, AppUserModelID, shortcutRepair{}}, |
| 26 | {"versioned electron", electron, electron, "Reasonix", shortcutRepair{target: launcher, icon: launcher, identity: AppUserModelID}}, |
| 27 | {"versioned Go", goDesktop, goDesktop, "", shortcutRepair{target: launcher, icon: launcher, identity: AppUserModelID}}, |
| 28 | {"flat electron", filepath.Join(root, "app", "Reasonix.exe"), "", "Reasonix", shortcutRepair{target: launcher, identity: AppUserModelID}}, |
| 29 | {"custom icon", electron, customIcon, "", shortcutRepair{target: launcher, identity: AppUserModelID}}, |
| 30 | {"missing flat", flat, flat, "", shortcutRepair{target: launcher, icon: launcher, identity: AppUserModelID}}, |
| 31 | {"current ID stale target", electron, launcher, AppUserModelID, shortcutRepair{target: launcher}}, |
| 32 | {"stable target stale icon", launcher, electron, AppUserModelID, shortcutRepair{icon: launcher}}, |
| 33 | {"studio ID", electron, electron, "io.reasonix.studio", shortcutRepair{}}, |
| 34 | {"Tauri ID", electron, electron, "dev.reasonix.desktop", shortcutRepair{}}, |
| 35 | {"unknown ID", electron, electron, "Custom.App", shortcutRepair{}}, |
| 36 | {"other install", filepath.Join(t.TempDir(), "Reasonix.exe"), electron, "Reasonix", shortcutRepair{}}, |
| 37 | {"Studio executable", filepath.Join(root, "Reasonix Studio.exe"), electron, "Reasonix", shortcutRepair{}}, |
| 38 | {"unrelated executable", filepath.Join(root, "other.exe"), electron, "", shortcutRepair{}}, |
| 39 | } { |
| 40 | t.Run(tt.name, func(t *testing.T) { |
| 41 | if got := planShortcutRepair(tt.target, tt.icon, tt.id, root, false); got != tt.want { |
| 42 | t.Fatalf("repair = %+v, want %+v", got, tt.want) |
| 43 | } |
| 44 | }) |
| 45 | } |
| 46 | if err := os.WriteFile(flat, []byte("flat desktop"), 0o600); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if got := planShortcutRepair(flat, flat, AppUserModelID, root, false); got != (shortcutRepair{}) { |
| 50 | t.Fatalf("live flat install must keep its entry: %+v", got) |
| 51 | } |
| 52 | if got := planShortcutRepair(flat, flat, AppUserModelID, root, true); got != (shortcutRepair{target: launcher, icon: launcher}) { |
| 53 | t.Fatalf("activated versioned install must replace stale flat entry: %+v", got) |
| 54 | } |
| 55 | if err := os.Remove(launcher); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if got := planShortcutRepair(electron, electron, "Reasonix", root, false); got != (shortcutRepair{identity: AppUserModelID}) { |
| 59 | t.Fatalf("missing launcher must not create a dangling entry: %+v", got) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestShortcutOwnershipResolvesLinksBeforeCheckingLayout(t *testing.T) { |
| 64 | root := t.TempDir() |
| 65 | outside := t.TempDir() |
| 66 | if err := os.MkdirAll(filepath.Join(root, "versions"), 0o700); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if err := os.Symlink(outside, filepath.Join(root, "versions", "v1.0.0")); err != nil { |
| 70 | t.Skipf("symbolic links unavailable: %v", err) |
| 71 | } |
| 72 | for _, suffix := range []string{"reasonix-desktop.exe", filepath.Join("app", "Reasonix.exe")} { |
| 73 | if ownedShortcutTarget(filepath.Join(root, "versions", "v1.0.0", suffix), root) { |
| 74 | t.Fatalf("external target %s treated as owned", suffix) |
| 75 | } |
| 76 | } |
| 77 | alias := filepath.Join(t.TempDir(), "current") |
| 78 | if err := os.Symlink(root, alias); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if !ownedShortcutTarget(filepath.Join(alias, "versions", "v2.0.0", "app", "Reasonix.exe"), root) { |
| 82 | t.Fatal("alias of the owned root with a pruned version must remain repairable") |
| 83 | } |
| 84 | if ownedShortcutTarget(filepath.Join(root, "Reasonix.exe"), filepath.Join(root, "missing-root")) { |
| 85 | t.Fatal("missing installation root must not establish ownership") |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestCanonicalShortcutMigratesLegacyButPreservesCustomProperties(t *testing.T) { |
| 90 | root := t.TempDir() |
| 91 | canonical := filepath.Join(root, "Reasonix.exe") |
| 92 | legacy := filepath.Join(root, "reasonix-launcher.exe") |
| 93 | for _, path := range []string{canonical, legacy} { |
| 94 | if err := os.WriteFile(path, []byte("launcher"), 0o600); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | } |
| 98 | for _, tc := range []struct { |
| 99 | name, target, icon, id string |
| 100 | want shortcutRepair |
| 101 | }{ |
| 102 | {"legacy stable", legacy, legacy, AppUserModelID, shortcutRepair{target: canonical, icon: canonical}}, |
| 103 | {"custom icon", legacy, filepath.Join(root, "custom.ico"), AppUserModelID, shortcutRepair{target: canonical}}, |
| 104 | {"canonical", canonical, canonical, AppUserModelID, shortcutRepair{}}, |
| 105 | {"foreign identity", legacy, legacy, "io.reasonix.studio", shortcutRepair{}}, |
| 106 | } { |
| 107 | t.Run(tc.name, func(t *testing.T) { |
| 108 | if got := planShortcutRepair(tc.target, tc.icon, tc.id, root, true); got != tc.want { |
| 109 | t.Fatalf("got %+v, want %+v", got, tc.want) |
| 110 | } |
| 111 | }) |
| 112 | } |
| 113 | oldTarget := filepath.Join(root, "versions", "v1.38.9", "app", "Reasonix.exe") |
| 114 | for _, tc := range []struct { |
| 115 | dir string |
| 116 | want bool |
| 117 | }{ |
| 118 | {"", true}, {filepath.Dir(oldTarget), true}, {root, false}, {t.TempDir(), false}, {filepath.Join(root, "workspace"), false}, |
| 119 | } { |
| 120 | if got := repairShortcutWorkingDirectory(tc.dir, oldTarget, root); got != tc.want { |
| 121 | t.Fatalf("directory %q: got %v, want %v", tc.dir, got, tc.want) |
| 122 | } |
| 123 | } |
| 124 | if err := os.Remove(canonical); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | if got := planShortcutRepair(legacy, legacy, AppUserModelID, root, true); got != (shortcutRepair{}) { |
| 128 | t.Fatalf("missing canonical entry broke legacy shortcut: %+v", got) |
| 129 | } |
| 130 | } |
| 131 |