| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/json" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "aead.dev/minisign" |
| 12 | |
| 13 | "reasonix/desktop/internal/update" |
| 14 | ) |
| 15 | |
| 16 | // TestSignFiles signs a file with a throwaway key pair (injected via env, exactly |
| 17 | // as CI passes the real key) and verifies the produced .minisig validates under the |
| 18 | // matching public key. |
| 19 | func TestSignFiles(t *testing.T) { |
| 20 | pub, priv, err := minisign.GenerateKey(rand.Reader) |
| 21 | if err != nil { |
| 22 | t.Fatal(err) |
| 23 | } |
| 24 | enc, err := minisign.EncryptKey("pw", priv) |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | t.Setenv("MINISIGN_PRIVATE_KEY", string(enc)) |
| 29 | t.Setenv("MINISIGN_PASSWORD", "pw") |
| 30 | |
| 31 | dir := t.TempDir() |
| 32 | artifact := filepath.Join(dir, "Reasonix-linux-amd64.tar.gz") |
| 33 | payload := []byte("pretend this is a release tarball") |
| 34 | if err := os.WriteFile(artifact, payload, 0o644); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | |
| 38 | if err := signFiles([]string{artifact}); err != nil { |
| 39 | t.Fatalf("signFiles: %v", err) |
| 40 | } |
| 41 | sig, err := os.ReadFile(artifact + ".minisig") |
| 42 | if err != nil { |
| 43 | t.Fatalf("read signature: %v", err) |
| 44 | } |
| 45 | if !minisign.Verify(pub, payload, sig) { |
| 46 | t.Fatal("produced signature does not verify under the signing key") |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | // TestGenManifest builds a manifest from a directory of fake artifacts and checks |
| 51 | // every platform is listed with a download URL, a parallel .minisig URL, and a |
| 52 | // non-empty digest. The .minisig and latest.json files must be ignored. |
| 53 | func TestGenManifest(t *testing.T) { |
| 54 | dir := t.TempDir() |
| 55 | names := []string{ |
| 56 | "Reasonix-darwin-arm64.zip", |
| 57 | "Reasonix-darwin-amd64.zip", |
| 58 | "Reasonix-darwin-universal.dmg", |
| 59 | "Reasonix-windows-amd64-installer.exe", |
| 60 | "Reasonix-windows-amd64.zip", // portable download, not the updater channel |
| 61 | "Reasonix-windows-arm64-installer.exe", |
| 62 | "Reasonix-windows-arm64.zip", // portable download, not the updater channel |
| 63 | "Reasonix-linux-amd64.tar.gz", |
| 64 | "Reasonix-linux-amd64.deb", // human download, not the updater channel |
| 65 | "Reasonix-linux-amd64.tar.gz.minisig", // must be skipped |
| 66 | "README.txt", // unmatched, must be skipped |
| 67 | } |
| 68 | for _, n := range names { |
| 69 | if err := os.WriteFile(filepath.Join(dir, n), []byte(n), 0o644); err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | } |
| 73 | t.Setenv("GITHUB_REPOSITORY", "esengine/reasonix") |
| 74 | |
| 75 | if err := genManifest(dir, "v1.2.0", "desktop-v1.2.0"); err != nil { |
| 76 | t.Fatalf("genManifest: %v", err) |
| 77 | } |
| 78 | raw, err := os.ReadFile(filepath.Join(dir, "latest.json")) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | var m update.Manifest |
| 83 | if err := json.Unmarshal(raw, &m); err != nil { |
| 84 | t.Fatalf("latest.json is not valid: %v", err) |
| 85 | } |
| 86 | if m.Version != "v1.2.0" { |
| 87 | t.Fatalf("version = %q, want v1.2.0", m.Version) |
| 88 | } |
| 89 | if m.DownloadPage != "https://reasonix.io/?download=desktop#start" { |
| 90 | t.Fatalf("download_page = %q, want official install page", m.DownloadPage) |
| 91 | } |
| 92 | if m.ReleaseNotesURL != "https://reasonix.io/changelog/v1.2.0/" { |
| 93 | t.Fatalf("release_notes_url = %q, want exact version history", m.ReleaseNotesURL) |
| 94 | } |
| 95 | if len(m.Platforms) != 5 { |
| 96 | t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms) |
| 97 | } |
| 98 | win, ok := m.Platforms["windows-amd64"] |
| 99 | if !ok { |
| 100 | t.Fatal("windows-amd64 missing") |
| 101 | } |
| 102 | wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe" |
| 103 | if win.URL != wantURL { |
| 104 | t.Fatalf("windows url = %q, want %q", win.URL, wantURL) |
| 105 | } |
| 106 | if win.Sig != wantURL+".minisig" { |
| 107 | t.Fatalf("windows sig = %q, want %q.minisig", win.Sig, wantURL) |
| 108 | } |
| 109 | if win.SHA256 == "" || win.Size == 0 { |
| 110 | t.Fatalf("windows asset missing digest/size: %+v", win) |
| 111 | } |
| 112 | // The Windows updater channel is the per-arch -installer.exe; the portable .zip |
| 113 | // must not shadow the windows-arm64 key. |
| 114 | arm, ok := m.Platforms["windows-arm64"] |
| 115 | if !ok { |
| 116 | t.Fatal("windows-arm64 missing") |
| 117 | } |
| 118 | if !strings.HasSuffix(arm.URL, "/Reasonix-windows-arm64-installer.exe") { |
| 119 | t.Fatalf("windows-arm64 url = %q, want the installer, not the portable zip", arm.URL) |
| 120 | } |
| 121 | // The Linux portable channel stays the .tar.gz; the co-located .deb lands |
| 122 | // only in native_packages so older clients keep resolving platforms["linux-amd64"]. |
| 123 | lin, ok := m.Platforms["linux-amd64"] |
| 124 | if !ok { |
| 125 | t.Fatal("linux-amd64 missing") |
| 126 | } |
| 127 | if !strings.HasSuffix(lin.URL, "/Reasonix-linux-amd64.tar.gz") { |
| 128 | t.Fatalf("linux-amd64 url = %q, want the .tar.gz, not the .deb", lin.URL) |
| 129 | } |
| 130 | if lin.Sig == "" || lin.SHA256 == "" || lin.Size == 0 { |
| 131 | t.Fatalf("linux portable asset incomplete: %+v", lin) |
| 132 | } |
| 133 | deb, ok := m.NativePackages["linux-amd64"] |
| 134 | if !ok { |
| 135 | t.Fatal("native_packages linux-amd64 missing") |
| 136 | } |
| 137 | if !strings.HasSuffix(deb.URL, "/Reasonix-linux-amd64.deb") { |
| 138 | t.Fatalf("native linux-amd64 url = %q, want the .deb", deb.URL) |
| 139 | } |
| 140 | if deb.Sig != deb.URL+".minisig" || deb.SHA256 == "" || deb.Size == 0 { |
| 141 | t.Fatalf("native linux asset incomplete: %+v", deb) |
| 142 | } |
| 143 | if len(m.Downloads) != 2 { |
| 144 | t.Fatalf("want 2 website downloads, got %d: %+v", len(m.Downloads), m.Downloads) |
| 145 | } |
| 146 | for _, name := range []string{"Reasonix-darwin-universal.dmg", "Reasonix-windows-amd64.zip"} { |
| 147 | asset, ok := m.Downloads[name] |
| 148 | if !ok { |
| 149 | t.Fatalf("website download %q missing", name) |
| 150 | } |
| 151 | if !strings.HasSuffix(asset.URL, "/"+name) || |
| 152 | asset.Sig != asset.URL+".minisig" || |
| 153 | asset.SHA256 == "" || |
| 154 | asset.Size == 0 { |
| 155 | t.Fatalf("website download %q incomplete: %+v", name, asset) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | func TestGenManifestCanReuseStableNotesForStandaloneRC(t *testing.T) { |
| 161 | dir := t.TempDir() |
| 162 | if err := os.WriteFile(filepath.Join(dir, "Reasonix-linux-amd64.tar.gz"), []byte("rc"), 0o644); err != nil { |
| 163 | t.Fatal(err) |
| 164 | } |
| 165 | if err := genManifest(dir, "v1.3.0-rc.1", "desktop-v1.3.0-rc.1", "v1.3.0"); err != nil { |
| 166 | t.Fatalf("genManifest: %v", err) |
| 167 | } |
| 168 | raw, err := os.ReadFile(filepath.Join(dir, "latest.json")) |
| 169 | if err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | var m update.Manifest |
| 173 | if err := json.Unmarshal(raw, &m); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if m.ReleaseNotesURL != "https://reasonix.io/changelog/v1.3.0/" { |
| 177 | t.Fatalf("release_notes_url = %q, want stable base history", m.ReleaseNotesURL) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // TestGenManifestIgnoresUnknownNativePackages ensures a .deb without a known |
| 182 | // platform key is skipped rather than inventing a native_packages entry. |
| 183 | func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) { |
| 184 | dir := t.TempDir() |
| 185 | for _, n := range []string{ |
| 186 | "Reasonix-linux-amd64.tar.gz", |
| 187 | "Reasonix-mystery.deb", |
| 188 | } { |
| 189 | if err := os.WriteFile(filepath.Join(dir, n), []byte(n), 0o644); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | } |
| 193 | t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix") |
| 194 | if err := genManifest(dir, "v1.2.0", "desktop-v1.2.0"); err != nil { |
| 195 | t.Fatalf("genManifest: %v", err) |
| 196 | } |
| 197 | raw, err := os.ReadFile(filepath.Join(dir, "latest.json")) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | var m update.Manifest |
| 202 | if err := json.Unmarshal(raw, &m); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | if len(m.NativePackages) != 0 { |
| 206 | t.Fatalf("unexpected native_packages: %+v", m.NativePackages) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) { |
| 211 | dir := t.TempDir() |
| 212 | for _, name := range update.WindowsPayloadFileNames() { |
| 213 | if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | } |
| 217 | if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName)) |
| 221 | if err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4") |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | for _, name := range update.WindowsPayloadFileNames() { |
| 229 | want := update.WindowsPayloadSHA256([]byte("payload:" + name)) |
| 230 | if hashes[name] != want { |
| 231 | t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want) |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 |